Repository: Azure/ARO-RP
Pull request #1817 (2021-10-28 11:42:06)

fixing user admin kubeconfig api-int to api on PUCM
### Which issue this PR addresses:

<!--
Please include a link to the ADO work item as well as any GitHub issues.

Usage: `Fixes #<GitHub issue number>`, or `Fixes (paste link of issue)`.
-->
Fixes ADO-12394839

### What this PR does / why we need it:

On PUCM one of the update steps checks if the user admin kubeconfig is pointing to api-int. URI, and repoints it to api. URI if needed. Since I was refactoring that code the kubeconfig will also be regenerated if the expiry date is too close (<90 days) or if some of the fields are malformed.

<!--
Include a brief summary of what the PR is intended to accomplish and how the PR
does it. (2-3 sentences)
-->

### Test plan for issue:
No E2E test yet.

Added unit test for the UserAdminKubeconfig generation.

Manual test:
1. run local rp from commit before PR
2. create a cluster
3. Pull kubeconfig with azext
4. run local rp from this PR
5. `curl -v -H "Content-Type: application/json" -d"{}" -X PATCH -k https://127.0.0.1:8443/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCEGROUP/providers/Microsoft.RedHatOpenShift/openShiftClusters/$CLUSTER?api-version=admin`
6. Pull kubeconfig with azext
The old kubeconfig should point to api-int, new to api
<!--
How did you test that this PR works?

- Are there unit tests?
- Are there integration/e2e tests?
- If it is not possible to write automated tests, explain why and document how
  to manually test and verify the feature.
-->

### Is there any documentation that needs to be updated for this PR?

N/A fixing a bug.
<!--
- If yes and the docs are in GitHub, include doc updates in the PR.
- If yes and the docs are not in GitHub (i.e. ADO wiki), include a link to the
  docs.
- If no, explain why (e.g. "tech debt cleanup, N/A").
-->



Patch:
diff --git a/cmd/aro/portal.go b/cmd/aro/portal.go
index e10f44f25..54ac9b815 100644
--- a/cmd/aro/portal.go
+++ b/cmd/aro/portal.go
@@ -158,7 +158,9 @@ func portal(ctx context.Context, log *logrus.Entry, audit *logrus.Entry) error {
 		return err
 	}
 
-	hostname := "localhost:8444"
+	// In development the portal API is proxied by the frontend dev server which is
+	// hosted at localhost:3000, so the hostname needs to be set to that
+	hostname := "localhost:3000"
 	address := "localhost:8444"
 	sshAddress := "localhost:2222"
 	if !_env.IsLocalDevelopmentMode() {
@@ -177,7 +179,7 @@ func portal(ctx context.Context, log *logrus.Entry, audit *logrus.Entry) error {
 		return err
 	}
 
-	log.Print("listening")
+	log.Printf("listening %s", address)
 
 	p := pkgportal.NewPortal(_env, audit, log.WithField("component", "portal"), log.WithField("component", "portal-access"), l, sshl, verifier, hostname, servingKey, servingCerts, clientID, clientKey, clientCerts, sessionKey, sshKey, groupIDs, elevatedGroupIDs, dbOpenShiftClusters, dbPortal, dialer, m)
 
diff --git a/docs/portal-ui.md b/docs/portal-ui.md
index d162e3f58..f554dc71d 100644
--- a/docs/portal-ui.md
+++ b/docs/portal-ui.md
@@ -11,3 +11,48 @@ You will require Node.js and `npm`. These instructions were tested with the vers
 1. Run `make generate`. This will regenerate the golang file containing the portal content to be served.
 
 1. Commit the results of `build-portal` and `generate`.
+
+## Pointing Portal At Fake APIServer
+
+1. Create a file containing the following as `fakekubeconfig`:
+
+```
+kind: Config
+apiVersion: v1
+clusters:
+- cluster:
+    insecure-skip-tls-verify: true
+    server: https://localhost:6443
+  name: test:6443
+contexts:
+- context:
+    cluster: test:6443
+    namespace: default
+    user: test
+  name: test
+current-context: test
+users:
+- name: test
+  user:
+    token: sha256~aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+```
+
+1. `source secrets/env`
+
+1. `go run hack/aead/aead.go --file fakekubeconfig`
+
+1. Replace the CosmosDB Kubeconfig entries with what's returned (excepting the logs)
+
+1. Replace `OpenShiftCluster.Properties.APIServerProfile.IP` value in CosmosDB with "127.0.0.1"
+
+## Running the fake API Server
+
+1. `go run hack/fakecluster/fakecluster.go`
+
+## Adding Fake Data
+
+1. Use `oc get --raw="/path/to/api` to get the raw output, and put it in `pkg/portal/cluster/testdata/<api.json>`
+
+1. Add a new route to `hack/fakecluster/fakecluster.go`
+
+1. Add new fetcher tests in `pkg/portal/cluster`, too!
diff --git a/hack/aead/aead.go b/hack/aead/aead.go
new file mode 100644
index 000000000..1eb67c55c
--- /dev/null
+++ b/hack/aead/aead.go
@@ -0,0 +1,88 @@
+package main
+
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache License 2.0.
+
+import (
+	"bufio"
+	"context"
+	"encoding/base64"
+	"flag"
+	"fmt"
+	"io"
+	"os"
+
+	"github.com/sirupsen/logrus"
+
+	"github.com/Azure/ARO-RP/pkg/api"
+	"github.com/Azure/ARO-RP/pkg/env"
+	"github.com/Azure/ARO-RP/pkg/util/encryption"
+	"github.com/Azure/ARO-RP/pkg/util/keyvault"
+	utillog "github.com/Azure/ARO-RP/pkg/util/log"
+)
+
+var (
+	fileName = flag.String("file", "-", "File to read. '-' for stdin.")
+)
+
+func run(ctx context.Context, log *logrus.Entry) error {
+	flag.Parse()
+
+	var (
+		file io.Reader
+		err  error
+		v    string
+	)
+
+	if *fileName == "-" {
+		file = os.Stdin
+	} else {
+		file, err = os.Open(*fileName)
+		if err != nil {
+			return err
+		}
+	}
+
+	scanner := bufio.NewScanner(file)
+	for scanner.Scan() {
+		v = v + scanner.Text() + "\n"
+	}
+
+	_env, err := env.NewCore(ctx, log)
+	if err != nil {
+		return err
+	}
+
+	msiKVAuthorizer, err := _env.NewMSIAuthorizer(env.MSIContextRP, _env.Environment().ResourceIdentifiers.KeyVault)
+	if err != nil {
+		return err
+	}
+
+	serviceKeyvaultURI, err := keyvault.URI(_env, env.ServiceKeyvaultSuffix)
+	if err != nil {
+		return err
+	}
+
+	serviceKeyvault := keyvault.NewManager(msiKVAuthorizer, serviceKeyvaultURI)
+
+	aead, err := encryption.NewMulti(ctx, serviceKeyvault, env.EncryptionSecretV2Name, env.EncryptionSecretName)
+	if err != nil {
+		return err
+	}
+
+	b, err := aead.Seal([]byte(api.SecureString(v)))
+	if err != nil {
+		return err
+	}
+
+	fmt.Println(base64.StdEncoding.EncodeToString(b))
+	return nil
+}
+
+func main() {
+	log := utillog.GetLogger()
+
+	if err := run(context.Background(), log); err != nil {
+		log.Fatal(err)
+	}
+}
diff --git a/hack/fakecluster/fakecluster.go b/hack/fakecluster/fakecluster.go
new file mode 100644
index 000000000..90f7b5436
--- /dev/null
+++ b/hack/fakecluster/fakecluster.go
@@ -0,0 +1,132 @@
+package main
+
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache License 2.0.
+
+import (
+	"context"
+	"crypto/tls"
+	"crypto/x509"
+	"encoding/json"
+	"flag"
+	"fmt"
+	"io/ioutil"
+	"log"
+	"net"
+	"net/http"
+	"time"
+
+	"github.com/gorilla/mux"
+	"github.com/sirupsen/logrus"
+
+	frontendmiddleware "github.com/Azure/ARO-RP/pkg/frontend/middleware"
+	"github.com/Azure/ARO-RP/pkg/portal/cluster"
+	"github.com/Azure/ARO-RP/pkg/portal/middleware"
+	utillog "github.com/Azure/ARO-RP/pkg/util/log"
+	"github.com/Azure/ARO-RP/pkg/util/version"
+)
+
+var (
+	certFile = flag.String("certFile", "secrets/proxy.crt", "file containing server certificate")
+	keyFile  = flag.String("keyFile", "secrets/proxy.key", "file containing server key")
+	port     = flag.Int("port", 6443, "Port to listen on")
+	host     = flag.String("host", "localhost", "Host to listen on")
+)
+
+func run(ctx context.Context, l *logrus.Entry) error {
+	l.Printf("starting, git commit %s", version.GitCommit)
+
+	flag.Parse()
+
+	cert, err := ioutil.ReadFile(*certFile)
+	if err != nil {
+		panic(err)
+	}
+
+	b, err := ioutil.ReadFile(*keyFile)
+	if err != nil {
+		panic(err)
+	}
+
+	key, err := x509.ParsePKCS1PrivateKey(b)
+	if err != nil {
+		panic(err)
+	}
+
+	config := &tls.Config{
+		Certificates: []tls.Certificate{
+			{
+				Certificate: [][]byte{
+					cert,
+				},
+				PrivateKey: key,
+			},
+		},
+		NextProtos: []string{"h2", "http/1.1"},
+		CipherSuites: []uint16{
+			tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
+			tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
+			tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
+			tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
+			tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
+			tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
+		},
+		PreferServerCipherSuites: true,
+		SessionTicketsDisabled:   true,
+		MinVersion:               tls.VersionTLS12,
+		CurvePreferences: []tls.CurveID{
+			tls.CurveP256,
+			tls.X25519,
+		},
+	}
+
+	r := mux.NewRouter()
+	r.Use(middleware.Panic(l))
+
+	r.NewRoute().PathPrefix(
+		"/apis/config.openshift.io/v1/clusteroperators",
+	).HandlerFunc(resp(cluster.MustAsset("clusteroperator.json")))
+
+	s := &http.Server{
+		Handler:     frontendmiddleware.Lowercase(r),
+		ReadTimeout: 10 * time.Second,
+		IdleTimeout: 2 * time.Minute,
+		ErrorLog:    log.New(l.Writer(), "", 0),
+		BaseContext: func(net.Listener) context.Context { return ctx },
+	}
+
+	l.Printf("Listening on %s", fmt.Sprint(*host, ":", *port))
+	lis, err := net.Listen("tcp", fmt.Sprint(*host, ":", *port))
+	if err != nil {
+		return err
+	}
+
+	return s.Serve(tls.NewListener(lis, config))
+}
+
+func resp(bytes []byte) func(http.ResponseWriter, *http.Request) {
+	e := func(w http.ResponseWriter, r *http.Request) {
+		var resp interface{}
+		err := json.Unmarshal(bytes, &resp)
+		if err != nil {
+			return
+		}
+
+		b, err := json.MarshalIndent(resp, "", "    ")
+		if err != nil {
+			return
+		}
+
+		w.Header().Set("Content-Type", "application/json")
+		_, _ = w.Write(b)
+	}
+	return e
+}
+
+func main() {
+	log := utillog.GetLogger()
+
+	if err := run(context.Background(), log); err != nil {
+		log.Fatal(err)
+	}
+}
diff --git a/pkg/cluster/delete.go b/pkg/cluster/delete.go
index 8e58d442c..85f1e290b 100644
--- a/pkg/cluster/delete.go
+++ b/pkg/cluster/delete.go
@@ -10,11 +10,13 @@ import (
 	"net/http"
 	"sort"
 	"strings"
+	"time"
 
 	mgmtfeatures "github.com/Azure/azure-sdk-for-go/services/resources/mgmt/2019-07-01/features"
 	"github.com/Azure/go-autorest/autorest"
 	"github.com/Azure/go-autorest/autorest/azure"
 	"github.com/davecgh/go-spew/spew"
+	"k8s.io/apimachinery/pkg/util/wait"
 
 	"github.com/Azure/ARO-RP/pkg/api"
 	"github.com/Azure/ARO-RP/pkg/database/cosmosdb"
@@ -260,6 +262,29 @@ func (m *manager) deleteRoleDefinition(ctx context.Context) error {
 	return nil
 }
 
+func (m *manager) deleteGatewayAndWait(ctx context.Context) error {
+	timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
+	defer cancel()
+
+	if m.doc.OpenShiftCluster.Properties.NetworkProfile.GatewayPrivateLinkID == "" {
+		return nil
+	}
+
+	err := m.deleteGateway(ctx)
+	if err != nil {
+		return err
+	}
+
+	m.log.Info("waiting for gateway record deletion")
+	return wait.PollImmediateUntil(15*time.Second, func() (bool, error) {
+		_, err := m.dbGateway.Get(ctx, m.doc.OpenShiftCluster.Properties.NetworkProfile.GatewayPrivateLinkID)
+		if err != nil && cosmosdb.IsErrorStatusCode(err, http.StatusNotFound) /* already gone */ {
+			return true, nil
+		}
+		return false, nil
+	}, timeoutCtx.Done())
+}
+
 func (m *manager) deleteGateway(ctx context.Context) error {
 	if m.doc.OpenShiftCluster.Properties.NetworkProfile.GatewayPrivateLinkID == "" {
 		return nil
@@ -268,7 +293,7 @@ func (m *manager) deleteGateway(ctx context.Context) error {
 	// https://docs.microsoft.com/en-us/azure/cosmos-db/change-feed-design-patterns#deletes
 	_, err := m.dbGateway.Patch(ctx, m.doc.OpenShiftCluster.Properties.NetworkProfile.GatewayPrivateLinkID, func(doc *api.GatewayDocument) error {
 		doc.Gateway.Deleting = true
-		doc.TTL = 600
+		doc.TTL = 60
 		return nil
 	})
 	if err != nil && !cosmosdb.IsErrorStatusCode(err, http.StatusNotFound) /* already gone */ {
@@ -349,13 +374,17 @@ func (m *manager) Delete(ctx context.Context) error {
 		return err
 	}
 
-	err = m.deleteResourcesAndResourceGroup(ctx)
+	// private endpoint LinkIDs are reused so we wait for the deletion of the
+	// gateway LinkID record before deleting the private endpoint
+	// this ensures that we don't delete a LinkID record that was previously in use
+	// on a newly created cluster
+	m.log.Printf("deleting gateway record")
+	err = m.deleteGatewayAndWait(ctx)
 	if err != nil {
 		return err
 	}
 
-	m.log.Printf("deleting gateway record")
-	err = m.deleteGateway(ctx)
+	err = m.deleteResourcesAndResourceGroup(ctx)
 	if err != nil {
 		return err
 	}
diff --git a/pkg/cluster/fixuserkubeconfig.go b/pkg/cluster/fixuserkubeconfig.go
index cb74b0d13..5c3b9ad07 100644
--- a/pkg/cluster/fixuserkubeconfig.go
+++ b/pkg/cluster/fixuserkubeconfig.go
@@ -11,10 +11,8 @@ import (
 )
 
 // fixUserAdminKubeconfig adds shorter kubeconfig for user to return
-// TODO(mjudeikis): This will one 1 year kubeconfig. We should add check for -90 days
-// and rotate it.
 func (m *manager) fixUserAdminKubeconfig(ctx context.Context) error {
-	if len(m.doc.OpenShiftCluster.Properties.UserAdminKubeconfig) > 0 {
+	if m.checkUserAdminKubeconfigUpdated() {
 		return nil
 	}
 
diff --git a/pkg/cluster/ipaddresses.go b/pkg/cluster/ipaddresses.go
index d04437787..5817875bc 100644
--- a/pkg/cluster/ipaddresses.go
+++ b/pkg/cluster/ipaddresses.go
@@ -255,10 +255,25 @@ func (m *manager) ensureGatewayCreate(ctx context.Context) error {
 			ImageRegistryStorageAccountName: m.doc.OpenShiftCluster.Properties.ImageRegistryStorageAccountName,
 		},
 	})
-	if err != nil && !cosmosdb.IsErrorStatusCode(err, http.StatusConflict) /* already exists */ {
+
+	recordExists := err != nil && cosmosdb.IsErrorStatusCode(err, http.StatusConflict)
+	if err != nil && !recordExists /* already exists */ {
 		return err
 	}
 
+	// ensure the record is this clusters if it exists
+	if recordExists {
+		gwyDoc, err := m.dbGateway.Get(ctx, linkIdentifier)
+		if err != nil {
+			return err
+		}
+		if !strings.EqualFold(gwyDoc.ID, m.doc.OpenShiftCluster.ID) ||
+			!strings.EqualFold(gwyDoc.Gateway.ImageRegistryStorageAccountName, m.doc.OpenShiftCluster.Properties.ImageRegistryStorageAccountName) ||
+			!strings.EqualFold(gwyDoc.Gateway.StorageSuffix, m.doc.OpenShiftCluster.Properties.StorageSuffix) {
+			return errors.New("gateway record already exists for a different cluster")
+		}
+	}
+
 	m.doc, err = m.db.PatchWithLease(ctx, m.doc.Key, func(doc *api.OpenShiftClusterDocument) error {
 		doc.OpenShiftCluster.Properties.NetworkProfile.GatewayPrivateEndpointIP = *(*(*pe.PrivateEndpointProperties.NetworkInterfaces)[0].IPConfigurations)[0].PrivateIPAddress
 		doc.OpenShiftCluster.Properties.NetworkProfile.GatewayPrivateLinkID = linkIdentifier
diff --git a/pkg/cluster/kubeconfig.go b/pkg/cluster/kubeconfig.go
index c758acbb9..2c6711984 100644
--- a/pkg/cluster/kubeconfig.go
+++ b/pkg/cluster/kubeconfig.go
@@ -18,6 +18,7 @@ import (
 
 	"github.com/Azure/ARO-RP/pkg/api"
 	"github.com/Azure/ARO-RP/pkg/cluster/graph"
+	utilpem "github.com/Azure/ARO-RP/pkg/util/pem"
 	"github.com/Azure/ARO-RP/pkg/util/stringutils"
 )
 
@@ -25,19 +26,64 @@ import (
 // kubeconfig for the ARO service, based on the admin kubeconfig found in the
 // graph.
 func (m *manager) generateAROServiceKubeconfig(pg graph.PersistedGraph) (*kubeconfig.AdminInternalClient, error) {
-	return generateKubeconfig(pg, "system:aro-service", []string{"system:masters"}, tls.ValidityTenYears)
+	return generateKubeconfig(pg, "system:aro-service", []string{"system:masters"}, tls.ValidityTenYears, true)
 }
 
 // generateAROSREKubeconfig generates additional admin credentials and a
 // kubeconfig for ARO SREs, based on the admin kubeconfig found in the graph.
 func (m *manager) generateAROSREKubeconfig(pg graph.PersistedGraph) (*kubeconfig.AdminInternalClient, error) {
-	return generateKubeconfig(pg, "system:aro-sre", nil, tls.ValidityTenYears)
+	return generateKubeconfig(pg, "system:aro-sre", nil, tls.ValidityTenYears, true)
+}
+
+// checkUserAdminKubeconfigUpdated checks if the user kubeconfig is
+// present, has >90days until expiry, has the right settings
+func (m *manager) checkUserAdminKubeconfigUpdated() bool {
+	if len(m.doc.OpenShiftCluster.Properties.UserAdminKubeconfig) == 0 {
+		// field empty, not updated
+		return false
+	}
+	var userAdminKubeconfig clientcmdv1.Config
+	err := yaml.Unmarshal([]byte(m.doc.OpenShiftCluster.Properties.UserAdminKubeconfig), &userAdminKubeconfig)
+	if err != nil {
+		// yaml invalid, not updated
+		return false
+	}
+	for i := range userAdminKubeconfig.Clusters {
+		if strings.HasPrefix(userAdminKubeconfig.Clusters[i].Cluster.Server, "https://api-int.") {
+			// URL pointing to api-int, not updated
+			// TODO remove this after PUCM has been run on all clusters.
+			return false
+		}
+	}
+	for i := range userAdminKubeconfig.AuthInfos {
+		var b []byte
+		b = append(b, userAdminKubeconfig.AuthInfos[i].AuthInfo.ClientCertificateData...)
+		b = append(b, userAdminKubeconfig.AuthInfos[i].AuthInfo.ClientKeyData...)
+		innerkey, innercert, err := utilpem.Parse(b)
+		if err != nil {
+			// error while parsing cert or key, not updated
+			return false
+		}
+		if innerkey == nil {
+			// no client key, not updated
+			return false
+		}
+		for j := range innercert {
+			if !innercert[j].NotAfter.After(time.Now().AddDate(0, 0, 90)) {
+				// Not After field in certificate closer than 90 days, not updated
+				return false
+			}
+		}
+	}
+
+	// passed all checks, it's up to date
+	return true
 }
 
 // generateUserAdminKubeconfig generates additional admin credentials and a
 // kubeconfig for ARO User, based on the admin kubeconfig found in the graph.
 func (m *manager) generateUserAdminKubeconfig(pg graph.PersistedGraph) (*kubeconfig.AdminInternalClient, error) {
-	return generateKubeconfig(pg, "system:admin", nil, tls.ValidityOneYear)
+	return generateKubeconfig(pg, "system:admin", nil, tls.ValidityOneYear, false)
 }
 
 func (m *manager) generateKubeconfigs(ctx context.Context) error {
@@ -88,7 +134,7 @@ func (m *manager) generateKubeconfigs(ctx context.Context) error {
 	return err
 }
 
-func generateKubeconfig(pg graph.PersistedGraph, commonName string, organization []string, validity time.Duration) (*kubeconfig.AdminInternalClient, error) {
+func generateKubeconfig(pg graph.PersistedGraph, commonName string, organization []string, validity time.Duration, internal bool) (*kubeconfig.AdminInternalClient, error) {
 	var ca *tls.AdminKubeConfigSignerCertKey
 	var adminInternalClient *kubeconfig.AdminInternalClient
 	err := pg.Get(&ca, &adminInternalClient)
@@ -135,6 +181,14 @@ func generateKubeconfig(pg graph.PersistedGraph, commonName string, organization
 		CurrentContext: commonName,
 	}
 
+	if !internal {
+		for i := range aroInternalClient.Config.Clusters {
+			// user kubeconfig should point to external URL not api-int, which has a properly signed cert
+			aroInternalClient.Config.Clusters[i].Cluster.Server = strings.Replace(aroInternalClient.Config.Clusters[i].Cluster.Server, "https://api-int.", "https://api.", 1)
+			aroInternalClient.Config.Clusters[i].Cluster.CertificateAuthorityData = nil
+		}
+	}
+
 	data, err := yaml.Marshal(aroInternalClient.Config)
 	if err != nil {
 		return nil, err
diff --git a/pkg/frontend/openshiftcluster_putorpatch.go b/pkg/frontend/openshiftcluster_putorpatch.go
index 94d39f44f..c7d1d597c 100644
--- a/pkg/frontend/openshiftcluster_putorpatch.go
+++ b/pkg/frontend/openshiftcluster_putorpatch.go
@@ -84,9 +84,10 @@ func (f *frontend) _putOrPatchOpenShiftCluster(ctx context.Context, log *logrus.
 				},
 			},
 		}
-		if !f.env.IsLocalDevelopmentMode() /* not local dev or CI */ {
-			doc.OpenShiftCluster.Properties.FeatureProfile.GatewayEnabled = true
-		}
+		// TODO (BV): reenable gateway on create once we fix bugs
+		// if !f.env.IsLocalDevelopmentMode() /* not local dev or CI */ {
+		// 	doc.OpenShiftCluster.Properties.FeatureProfile.GatewayEnabled = true
+		// }
 	}
 
 	doc.CorrelationData = correlationData
diff --git a/pkg/gateway/linkid.go b/pkg/gateway/linkid.go
index dd20406a5..83d1934ce 100644
--- a/pkg/gateway/linkid.go
+++ b/pkg/gateway/linkid.go
@@ -6,6 +6,7 @@ package gateway
 import (
 	"encoding/binary"
 	"errors"
+	"fmt"
 	"strconv"
 	"strings"
 
@@ -35,7 +36,7 @@ func (g *gateway) isAllowed(conn *proxyproto.Conn, host string) (string, bool, e
 	g.mu.RUnlock()
 
 	if gateway == nil || gateway.Deleting {
-		return "", false, errors.New("gateway record not found")
+		return "", false, fmt.Errorf("gateway record not found for linkID %s", linkID)
 	}
 
 	if _, found := g.allowList[strings.ToLower(host)]; found {
diff --git a/pkg/operator/controllers/rbac/bindata.go b/pkg/operator/controllers/rbac/bindata.go
index 6bea13456..66022d580 100644
--- a/pkg/operator/controllers/rbac/bindata.go
+++ b/pkg/operator/controllers/rbac/bindata.go
@@ -78,7 +78,7 @@ func (fi bindataFileInfo) Sys() interface{} {
 	return nil
 }
 
-var _clusterroleYaml = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\xbc\x5a\x4f\xaf\xe3\xb8\x0d\xbf\xe7\x53\x18\xdb\xc3\x02\x05\x5e\x06\x45\x2f\xc5\xeb\x71\xb7\x28\x0a\x14\x5d\x60\x30\xed\x9d\x91\x19\x9b\x1b\x59\xd4\x48\x54\xde\xa4\x9f\xbe\x90\x2c\x39\x76\xfe\xd9\x71\x3a\x73\x8a\x4d\x49\xfc\x27\x8a\xe4\x4f\xce\x1f\xaa\x5f\xb8\xc6\xaa\x41\x83\x0e\x04\xeb\x6a\x77\xaa\x5a\x50\x87\x4f\x0d\x9a\x9a\xbc\xe2\x23\xba\x93\x02\xd5\xe2\x5f\xab\x5f\x7f\xab\xfe\xf5\xdb\x97\xea\x6f\xbf\xfe\xe3\xcb\x76\x03\x96\xfe\x83\xce\x13\x9b\xf7\xca\xed\x40\x6d\x21\x48\xcb\x8e\xfe\x0b\x42\x6c\xb6\x87\xbf\xf8\x2d\xf1\xa7\xe3\x9f\x36\x07\x32\xf5\x7b\xf5\x8b\x0e\x5e\xd0\x7d\x66\x8d\x9b\x0e\x05\x6a\x10\x78\xdf\x54\x95\x72\x98\x16\x7c\xa1\x0e\xbd\x40\x67\xdf\x2b\x13\xb4\xde\x54\x95\x81\x0e\xdf\x2b\x7f\xf2\x82\xdd\x3b\x38\x7e\xf3\x0e\x37\x2e\x68\xf4\xef\x9b\xb7\x0a\x2c\xfd\xdd\x71\xb0\x3e\x32\x79\xab\x7e\xfa\x69\x53\x55\x0e\x3d\x07\xa7\x30\xd3\x14\x77\x96\x0d\x1a\xf1\x02\x12\x3c\xfa\x4d\x55\x1d\xd1\xed\xf2\x70\x83\x92\x7e\x35\x79\x59\xca\xd0\xec\xa9\xe9\xc0\xfa\xf4\x8a\xa6\xb6\x4c\x46\xf2\xdb\x11\xcb\xa3\xa6\x8e\xc4\x81\x69\xb0\x7f\x8f\x96\x78\x0b\xaa\xbc\x72\x9d\x9f\x6c\x74\xa0\x17\x34\x72\x64\x1d\x3a\x54\x1a\xa8\xbb\x3d\x94\xa9\x5c\x0f\x0f\x82\x9d\xd5\x20\x79\xc4\xa1\xd5\xa4\x92\x2b\x15\x1b\x71\xac\x35\xba\x32\xd4\x5b\xf1\x35\xb0\x40\x4f\xf2\xe8\x8e\xa4\x10\x94\xe2\x50\xb4\xce\xb4\x47\x5e\x8a\x0f\x1f\x20\xaa\x5d\xe6\xaf\xa8\xed\x27\xcd\xcd\x35\xc7\xab\xe5\x50\x77\xe4\x63\x30\x39\x6c\xc8\x8b\x1b\x07\xd1\x35\xe3\x2e\x08\x08\x99\xe6\x03\x77\x2d\xf3\xa1\xdf\x97\xd0\x2f\xea\x8d\x39\x82\xa6\xfa\xe1\x9c\x15\x36\x82\x25\xfc\x26\x68\xa2\x9e\xfe\xae\x72\x2a\x78\xe1\xae\x10\x6b\xdc\x93\xa1\xd7\x84\x2e\xf2\x09\x58\x7a\x6d\x07\x33\x03\x74\x5b\xb6\x68\x7c\x4b\x7b\xb9\x27\xc8\xe1\xd7\x80\x5e\x86\xe0\x59\x25\x2d\x9d\xa2\xeb\x13\x96\x43\xd7\xe1\x91\xfc\xb0\x9d\x35\x60\xc7\xc6\x63\x0e\xd5\x1a\xad\xe6\x53\x37\x1c\xb8\x1c\xfc\xc3\x78\x3c\xf0\xb8\x0f\x3a\x13\x56\xaa\x37\xe3\x87\xb3\x12\x7d\x6c\xfd\x40\x49\x4b\x0f\x95\xe3\x19\xce\xaa\xcf\xca\x6b\x55\x0f\xd2\xa2\x91\x9c\x76\xee\x46\xa6\xf0\x01\x4d\xdc\x4f\xfc\xb8\x10\x94\x92\x3f\xde\x66\x7c\x59\x4a\xae\xf9\x7a\xd4\x7b\x1f\x76\xbf\xa3\x12\x50\x0a\xbd\x3f\xcb\x98\x0c\xa6\x9a\x31\x19\xbb\xbd\xe8\x69\xc5\x16\xf9\xd6\xb1\xc6\x1d\x99\x9a\x4c\xe3\x2f\xe9\x39\x7a\x2f\x67\x94\xa1\xc5\xc5\xea\x19\xb5\xca\xeb\x0d\x97\xfd\x10\xb7\x8c\xac\x75\xe8\xc5\x91\x7a\x25\x39\x06\x61\xaf\x40\x93\x69\xae\x25\x25\x95\xd8\x08\x68\xcb\x75\x99\xf9\x4a\xb0\x17\x51\xcb\x36\x7e\x2a\xf1\xad\xea\x40\xb5\x64\xf0\x65\x45\x76\x89\x7c\x2d\xd5\xb1\xf9\x9d\x77\xbd\xac\xfc\xb0\x86\x7b\x20\x5d\xcf\x18\x98\xe6\x9c\x93\x5e\x26\x7c\x6f\x81\x4b\xb3\x9e\x42\x27\xb4\x8f\x49\x09\x1f\x14\xe9\xd1\x24\x6a\x4c\x0a\xc6\x54\xd3\x56\x5a\xa1\x34\x87\x5a\x39\xac\x63\x3e\x04\x3d\x17\x21\xc3\x44\xff\xa2\xd8\xb4\x09\xf3\x05\xbb\x2f\xec\xfe\x46\xd6\xbe\xd8\xc0\x21\x7a\xd9\x46\x3c\xc0\x6e\x42\x3c\xf6\x0d\xbf\x2f\xb2\xfd\x90\xc3\x6a\xe3\xf3\xd3\x1e\x41\x82\xc3\x66\xe8\x4c\xa9\x83\xd2\x08\x93\xd9\x3b\xf0\xe2\x82\x8a\x53\x0a\x2d\xe6\x81\xb2\xda\xa0\x7c\xb0\x3b\xf4\x2f\x1c\x55\xcd\x8f\x59\x9d\x36\xe4\x08\xb7\x8e\x63\xb2\x1a\x5e\xbe\x51\xe6\xe0\x55\x8b\x75\x58\x7f\xbc\xb2\x59\x73\x3b\xd8\xcf\x52\x9a\x6a\xfe\x30\x9a\xa1\x9e\x38\x25\xb6\x89\xce\x80\xd6\xdc\x68\x32\x87\xc9\xd8\x15\xc1\x70\x0e\xc5\x4b\xd7\x5a\x1d\x1a\x9a\x92\xbe\x06\x52\x07\x2f\xe0\x64\x42\x3e\x41\xa7\x3d\x74\xf6\x71\xdd\x78\x6c\x75\x6c\xbc\xac\x06\x93\x4c\x4f\xce\x9e\xf1\x81\xe5\x3a\x6f\x97\x62\x63\x50\x09\x1d\x49\x4e\xaa\x45\x75\x58\xad\x05\xbb\x9a\xcc\xe3\xb2\xaf\x11\x1e\x63\xb9\x07\x02\x06\x44\x7b\x97\xfb\x00\xea\xf4\xfa\x4e\xba\x87\x82\xf7\x45\x14\xa4\xb8\x86\xf5\x80\x3f\xae\xf9\x8e\x4f\xd2\x0a\xd6\x7b\xcd\x1f\x39\x0c\xb6\x67\x2c\x70\xcf\x88\x38\x3b\x1e\xb5\x0e\xca\x11\x24\x76\x24\x27\x8d\x47\xd4\xff\x0f\xb0\xd5\xa2\xee\x66\x02\x30\x4e\x51\x2d\x38\x71\x68\xd9\x93\xb0\xa3\xb5\xc6\xa7\x24\x35\x23\x6e\x9c\xc8\xd2\xa3\x38\x84\xee\xbb\x0b\x4c\x52\x06\xd9\x73\xd5\xef\x49\xbe\x02\xcd\xc8\xa2\xfc\xb6\xb8\xef\x4c\x8b\x32\x3a\x3d\x2d\x4d\x1b\xe3\xc6\x21\x31\xb0\x2e\x98\xd5\xe9\x3a\x07\xfd\x52\xe1\xb5\xf1\x0e\x15\xbb\xb5\xdd\x4a\x3c\x0e\xca\xd0\x56\x19\xb5\xbf\x29\x20\x67\xc4\x37\x10\x01\xd5\x46\xcc\xf6\xf6\xf2\x15\x40\xee\x1c\x67\x4c\xcb\xb3\x5a\x04\x2d\xed\x90\x85\x07\xfa\xf4\x65\x35\x30\xce\x0c\x26\x27\x7c\x7e\xc3\x05\xc8\xa0\x73\xc1\x08\x75\x38\x0e\x80\x33\xe0\x1f\x53\x0f\x61\x87\x1a\x65\x4c\x9a\xc8\xb5\xcc\xfa\x06\x79\xad\x49\x28\xa0\xff\x7c\xbb\xf1\x04\x87\x69\xb8\x65\x7f\xee\x35\xfa\x9b\x89\x0c\xd7\xd6\x09\x74\xa4\xee\x17\x88\xd1\x05\x21\x3f\x0c\xd4\x6b\xce\xd4\xcc\x5c\x13\x79\x61\x97\x4e\xff\xd0\x9f\x65\x4a\xee\xed\x06\x0e\x6b\x6d\x63\x93\x12\xb1\x69\xb6\x8a\x1d\xb2\xdf\x2a\xee\x6e\xf4\xa4\x1a\x9d\x74\x60\x62\xfe\x18\x6f\xf3\x98\x3e\xb8\x20\xf3\x1c\xfc\xbf\xc3\xe1\xb1\x43\x69\x31\xf8\x2b\x42\xc2\xfc\xbd\x79\xfd\xa5\xd8\x84\x87\xb4\x60\x38\xcd\x59\x9b\x76\xf2\x31\x5f\x86\x04\x27\x4d\x2d\xa6\x7c\x95\x49\x96\x35\xa9\xd2\xb9\xa6\x18\x0b\x3b\x53\x6e\xb1\x0c\xca\xe4\xe6\xf8\x35\x35\x97\x64\xc7\x5e\x37\xc7\x41\x8a\xf7\xcb\x42\x7b\xa0\xd7\x54\x88\x01\x71\x2f\x26\x73\x0e\x57\x1a\x06\x1c\x70\x13\x15\x8c\xbc\xb5\x46\x11\xae\xf1\xae\x0a\x25\x35\x0d\x2a\xac\x10\xb0\xd0\xc9\xb7\xf0\xd7\x05\x7c\x9c\x80\x2e\xe5\xa9\x76\x34\xc0\xb7\x8b\xec\x79\x86\x60\xca\x93\x37\x60\x7d\xcb\x72\xf9\x05\xe0\x8c\xce\x50\x54\x3d\x2a\xbd\x71\x62\x6c\x46\x93\x7e\x93\x60\x2c\x7b\x72\xc1\x29\x66\xe6\x0b\x3c\x19\x49\xe7\x69\x93\xb3\x1b\x87\x26\x78\x2c\x93\x6e\x64\x9c\x72\x34\xa7\x00\xb0\x38\xf2\x42\xe4\x40\xbf\x23\x37\x9f\x79\x05\x93\x0c\xf7\xe2\xb6\xfa\x87\x09\x4d\x81\x80\xe6\x26\xd3\xc6\xfb\x97\x95\x99\x60\x67\x32\x5e\x40\x27\xb8\x35\x3d\x67\x8a\x4d\x4d\xe7\xb8\x28\xe4\x26\xe9\x33\x55\xa7\x37\x2d\xec\xbc\x72\x64\x5f\xc8\xd8\x16\xd4\x21\x7a\x67\xbb\xcc\xd0\x3c\xbd\x03\x43\xfb\x99\x8b\x8b\x6b\x51\x31\xc2\x4e\x37\x91\x64\x4d\xde\x85\x64\xc6\x2e\xd4\x4d\x49\x81\xb1\xfe\xa1\x0a\x11\x58\xbc\x76\xf8\xf3\x75\xc1\x1c\xa2\xed\x67\xdd\xbe\x93\xb9\x63\xd2\x33\x8c\x57\x2a\x9f\x3e\xdc\xcd\x5e\xf2\x58\x4d\x58\x97\xcb\xe5\xcb\x4f\x7e\x8b\xb7\x68\x89\xac\x67\x85\x3c\x30\xed\xee\x97\xe3\x1f\x79\x9f\xfe\x48\xbf\x58\x0b\x67\x6f\xb4\x83\xac\xe5\x9f\x2f\x6d\x9e\xc7\x4e\x6b\x64\xf5\x99\xf8\x51\x25\x2e\x28\xfe\xa5\x3a\x58\x4e\xec\x96\x4c\x7f\x0b\x36\xe7\x3f\x30\x0d\x82\xd6\xac\x5e\x69\x3c\x07\xa9\x4f\x0b\x3b\xaf\x4d\xe5\xf0\x5b\xac\x2a\x5e\x1c\xd0\xea\x5b\x9a\x52\x87\xb7\xb9\xf2\xdc\xf5\x77\xfe\xb6\x5f\xca\xf6\xa8\x01\xba\x18\xc9\x75\xfa\xc6\xd0\x5a\x15\x67\x34\xbb\xec\x3a\x3c\x9d\x51\x49\xec\x34\xfa\xe5\x0a\x2c\x28\x12\x9a\x42\x89\x6b\x3b\xce\x68\x78\xa5\xba\xe5\xcf\x0e\x73\x1f\x0a\x1c\x1f\xd0\x95\xc9\xa9\xca\x9a\x52\x8e\x1f\x53\xd7\xea\x15\x0c\xce\x7d\xbd\xb0\x8e\xf7\x54\x92\x53\x5a\xb0\x52\x58\xf0\xb3\x1f\xe7\x47\x8d\x02\xa5\x66\x72\xd8\x9b\xb8\xf8\xfb\xc9\x8d\x53\xb2\xc0\x53\x07\xd6\xde\x06\xc7\x57\x9c\x3f\x5a\x74\x08\x3b\x0e\x32\x73\xad\x42\xf6\x8c\xf9\xf9\x88\x4e\xf7\x32\xd2\x69\x26\xeb\x30\x36\x59\x4f\x65\x0f\xc3\xe6\x73\x16\xf1\xef\xcf\xff\xcc\xb3\x7f\xfe\xe3\xcf\xd7\xcb\xff\x17\x00\x00\xff\xff\x55\x78\xab\x48\x22\x25\x00\x00")
+var _clusterroleYaml = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\xbc\x1a\x4b\xcf\xe3\xb8\xed\x9e\x5f\x61\x6c\x0f\x0b\x14\xf8\x32\x28\x7a\x29\xbe\x1e\x77\x8b\xa2\x40\xd1\x05\x06\xd3\xde\x19\x99\xb1\xb9\x91\x45\x8d\x44\xe5\x9b\xf4\xd7\x17\x92\x25\xc7\xce\xcb\x8e\xd3\x99\x53\x6c\x8a\xe2\x4b\x14\x5f\xce\x1f\xaa\x5f\xb8\xc6\xaa\x41\x83\x0e\x04\xeb\x6a\x77\xaa\x5a\x50\x87\x4f\x0d\x9a\x9a\xbc\xe2\x23\xba\x93\x02\xd5\xe2\x5f\xab\x5f\x7f\xab\xfe\xf5\xdb\x97\xea\x6f\xbf\xfe\xe3\xcb\x76\x03\x96\xfe\x83\xce\x13\x9b\xf7\xca\xed\x40\x6d\x21\x48\xcb\x8e\xfe\x0b\x42\x6c\xb6\x87\xbf\xf8\x2d\xf1\xa7\xe3\x9f\x36\x07\x32\xf5\x7b\xf5\x8b\x0e\x5e\xd0\x7d\x66\x8d\x9b\x0e\x05\x6a\x10\x78\xdf\x54\x95\x72\x98\x36\x7c\xa1\x0e\xbd\x40\x67\xdf\x2b\x13\xb4\xde\x54\x95\x81\x0e\xdf\x2b\x7f\xf2\x82\xdd\x3b\x38\x7e\xf3\x0e\x37\x2e\x68\xf4\xef\x9b\xb7\x0a\x2c\xfd\xdd\x71\xb0\x3e\x12\x79\xab\x7e\xfa\x69\x53\x55\x0e\x3d\x07\xa7\x30\xc3\x14\x77\x96\x0d\x1a\xf1\x02\x12\x3c\xfa\x4d\x55\x1d\xd1\xed\xf2\x72\x83\x92\x7e\x35\x79\x59\x4a\xd0\xec\xa9\xe9\xc0\xfa\xf4\x8a\xa6\xb6\x4c\x46\xf2\xdb\x11\xcb\xa3\xa6\x8e\xc4\x81\x69\xb0\x7f\x8f\x9a\x78\x0b\xaa\xbc\x72\x9d\x9f\x6c\x34\xa0\x17\x34\x72\x64\x1d\x3a\x54\x1a\xa8\xbb\xbd\x94\xa1\x5c\x0f\x0f\x82\x9d\xd5\x20\x79\xc5\xa1\xd5\xa4\x92\x29\x15\x1b\x71\xac\x35\xba\xb2\xd4\x6b\xf1\x35\xb0\x40\x0f\xf2\xe8\x8e\xa4\x10\x94\xe2\x50\xa4\xce\xb0\x47\x56\x8a\x0f\x1f\x20\xaa\x5d\x66\xaf\x28\xed\x27\xcd\xcd\x35\xc5\xab\xed\x50\x77\xe4\xa3\x33\x39\x6c\xc8\x8b\x1b\x3b\xd1\x35\xe1\x2e\x08\x08\x99\xe6\x03\x77\x2d\xf3\xa1\x3f\x97\xd0\x6f\xea\x95\x39\x82\xa6\xfa\x21\xce\x0a\x1d\xc1\x12\x7e\x13\x34\x51\x4e\x7f\x57\x38\x15\xbc\x70\x57\x80\x35\xee\xc9\xd0\x6b\x4c\x17\xd9\x04\x2c\xbd\x76\x82\x99\x00\xba\x2d\x5b\x34\xbe\xa5\xbd\xdc\x63\xe4\xf0\x6b\x40\x2f\x83\xf3\xac\xe2\x96\x6e\xd1\xf5\x0d\xcb\xae\xeb\xf0\x48\x7e\x38\xce\x1a\xb0\x63\xe3\x31\xbb\x6a\x8d\x56\xf3\xa9\x1b\x2e\x5c\x76\xfe\x61\x3d\x5e\x78\xdc\x07\x9d\x01\x2b\xc5\x9b\xb1\xc3\x59\x88\xde\xb7\x7e\x20\xa7\xa5\x97\xca\xf1\x0c\x65\xd5\x47\xe5\xb5\xa2\x07\x69\xd1\x48\x0e\x3b\x77\x3d\x53\xf8\x80\x26\x9e\x27\x7e\x5c\x30\x4a\xc1\x1f\x6f\x13\xbe\x4c\x25\xd7\x74\x3d\xea\xbd\x0f\xbb\xdf\x51\x09\x28\x85\xde\x9f\x79\x4c\x16\x53\xce\x98\xac\xdd\xde\xf4\xb4\x60\x8b\x6c\xeb\x58\xe3\x8e\x4c\x4d\xa6\xf1\x97\xf0\xec\xbd\x97\x18\x65\x69\x71\xb2\x7a\x46\xac\xf2\x7a\xc3\x64\x3f\xc4\x2c\x23\x6d\x1d\x7a\x71\xa4\x5e\x09\x8e\x41\xd8\x2b\xd0\x64\x9a\x6b\x4e\x49\x24\x36\x02\xda\x72\x5d\x30\x5f\x71\xf6\xc2\x6a\xd9\xc1\x4f\x39\xbe\x55\x1d\xa8\x96\x0c\xbe\x2c\xc8\x2e\x81\xaf\xb9\x3a\x36\xbf\xf3\xae\xe7\x95\x1f\xd6\x50\x0f\xa4\xeb\x19\x05\x13\xce\x39\xe8\x65\xc0\xf7\x66\xb8\x34\xea\x29\x74\x42\xfb\x18\x94\xf0\x41\x92\x1e\x21\x51\x63\x92\x33\xa6\x9c\xb6\x52\x0b\xa5\x39\xd4\xca\x61\x1d\xe3\x21\xe8\x39\x0f\x19\x10\xfd\x8b\x6c\xd3\x21\xcc\x27\xec\x3e\xb1\xfb\x1b\x51\xfb\xe2\x00\x07\xef\x65\x1b\xfb\x01\x76\x13\xe0\xb1\x2f\xf8\x7d\xe1\xed\x87\x18\x56\x1b\x9f\x9f\xf6\x08\x12\x1c\x36\x43\x65\x4a\x1d\x94\x42\x98\xcc\xde\x81\x17\x17\x54\x44\x29\xb0\x18\x07\xca\x6e\x83\xf2\xc1\xee\xd0\xbf\x70\x14\x35\x3f\x66\x71\xda\x90\x3d\xdc\x3a\x8e\xc1\x6a\x78\xf9\x46\x99\x82\x57\x2d\xd6\x61\xfd\xf5\xca\x6a\xcd\x9d\x60\x8f\xa5\x34\xd5\xfc\x61\x34\x43\x3d\x31\x4a\x2c\x13\x9d\x01\xad\xb9\xd1\x64\x0e\x93\xb5\x2b\x80\xe1\xec\x8a\x97\xa6\xb5\x3a\x34\x34\x05\x7d\x0d\xa4\x0e\x5e\xc0\xc9\x04\x7c\x82\x4e\x7b\xe8\xec\xe3\xbc\xf1\x58\xeb\x58\x78\x59\x0d\x26\xa9\x9e\x8c\x3d\x63\x03\xcb\x75\x3e\x2e\xc5\xc6\xa0\x12\x3a\x92\x9c\x54\x8b\xea\xb0\x5a\x0a\x76\x35\x99\xc7\x69\x5f\x23\x3c\xee\xe5\x1e\x30\x18\x3a\xda\xbb\xd4\x87\xa6\x4e\xaf\xaf\xa4\xfb\x56\xf0\x3e\x8b\xd2\x29\xae\x21\x3d\xf4\x1f\xd7\x74\xc7\x37\x69\x05\xe9\xbd\xe6\x8f\xec\x06\xdb\x73\x2f\x70\x4f\x89\x88\x1d\xaf\x5a\x07\xe5\x0a\x12\x3b\x92\x93\xc6\x23\xea\xff\x47\xb3\xd5\xa2\xee\x66\x1c\x30\xa2\xa8\x16\x9c\x38\xb4\xec\x49\xd8\xd1\x5a\xe5\x53\x90\x9a\x61\x37\x0e\x64\xe9\x51\x1c\x42\xf7\xdd\x19\x26\x2e\x03\xef\xb9\xec\xf7\x24\x5d\x81\x66\xa4\x51\x7e\x5b\x5c\x77\xa6\x4d\xb9\x3b\x3d\x2d\x0d\x1b\xe3\xc2\x21\x11\xb0\x2e\x98\xd5\xe1\x3a\x3b\xfd\x52\xe6\xb5\xf1\x0e\x15\xbb\xb5\xd5\x4a\xbc\x0e\xca\xd0\x56\x19\xb5\xbf\xc9\x20\x47\xc4\x37\x10\x01\xd5\xc6\x9e\xed\xed\xe5\x11\x40\xae\x1c\x67\x54\xcb\x58\x2d\x82\x96\x76\x88\xc2\x03\x7c\xfa\xb2\xba\x31\xce\x04\x26\x37\x7c\xfe\xc0\x05\xc8\xa0\x73\xc1\x08\x75\x38\x76\x80\x73\xc3\x3f\x86\x1e\xc2\x0e\x35\xca\x18\x34\xe1\x6b\x99\xf5\x0d\xf0\x5a\x95\x50\x40\xff\xf9\x76\xe1\x09\x0e\xd3\x72\xcb\xfe\x5c\x6b\xf4\x93\x89\xdc\xae\xad\x63\xe8\x48\xdd\x4f\x10\xa3\x01\x21\x3f\x74\xd4\x6b\xca\xd4\xcc\x8c\x89\xbc\xb0\x4b\xb7\x7f\xa8\xcf\x32\x24\xd7\x76\x03\x85\xb5\xba\xb1\x49\x81\xd8\x34\x5b\xc5\x0e\xd9\x6f\x15\x77\x37\x6a\x52\x8d\x4e\x3a\x30\x31\x7e\x8c\x8f\x79\x0c\x1f\x4c\x90\x69\x0e\xf6\xdf\xe1\xf0\xd8\xa1\xb4\x18\xfc\x15\x20\xf5\xfc\xbd\x7a\xfd\x50\x6c\x42\x43\x5a\x30\x9c\x70\xd6\x86\x9d\x7c\xcd\x97\x75\x82\x93\xa2\x16\x53\xbc\xca\x20\xcb\x9a\x54\xa9\x5c\x93\x8f\x85\x9d\x29\x53\x2c\x83\x32\x99\x1c\xbf\x26\xe6\x92\xe8\xd8\xcb\xe6\x38\x48\xb1\x7e\xd9\x68\x0f\xf4\x9a\x08\xd1\x21\xee\xf9\x64\x8e\xe1\x4a\xc3\xd0\x07\xdc\xec\x0a\x46\xd6\x5a\x23\x08\xd7\x78\x57\x84\x12\x9a\x06\x11\x56\x30\x58\x68\xe4\x5b\xfd\xd7\x45\xfb\x38\x69\xba\x94\xa7\xda\xd1\xd0\xbe\x5d\x44\xcf\x73\x0b\xa6\x3c\x79\x03\xd6\xb7\x2c\x97\x5f\x00\xce\xdd\x19\x8a\xaa\x47\xa9\x37\x22\xc6\x62\x34\xc9\x37\x71\xc6\x72\x26\x17\x94\x62\x64\xbe\xe8\x27\x23\xe8\x8c\x36\xb9\xbb\x71\x69\xd2\x8f\x65\xd0\x8d\x88\x53\xae\xe6\xb4\x01\x2c\x86\xbc\x60\x39\xc0\xef\xf0\xcd\x77\x5e\xc1\x24\xc2\xbd\x78\xac\xfe\x61\x40\x53\x20\xa0\xb9\xc9\xb0\xf1\xf9\x65\x61\x26\xbd\x33\x19\x2f\xa0\x53\xbb\x35\xbd\x67\x8a\x4d\x4d\x67\xbf\x28\xe0\x26\xc9\x33\x15\xa7\x57\x2d\xec\xbc\x72\x64\x5f\x88\xd8\x16\xd4\x21\x5a\x67\xbb\x4c\xd1\x8c\xde\x81\xa1\xfd\xcc\xe0\xe2\x9a\x55\xf4\xb0\xd3\xcd\x4e\xb2\x26\xef\x42\x52\x63\x17\xea\xa6\x84\xc0\x98\xff\x50\x85\xd8\x58\xbc\x76\xf9\x6d\x3f\xcf\xdc\xce\x8f\xc4\x33\x66\x1e\x63\xac\x66\x97\xa6\x13\xb3\xac\x12\xd6\xed\x11\xd0\x1d\x0b\x3e\x43\x78\xa5\xf0\xe9\x3b\xe1\xec\x4c\xc9\x6a\xc2\xba\xcc\xb2\x2f\xbf\x30\x2e\xf6\x88\x25\xbc\x9e\x65\xf2\x40\xb5\xbb\x1f\xaa\x7f\xe4\xf8\xfe\x91\x7c\x31\xf5\xce\x0e\xd0\x83\xac\xa5\x9f\x67\x44\xcf\xb7\x6a\x6b\x78\xf5\x81\xff\x51\xe2\x2f\x43\x83\x97\xd2\x6e\x09\x10\x5b\x32\xfd\xd0\x6d\xce\x7e\x60\x1a\x04\xad\x59\xbd\x52\xe7\x0e\x5c\x9f\x66\x76\xde\x9b\xb2\xef\xb7\x98\xc4\xbc\x38\xa0\xd5\x43\xa1\x92\xf6\xb7\x39\xd1\xdd\xb5\x77\xfe\x2b\x41\xa9\x12\x46\xf5\xd6\xc5\x4a\x2e\x0b\x6e\x2c\xad\x15\x71\x46\xb2\xcb\x22\xc7\xd3\xb9\x09\x8a\x85\x4d\xbf\x5d\x81\x05\x45\x42\xd3\xce\xe5\x5a\x8f\x73\xf3\xbd\x52\xdc\xf2\xdf\x8a\xb9\xef\x12\x8e\x0f\xe8\x0a\x72\x4a\xea\xa6\x64\xff\xc7\xd0\xb5\x72\x05\x83\x73\x1f\x4b\xac\xe3\x3d\x95\xe0\x94\x36\xac\x64\x16\xfc\xec\x7f\x01\x46\x75\x09\xa5\xda\x75\x38\x9b\xb8\xf9\xfb\xf1\x8d\x28\x99\xe1\xa9\x03\x6b\x6f\xf7\xe2\x57\x94\x3f\x5a\x74\x08\x3b\x0e\x32\x33\xc5\x21\x7b\x1e\x31\xf0\x11\x9d\xee\x79\xa4\xdb\x4c\xd6\x61\xac\xe9\x9e\x8a\x1e\x86\xcd\xe7\xcc\xe2\xdf\x9f\xff\x99\xb1\x7f\xfe\xe3\xcf\xd7\xdb\xff\x17\x00\x00\xff\xff\x20\xd5\x12\xb9\x91\x25\x00\x00")
 
 func clusterroleYamlBytes() ([]byte, error) {
 	return bindataRead(
diff --git a/pkg/operator/controllers/rbac/staticresources/clusterrole.yaml b/pkg/operator/controllers/rbac/staticresources/clusterrole.yaml
index 6c4413c22..dba6ecd80 100644
--- a/pkg/operator/controllers/rbac/staticresources/clusterrole.yaml
+++ b/pkg/operator/controllers/rbac/staticresources/clusterrole.yaml
@@ -502,6 +502,14 @@ rules:
   - get
   - list
   - watch
+- apiGroups:
+  - preview.aro.openshift.io
+  resources:
+  - previewfeatures
+  verbs:
+  - get
+  - list
+  - watch
 - apiGroups:
   - project.openshift.io
   resources:
diff --git a/pkg/operator/deploy/deploy.go b/pkg/operator/deploy/deploy.go
index 9c3a4c38f..1c08efd73 100644
--- a/pkg/operator/deploy/deploy.go
+++ b/pkg/operator/deploy/deploy.go
@@ -184,18 +184,10 @@ func (o *operator) resources() ([]kruntime.Object, error) {
 		},
 	}
 
-	if o.oc.Properties.NetworkProfile.GatewayPrivateEndpointIP != "" {
-		cluster.Spec.GatewayDomains = append(o.env.GatewayDomains(), o.oc.Properties.ImageRegistryStorageAccountName+".blob."+o.env.Environment().StorageEndpointSuffix)
-	} else {
-		cluster.Spec.InternetChecker = arov1alpha1.InternetCheckerSpec{
-			URLs: []string{
-				fmt.Sprintf("https://%s/", o.env.ACRDomain()),
-				o.env.Environment().ActiveDirectoryEndpoint,
-				o.env.Environment().ResourceManagerEndpoint,
-				o.env.Environment().GenevaMonitoringEndpoint,
-			},
-		}
-	}
+	// TODO (BV): reenable gateway once we fix bugs
+	// if o.oc.Properties.NetworkProfile.GatewayPrivateEndpointIP != "" {
+	// 	cluster.Spec.GatewayDomains = append(o.env.GatewayDomains(), o.oc.Properties.ImageRegistryStorageAccountName+".blob."+o.env.Environment().StorageEndpointSuffix)
+	// }
 
 	// create a secret here for genevalogging, later we will copy it to
 	// the genevalogging namespace.
diff --git a/pkg/portal/cluster.go b/pkg/portal/cluster.go
new file mode 100644
index 000000000..5285273f7
--- /dev/null
+++ b/pkg/portal/cluster.go
@@ -0,0 +1,110 @@
+package portal
+
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache License 2.0.
+
+import (
+	"encoding/json"
+	"net/http"
+	"sort"
+	"strings"
+	"time"
+
+	"github.com/Azure/go-autorest/autorest/azure"
+)
+
+type AdminOpenShiftCluster struct {
+	Key                     string `json:"key"`
+	Name                    string `json:"name"`
+	Subscription            string `json:"subscription"`
+	ResourceGroup           string `json:"resourceGroup"`
+	ResourceId              string `json:"resourceId"`
+	ProvisioningState       string `json:"provisioningState"`
+	FailedProvisioningState string `json:"failedprovisioningState"`
+	Version                 string `json:"version"`
+	CreatedAt               string `json:"createdAt"`
+	ProvisionedBy           string `json:"provisionedBy"`
+}
+
+func (p *portal) clusters(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	docs, err := p.dbOpenShiftClusters.ListAll(ctx)
+	if err != nil {
+		p.internalServerError(w, err)
+		return
+	}
+
+	clusters := make([]*AdminOpenShiftCluster, 0, len(docs.OpenShiftClusterDocuments))
+	for _, doc := range docs.OpenShiftClusterDocuments {
+		if doc.OpenShiftCluster == nil {
+			continue
+		}
+
+		ps := doc.OpenShiftCluster.Properties.ProvisioningState
+		fps := doc.OpenShiftCluster.Properties.FailedProvisioningState
+		subscription := "Unknown"
+		resourceGroup := "Unknown"
+		name := "Unknown"
+
+		if resource, err := azure.ParseResourceID(doc.OpenShiftCluster.ID); err == nil {
+			subscription = resource.SubscriptionID
+			resourceGroup = resource.ResourceGroup
+			name = resource.ResourceName
+		}
+
+		createdAt := "Unknown"
+		if !doc.OpenShiftCluster.Properties.CreatedAt.IsZero() {
+			createdAt = doc.OpenShiftCluster.Properties.CreatedAt.Format(time.RFC3339)
+		}
+
+		clusters = append(clusters, &AdminOpenShiftCluster{
+			Key:                     doc.ID,
+			ResourceId:              doc.OpenShiftCluster.ID,
+			Name:                    name,
+			Subscription:            subscription,
+			ResourceGroup:           resourceGroup,
+			Version:                 doc.OpenShiftCluster.Properties.ClusterProfile.Version,
+			CreatedAt:               createdAt,
+			ProvisionedBy:           doc.OpenShiftCluster.Properties.ProvisionedBy,
+			ProvisioningState:       ps.String(),
+			FailedProvisioningState: fps.String(),
+		})
+	}
+
+	sort.SliceStable(clusters, func(i, j int) bool { return strings.Compare(clusters[i].Key, clusters[j].Key) < 0 })
+
+	b, err := json.MarshalIndent(clusters, "", "    ")
+	if err != nil {
+		p.internalServerError(w, err)
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	_, _ = w.Write(b)
+}
+
+func (p *portal) clusterOperators(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	fetcher, err := p.makeFetcher(ctx, r)
+	if err != nil {
+		p.internalServerError(w, err)
+		return
+	}
+
+	res, err := fetcher.ClusterOperators(ctx)
+	if err != nil {
+		p.internalServerError(w, err)
+		return
+	}
+
+	b, err := json.MarshalIndent(res, "", "    ")
+	if err != nil {
+		p.internalServerError(w, err)
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	_, _ = w.Write(b)
+}
diff --git a/pkg/portal/cluster/bindata.go b/pkg/portal/cluster/bindata.go
new file mode 100644
index 000000000..79d4f34d7
--- /dev/null
+++ b/pkg/portal/cluster/bindata.go
@@ -0,0 +1,244 @@
+// Code generated for package cluster by go-bindata DO NOT EDIT. (@generated)
+// sources:
+// testdocs/clusteroperator.json
+package cluster
+
+import (
+	"bytes"
+	"compress/gzip"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+)
+
+func bindataRead(data []byte, name string) ([]byte, error) {
+	gz, err := gzip.NewReader(bytes.NewBuffer(data))
+	if err != nil {
+		return nil, fmt.Errorf("Read %q: %v", name, err)
+	}
+
+	var buf bytes.Buffer
+	_, err = io.Copy(&buf, gz)
+	clErr := gz.Close()
+
+	if err != nil {
+		return nil, fmt.Errorf("Read %q: %v", name, err)
+	}
+	if clErr != nil {
+		return nil, err
+	}
+
+	return buf.Bytes(), nil
+}
+
+type asset struct {
+	bytes []byte
+	info  os.FileInfo
+}
+
+type bindataFileInfo struct {
+	name    string
+	size    int64
+	mode    os.FileMode
+	modTime time.Time
+}
+
+// Name return file name
+func (fi bindataFileInfo) Name() string {
+	return fi.name
+}
+
+// Size return file size
+func (fi bindataFileInfo) Size() int64 {
+	return fi.size
+}
+
+// Mode return file mode
+func (fi bindataFileInfo) Mode() os.FileMode {
+	return fi.mode
+}
+
+// Mode return file modify time
+func (fi bindataFileInfo) ModTime() time.Time {
+	return fi.modTime
+}
+
+// IsDir return file whether a directory
+func (fi bindataFileInfo) IsDir() bool {
+	return fi.mode&os.ModeDir != 0
+}
+
+// Sys return file is sys mode
+func (fi bindataFileInfo) Sys() interface{} {
+	return nil
+}
+
+var _clusteroperatorJson = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\xec\x5b\xcd\x8e\xe3\xb8\x11\xbe\xfb\x29\x04\x9f\x97\xdd\x22\xf5\x2f\x60\x0f\x83\x6c\x72\x5a\x60\x82\x8d\x93\xc3\x06\x41\x50\xa2\x4a\x6e\x66\x64\x49\x21\x29\x4f\x4f\x06\xfd\xee\x81\x64\x59\x96\x6d\xd9\x6d\x77\xbb\x2d\x5b\xb3\x73\x18\x18\xad\x12\xeb\xab\x62\xd5\x57\x45\x91\xfc\x3e\x31\x8c\x29\x14\xe2\x1f\x28\x95\xc8\xb3\x69\x68\x4c\x79\x9e\x25\x62\xfe\x90\x17\x98\xa9\x27\x91\xe8\x07\x91\x3f\x2e\xe9\xf4\xa7\x4a\x52\x68\x5c\xa8\x69\x68\xfc\x73\x62\x18\x86\xf1\xbd\xfe\xff\x9c\x01\x6a\xe9\x2f\x22\x8b\x2b\xb9\x3f\xa5\xa5\xd2\x28\x3f\x17\x28\x41\xe7\x72\x23\xb0\x40\x0d\x31\x68\x98\x86\xad\x0a\xc3\x98\x72\x89\xa0\x45\x9e\xcd\xc4\x02\x95\x86\x45\x51\x8d\xc1\x4c\x46\x89\xc9\x88\x69\xcd\x4c\x3f\x64\x6e\x68\xb2\xdf\xdb\x81\x0c\x63\x3a\xc7\xac\x1a\x7d\x85\x8c\x76\x1e\x64\xb0\xc0\x6a\x00\x90\x79\x57\x3e\xff\x9a\xa1\xfc\x0d\x13\x94\x98\x71\xdc\x98\xba\xfa\xf7\xbd\xf3\x7b\xcf\x6c\x90\xf9\xae\xcd\x90\x16\x4f\x40\x3b\xe3\xd7\xaf\x45\x69\xce\xbf\x7c\xae\x14\xfd\x82\x29\x36\xd8\xb4\x2c\x71\x47\x8e\xe7\x99\x96\x79\x9a\xa2\xec\x7f\xbe\xe3\xc7\x5d\x35\x6b\x0b\x79\xff\xe3\x52\xd4\x2f\xdb\xe0\x24\x94\xc7\x36\x89\x29\x50\x62\x3b\x1e\x10\xdf\x77\x03\x02\xe0\x98\x56\x64\xba\x56\x60\x7a\xd3\xce\x9b\x2f\xed\xef\x7f\x75\xdc\x26\x51\xe5\xa5\xe4\xd8\x71\x07\xb5\x03\xea\x79\xa6\xdb\x35\x7f\xaa\x30\x4d\x7e\x15\xd9\x97\x4a\xe0\x11\x0a\xa1\x1e\xfb\x83\xe5\xb1\x01\x9d\x37\xb1\xa1\x1e\x77\xe6\xa9\x41\xef\xd8\x49\x82\xcc\xf5\x08\x98\x6e\x42\x6c\x08\x3c\x12\xd8\x41\x44\x3c\xcb\xb2\x7c\xc7\x45\x0f\x10\xd6\xe8\x5f\xda\xf8\x52\x05\xf2\x2a\xb6\x3a\x7f\xd1\xa0\x4b\xb5\x13\x6f\x79\x16\x8b\x6a\x76\x5e\x89\x82\x14\x94\x9e\x49\xc8\x94\x58\x47\xe7\x26\x30\x1d\x62\xda\x33\x33\x08\x1d\x2b\xa4\xf4\xf7\xdd\x29\x90\x08\x6a\xe5\xac\x4f\xea\xcf\xcf\x05\x72\x8d\xf1\xae\x4c\x8b\x6c\x3a\x93\x25\xee\x3e\xd5\xdf\x8a\x5a\xdb\xa7\x25\x88\x14\xa2\x14\xb7\xa6\xea\xa7\xe1\x41\xff\x05\x52\x75\x10\xf5\x5f\x65\x3e\x97\xa8\x94\xc8\xe6\x77\x85\xfb\x17\x9c\x4b\x88\x31\xbe\x35\xd0\xc7\x22\xe4\xef\x45\x8d\x79\x2f\x46\x7a\xd3\x19\x9f\x35\x66\x4d\x22\x67\x65\x9a\x4e\xba\xc2\x8d\xa5\xb7\x41\xfe\x66\xed\x41\x67\xc6\x58\xe8\xd0\x90\xfa\x67\x92\x7f\xa9\x9f\x30\xd3\x82\xaf\x84\x5e\x21\x34\xcf\xf5\x7c\xc7\xf2\xad\x0b\x11\xda\x41\xdd\x0d\xb7\xf1\x28\xa1\x11\x72\x9f\xf8\x2c\x42\x62\x47\xae\x4b\x82\x80\xb9\xc4\x77\x5d\x4e\x3d\x87\x45\x8c\x0d\xcc\x6d\x8c\x56\x45\x97\x9a\x21\x73\xee\x25\xdd\xdc\x2a\xdd\x18\x0b\x6d\x3f\xb4\xef\x89\xdb\x3c\x42\xe9\x8c\xd1\x90\x05\xa1\xe3\xed\xe1\x5e\xa0\x52\x30\xaf\xc5\x3f\x7f\x2a\xf5\xd3\xdf\x50\x2e\xab\xee\xa2\x48\xf3\x6f\x0b\xcc\x74\x5b\x1e\x42\x03\xd6\x3f\x7f\xc3\x22\x15\x1c\xd4\xcf\x3f\xb3\x7b\xa8\x4b\xc7\x12\xfd\xc2\xa0\xdf\x45\x95\x5b\x1c\x92\x82\xc6\xf8\x73\xf4\x1f\xe4\xfa\x95\x44\x9b\xcb\xbc\xac\x39\x6d\x4d\x10\x5b\x04\x72\x66\x6b\xb7\x26\xaf\x7d\x8a\x53\xa7\xce\x43\x8b\xa7\x87\xce\x46\x83\x46\x64\x89\x04\xa5\x65\xc9\x75\x29\x71\x68\x34\x79\xe5\x9c\xf3\x41\xc8\xbc\xd4\x78\x12\x86\x5a\x01\x69\x25\xfb\xc4\x54\x01\x0d\x98\xb5\x14\x39\x58\xa7\xf6\x0c\xa8\x91\x9c\x6f\xc0\x50\x70\x15\xca\xa5\xe0\x97\x04\xdc\x82\x58\x05\xc6\x31\xe5\x2d\xfa\x8f\x53\x4f\x16\x90\xc1\x7c\x9f\x04\xaf\x06\xe3\xf4\xa9\xb8\x22\x0c\x92\xef\x76\x9c\xd7\xc6\x23\xb2\xba\x31\x18\x4c\xff\x2a\xaf\xaa\x5e\xb5\xee\x12\xde\x80\xa3\xb7\x10\x2e\x57\x9d\xf2\x2b\x75\xae\x03\xa7\x77\x16\x96\x9b\x7e\xdb\x7e\x70\x1f\x98\x7b\xaa\x03\x4e\x64\x8d\xbd\xf1\xff\xbd\x11\x7d\x93\xa6\x83\x7e\x3c\x6e\xc9\xc6\x85\x77\xb1\xb6\xb2\x59\x68\xd9\xe7\xad\xad\x78\x9a\x97\x31\xe1\x12\xe3\x2a\xf9\x20\x7d\x7d\x75\x15\x30\xcf\x09\x2e\xb4\xba\x3a\xa6\xbd\x59\x5f\x05\x90\xb8\x26\x42\x4c\x3c\xea\xbb\xc4\x8e\x22\x46\xc0\x8d\x4c\x82\x7e\xe0\x5a\xa6\x65\x5a\x89\xef\x0d\xb6\xbe\x3a\xe6\x7b\x63\x98\xfe\x9b\x32\xc2\xac\x19\xb5\xaa\xfe\xdb\x76\x8f\x00\xba\xe2\xb7\x93\x6a\x05\xea\x85\xb6\x1d\xd2\xfd\x15\xe8\x00\x0b\xb5\x0b\xcd\xda\xa8\x16\x20\x75\x26\x6e\x12\xf1\x72\x15\x6d\x37\xc5\xdb\xca\x7e\xa0\xfd\x3a\xd0\x30\x1e\x1c\xe6\xa8\x51\xb5\x86\x05\x14\x1f\xd1\xbd\xbd\x05\xd0\x3b\x3a\x86\x9d\x09\x3a\x6d\x29\xd1\xa2\x5d\x00\x7f\x12\x19\x56\x75\x90\x2c\x55\xf1\x84\x72\x2f\xac\x2f\xea\xf8\x4d\x20\x49\xfc\x6f\x89\x4a\x0f\x69\xf0\x9c\x17\x63\x34\xf6\x70\x66\x29\x6b\x8c\xf6\x76\x7a\xf3\x05\xcc\x91\x48\x9c\x0b\xa5\xe5\x37\x02\xff\x2b\xc7\x19\xce\xf0\x55\x11\x8c\x14\xe1\x4a\x90\x58\x8a\x25\xca\x83\x70\xc7\x60\xee\x02\x32\x91\xc2\x8f\x62\xed\xc1\x70\xae\x1f\x68\xe0\x5f\x46\x6e\xf5\x6a\x81\x3d\x56\x76\xee\x2f\x45\xf0\x75\xef\x8b\xc2\x88\x8d\x1d\x2b\x2f\xe7\x4b\x21\xf5\x0f\xc8\x53\xfd\x9f\xc4\x46\x69\xe4\x88\x83\xb7\x37\x57\x7f\x94\xa2\xb3\x55\x6a\xc7\x6d\xeb\xd6\xf4\x56\x8c\x35\x46\x73\x0f\x2f\x82\x04\x2c\x88\xec\x7d\xef\xde\x6d\x3e\xd8\x39\xce\xf9\xc8\xc9\x39\x43\xfd\x35\x97\x83\x72\x54\xfb\xfb\xda\xbb\x2a\x1b\xc5\x93\xee\x5f\x6e\x75\x2f\xc2\x61\x21\xa3\xe7\xee\x45\xd4\x20\x08\x94\x3a\x57\x1c\xd2\xad\x2f\x96\xbd\xbb\x11\x8e\x13\x78\x8c\x59\xde\xa5\x76\x23\x8e\xe9\x6f\xf6\x23\x10\x3d\xcf\xa7\xcc\x23\xdc\xb1\x1d\x62\x23\x75\x08\x50\xc7\x27\x41\x64\x81\x15\x78\x68\x05\x49\x7c\x0b\xfb\x11\x7b\xfe\x6f\xa6\xb3\x3d\x82\x04\xda\x68\x02\xce\x68\xa2\x6d\x2f\x2b\x6e\xef\x7c\xd1\xf6\x76\x02\x33\x6f\x6c\x3b\xa1\xdf\xe9\xd7\xde\x73\x39\x03\xd0\xcd\xee\x6f\xac\x73\x50\x64\xa7\x9d\xdc\x39\xb1\x24\x74\x7a\xa2\x63\x45\xa0\x11\xdb\x10\xc1\xf9\x85\x6e\x58\x03\x1a\x2a\x7b\x8f\x01\x67\x35\x98\xf7\x74\x3c\xe1\x96\x0a\x29\x64\x59\xae\x61\x4d\xf9\x5d\x33\xa7\xf8\xcc\xd3\x32\xc6\x07\x89\x29\x82\xda\x3e\x3b\xf6\x28\x32\x8d\x32\x5b\xb5\x34\xcd\x6c\x3c\xe5\xaa\x22\xe8\xd0\x98\xea\x2a\xa9\x27\x3d\x93\x7d\xa4\x6e\x37\xdb\xc6\x2c\x34\x59\xc8\xce\x3c\x9f\xdd\x1c\x64\xea\x99\x8d\x43\x45\xdb\x75\x98\xe5\x5e\xa8\x68\x1f\x56\xde\x54\x6c\x37\x8e\x1d\x96\xb8\x36\x71\x79\xe2\x13\x3b\xb6\x6c\x12\x04\x96\x4b\x62\x60\x9e\xe9\x41\xe2\xfb\xf6\x90\x15\xbb\xf5\x3c\x33\x43\x73\xff\xd0\xf0\x2d\x9e\xd0\xbe\x26\xe8\xcb\x56\xea\x8b\xe2\xbe\xf2\x91\x8e\x8f\x07\x3d\xae\x13\x0d\x35\x31\x8c\xf7\xdc\xe8\x3b\xce\x1b\xfc\x51\x7a\x4f\xbe\xab\x64\x9d\x79\x51\x95\xe7\x99\xca\x53\x7c\xad\x06\xba\xd4\x77\x1c\xd3\x32\x2f\x56\x03\x77\x95\xae\x6f\x27\x41\x12\xdb\x66\x10\x93\xd8\x32\x63\x62\x27\x76\x4c\x22\x1a\x05\xc4\x63\x81\xeb\x46\x49\xe0\xd3\x38\x18\xf2\x76\x92\x4d\x4c\x5a\x79\xda\x72\x42\xeb\x6e\x2e\x03\x76\x6e\x27\x6d\x87\xc7\xa5\x41\x5f\xf8\x76\xd2\x25\x71\x0f\x71\x9d\xe8\x43\x41\x8f\xad\xf6\x55\x84\x30\xf4\x5d\x99\x1b\x81\x71\x5b\x17\x88\x0a\x99\x3f\x8b\xc1\x51\xbc\xf1\x1a\x53\xfd\xda\x69\x18\xf6\x6a\x52\x3f\x06\x9e\x0a\xcc\xde\xb0\x91\x70\x52\x9f\x54\x21\x18\xfe\x2a\xc9\x09\xae\xf8\x00\xfd\x6b\xf3\x8b\x32\x4a\x05\x3f\x75\xcb\xe4\xe4\x3b\x49\xed\xd9\xda\x1b\x6f\x2d\x27\x0d\x94\x43\x6d\xe2\xaf\x42\xad\x36\x46\xf7\xdb\xc4\xbe\xce\xad\x01\xb5\xd5\xac\x55\x38\x5e\x26\x2f\x93\xff\x07\x00\x00\xff\xff\x56\xb8\x3b\x0d\xa6\x45\x00\x00")
+
+func clusteroperatorJsonBytes() ([]byte, error) {
+	return bindataRead(
+		_clusteroperatorJson,
+		"clusteroperator.json",
+	)
+}
+
+func clusteroperatorJson() (*asset, error) {
+	bytes, err := clusteroperatorJsonBytes()
+	if err != nil {
+		return nil, err
+	}
+
+	info := bindataFileInfo{name: "clusteroperator.json", size: 0, mode: os.FileMode(0), modTime: time.Unix(0, 0)}
+	a := &asset{bytes: bytes, info: info}
+	return a, nil
+}
+
+// Asset loads and returns the asset for the given name.
+// It returns an error if the asset could not be found or
+// could not be loaded.
+func Asset(name string) ([]byte, error) {
+	cannonicalName := strings.Replace(name, "\\", "/", -1)
+	if f, ok := _bindata[cannonicalName]; ok {
+		a, err := f()
+		if err != nil {
+			return nil, fmt.Errorf("Asset %s can't read by error: %v", name, err)
+		}
+		return a.bytes, nil
+	}
+	return nil, fmt.Errorf("Asset %s not found", name)
+}
+
+// MustAsset is like Asset but panics when Asset would return an error.
+// It simplifies safe initialization of global variables.
+func MustAsset(name string) []byte {
+	a, err := Asset(name)
+	if err != nil {
+		panic("asset: Asset(" + name + "): " + err.Error())
+	}
+
+	return a
+}
+
+// AssetInfo loads and returns the asset info for the given name.
+// It returns an error if the asset could not be found or
+// could not be loaded.
+func AssetInfo(name string) (os.FileInfo, error) {
+	cannonicalName := strings.Replace(name, "\\", "/", -1)
+	if f, ok := _bindata[cannonicalName]; ok {
+		a, err := f()
+		if err != nil {
+			return nil, fmt.Errorf("AssetInfo %s can't read by error: %v", name, err)
+		}
+		return a.info, nil
+	}
+	return nil, fmt.Errorf("AssetInfo %s not found", name)
+}
+
+// AssetNames returns the names of the assets.
+func AssetNames() []string {
+	names := make([]string, 0, len(_bindata))
+	for name := range _bindata {
+		names = append(names, name)
+	}
+	return names
+}
+
+// _bindata is a table, holding each asset generator, mapped to its name.
+var _bindata = map[string]func() (*asset, error){
+	"clusteroperator.json": clusteroperatorJson,
+}
+
+// AssetDir returns the file names below a certain
+// directory embedded in the file by go-bindata.
+// For example if you run go-bindata on data/... and data contains the
+// following hierarchy:
+//     data/
+//       foo.txt
+//       img/
+//         a.png
+//         b.png
+// then AssetDir("data") would return []string{"foo.txt", "img"}
+// AssetDir("data/img") would return []string{"a.png", "b.png"}
+// AssetDir("foo.txt") and AssetDir("notexist") would return an error
+// AssetDir("") will return []string{"data"}.
+func AssetDir(name string) ([]string, error) {
+	node := _bintree
+	if len(name) != 0 {
+		cannonicalName := strings.Replace(name, "\\", "/", -1)
+		pathList := strings.Split(cannonicalName, "/")
+		for _, p := range pathList {
+			node = node.Children[p]
+			if node == nil {
+				return nil, fmt.Errorf("Asset %s not found", name)
+			}
+		}
+	}
+	if node.Func != nil {
+		return nil, fmt.Errorf("Asset %s not found", name)
+	}
+	rv := make([]string, 0, len(node.Children))
+	for childName := range node.Children {
+		rv = append(rv, childName)
+	}
+	return rv, nil
+}
+
+type bintree struct {
+	Func     func() (*asset, error)
+	Children map[string]*bintree
+}
+
+var _bintree = &bintree{nil, map[string]*bintree{
+	"clusteroperator.json": {clusteroperatorJson, map[string]*bintree{}},
+}}
+
+// RestoreAsset restores an asset under the given directory
+func RestoreAsset(dir, name string) error {
+	data, err := Asset(name)
+	if err != nil {
+		return err
+	}
+	info, err := AssetInfo(name)
+	if err != nil {
+		return err
+	}
+	err = os.MkdirAll(_filePath(dir, filepath.Dir(name)), os.FileMode(0755))
+	if err != nil {
+		return err
+	}
+	err = ioutil.WriteFile(_filePath(dir, name), data, info.Mode())
+	if err != nil {
+		return err
+	}
+	err = os.Chtimes(_filePath(dir, name), info.ModTime(), info.ModTime())
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+// RestoreAssets restores an asset under the given directory recursively
+func RestoreAssets(dir, name string) error {
+	children, err := AssetDir(name)
+	// File
+	if err != nil {
+		return RestoreAsset(dir, name)
+	}
+	// Dir
+	for _, child := range children {
+		err = RestoreAssets(dir, filepath.Join(name, child))
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func _filePath(dir, name string) string {
+	cannonicalName := strings.Replace(name, "\\", "/", -1)
+	return filepath.Join(append([]string{dir}, strings.Split(cannonicalName, "/")...)...)
+}
diff --git a/pkg/portal/cluster/clusteroperators.go b/pkg/portal/cluster/clusteroperators.go
new file mode 100644
index 000000000..89319a172
--- /dev/null
+++ b/pkg/portal/cluster/clusteroperators.go
@@ -0,0 +1,56 @@
+package cluster
+
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache License 2.0.
+
+import (
+	"context"
+
+	configv1 "github.com/openshift/api/config/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+type OperatorInformation struct {
+	Name      string                   `json:"name"`
+	Available configv1.ConditionStatus `json:"available"`
+}
+
+type ClusterOperatorsInformation struct {
+	Operators []OperatorInformation `json:"operators"`
+}
+
+func clusterOperatorsInformationFromOperatorList(operators *configv1.ClusterOperatorList) *ClusterOperatorsInformation {
+	final := &ClusterOperatorsInformation{
+		Operators: make([]OperatorInformation, 0, len(operators.Items)),
+	}
+
+	for _, co := range operators.Items {
+		var Available configv1.ConditionStatus = configv1.ConditionUnknown
+
+		for _, cnd := range co.Status.Conditions {
+			if cnd.Type == "Available" {
+				Available = cnd.Status
+			}
+		}
+
+		final.Operators = append(final.Operators, OperatorInformation{
+			Name:      co.Name,
+			Available: Available,
+		})
+	}
+
+	return final
+}
+
+func (f *realFetcher) ClusterOperators(ctx context.Context) (*ClusterOperatorsInformation, error) {
+	r, err := f.configcli.ConfigV1().ClusterOperators().List(ctx, metav1.ListOptions{})
+	if err != nil {
+		return nil, err
+	}
+
+	return clusterOperatorsInformationFromOperatorList(r), nil
+}
+
+func (c *client) ClusterOperators(ctx context.Context) (*ClusterOperatorsInformation, error) {
+	return c.fetcher.ClusterOperators(ctx)
+}
diff --git a/pkg/portal/cluster/fetcher.go b/pkg/portal/cluster/fetcher.go
new file mode 100644
index 000000000..00a33982e
--- /dev/null
+++ b/pkg/portal/cluster/fetcher.go
@@ -0,0 +1,72 @@
+package cluster
+
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache License 2.0.
+
+import (
+	"context"
+
+	configclient "github.com/openshift/client-go/config/clientset/versioned"
+	"github.com/sirupsen/logrus"
+
+	"github.com/Azure/ARO-RP/pkg/api"
+	"github.com/Azure/ARO-RP/pkg/proxy"
+	"github.com/Azure/ARO-RP/pkg/util/restconfig"
+)
+
+// FetchClient is the interface that the Admin Portal Frontend uses to gather
+// information about clusters. It returns frontend-suitable data structures.
+type FetchClient interface {
+	ClusterOperators(context.Context) (*ClusterOperatorsInformation, error)
+}
+
+// client is an implementation of FetchClient. It currently contains a "fetcher"
+// which is responsible for fetching information from the k8s clusters. The
+// mechanism of fetching the data from the cluster and returning it to the
+// frontend is deliberately split since in the future this fetcher will instead
+// operate off a queue-like interface, similar to the RP Cluster backend. The
+// client will then be responsible for caching and access control.
+type client struct {
+	log     *logrus.Entry
+	cluster *api.OpenShiftClusterDocument
+	fetcher *realFetcher
+}
+
+// realFetcher is responsible for fetching information from k8s clusters. It
+// contains Kubernetes clients and returns the frontend-suitable data
+// structures. The concrete implementation of FetchClient wraps this.
+type realFetcher struct {
+	log       *logrus.Entry
+	configcli configclient.Interface
+}
+
+func newRealFetcher(log *logrus.Entry, dialer proxy.Dialer, doc *api.OpenShiftClusterDocument) (*realFetcher, error) {
+	restConfig, err := restconfig.RestConfig(dialer, doc.OpenShiftCluster)
+	if err != nil {
+		log.Error(err)
+		return nil, err
+	}
+
+	configcli, err := configclient.NewForConfig(restConfig)
+	if err != nil {
+		return nil, err
+	}
+
+	return &realFetcher{
+		log:       log,
+		configcli: configcli,
+	}, nil
+}
+
+func NewFetchClient(log *logrus.Entry, dialer proxy.Dialer, cluster *api.OpenShiftClusterDocument) (FetchClient, error) {
+	fetcher, err := newRealFetcher(log, dialer, cluster)
+	if err != nil {
+		return nil, err
+	}
+
+	return &client{
+		log:     log,
+		cluster: cluster,
+		fetcher: fetcher,
+	}, nil
+}
diff --git a/pkg/portal/cluster/generate.go b/pkg/portal/cluster/generate.go
new file mode 100644
index 000000000..954d4af6a
--- /dev/null
+++ b/pkg/portal/cluster/generate.go
@@ -0,0 +1,7 @@
+package cluster
+
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache License 2.0.
+
+//go:generate go run ../../../vendor/github.com/go-bindata/go-bindata/go-bindata -nometadata -pkg $GOPACKAGE -prefix testdocs testdocs/...
+//go:generate gofmt -s -l -w bindata.go
diff --git a/pkg/portal/cluster_info.go b/pkg/portal/cluster_info.go
new file mode 100644
index 000000000..e60efb80a
--- /dev/null
+++ b/pkg/portal/cluster_info.go
@@ -0,0 +1,91 @@
+package portal
+
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache License 2.0.
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/gorilla/mux"
+)
+
+type AdminOpenShiftClusterDetail struct {
+	Name                    string `json:"name"`
+	Subscription            string `json:"subscription"`
+	ResourceGroup           string `json:"resourceGroup"`
+	ResourceId              string `json:"resourceId"`
+	ProvisioningState       string `json:"provisioningState"`
+	FailedProvisioningState string `json:"failedProvisioningState"`
+	Version                 string `json:"version"`
+	CreatedAt               string `json:"createdAt"`
+	ProvisionedBy           string `json:"provisionedBy"`
+	CreatedBy               string `json:"createdBy"`
+	ArchitectureVersion     string `json:"architectureVersion"`
+	LastProvisioningState   string `json:"lastProvisioningState"`
+	LastAdminUpdateError    string `json:"lastAdminUpdateError"`
+	InfraId                 string `json:"infraId"`
+	ApiServerVisibility     string `json:"apiServerVisibility"`
+	InstallPhase            string `json:"installStatus"`
+}
+
+func (p *portal) clusterInfo(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	apiVars := mux.Vars(r)
+
+	subscription := apiVars["subscription"]
+	resourceGroup := apiVars["resourceGroup"]
+	clusterName := apiVars["name"]
+
+	resourceId := strings.ToLower(fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.RedHatOpenShift/openShiftClusters/%s", subscription, resourceGroup, clusterName))
+
+	doc, err := p.dbOpenShiftClusters.Get(ctx, resourceId)
+	if err != nil {
+		http.Error(w, "Cluster not found", http.StatusNotFound)
+		return
+	}
+
+	createdAt := "Unknown"
+	if !doc.OpenShiftCluster.Properties.CreatedAt.IsZero() {
+		createdAt = doc.OpenShiftCluster.Properties.CreatedAt.Format(time.RFC3339)
+	}
+
+	installPhase := "Installed"
+	if doc.OpenShiftCluster.Properties.Install != nil {
+		installPhase = doc.OpenShiftCluster.Properties.Install.Phase.String()
+	}
+
+	clusterInfo := AdminOpenShiftClusterDetail{
+		ResourceId:              resourceId,
+		Name:                    clusterName,
+		Subscription:            subscription,
+		ResourceGroup:           resourceGroup,
+		CreatedAt:               createdAt,
+		ProvisionedBy:           doc.OpenShiftCluster.Properties.ProvisionedBy,
+		ProvisioningState:       doc.OpenShiftCluster.Properties.ProvisioningState.String(),
+		FailedProvisioningState: doc.OpenShiftCluster.Properties.FailedProvisioningState.String(),
+		Version:                 doc.OpenShiftCluster.Properties.ClusterProfile.Version,
+
+		CreatedBy:             doc.OpenShiftCluster.Properties.CreatedBy,
+		ArchitectureVersion:   strconv.Itoa(int(doc.OpenShiftCluster.Properties.ArchitectureVersion)),
+		LastProvisioningState: doc.OpenShiftCluster.Properties.LastProvisioningState.String(),
+		LastAdminUpdateError:  doc.OpenShiftCluster.Properties.LastAdminUpdateError,
+		InfraId:               doc.OpenShiftCluster.Properties.InfraID,
+		ApiServerVisibility:   string(doc.OpenShiftCluster.Properties.APIServerProfile.Visibility),
+		InstallPhase:          installPhase,
+	}
+
+	b, err := json.MarshalIndent(clusterInfo, "", "    ")
+	if err != nil {
+		p.internalServerError(w, err)
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	_, _ = w.Write(b)
+}
diff --git a/pkg/portal/info.go b/pkg/portal/info.go
new file mode 100644
index 000000000..1a6a62858
--- /dev/null
+++ b/pkg/portal/info.go
@@ -0,0 +1,44 @@
+package portal
+
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache License 2.0.
+
+import (
+	"encoding/json"
+	"net/http"
+
+	"github.com/gorilla/csrf"
+
+	"github.com/Azure/ARO-RP/pkg/portal/middleware"
+	"github.com/Azure/ARO-RP/pkg/util/version"
+)
+
+type PortalInfo struct {
+	Location  string `json:"location"`
+	CSRFToken string `json:"csrf"`
+	Elevated  bool   `json:"elevated"`
+	Username  string `json:"username"`
+	RPVersion string `json:"rpversion"`
+}
+
+func (p *portal) info(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	elevated := len(middleware.GroupsIntersect(p.elevatedGroupIDs, ctx.Value(middleware.ContextKeyGroups).([]string))) > 0
+
+	resp := PortalInfo{
+		Location:  p.env.Location(),
+		CSRFToken: csrf.Token(r),
+		Elevated:  elevated,
+		Username:  ctx.Value(middleware.ContextKeyUsername).(string),
+		RPVersion: version.GitCommit,
+	}
+
+	b, err := json.MarshalIndent(resp, "", "    ")
+	if err != nil {
+		p.internalServerError(w, err)
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	_, _ = w.Write(b)
+}
diff --git a/pkg/portal/portal.go b/pkg/portal/portal.go
index b4d12cbf5..4abc8a75c 100644
--- a/pkg/portal/portal.go
+++ b/pkg/portal/portal.go
@@ -9,24 +9,24 @@ import (
 	"crypto/rsa"
 	"crypto/tls"
 	"crypto/x509"
-	"encoding/json"
 	"fmt"
 	"html/template"
 	"log"
 	"net"
 	"net/http"
-	"sort"
+	"strings"
 	"time"
 
 	"github.com/gorilla/csrf"
 	"github.com/gorilla/mux"
 	"github.com/sirupsen/logrus"
 
-	"github.com/Azure/ARO-RP/pkg/api"
+	"github.com/Azure/ARO-RP/pkg/api/validate"
 	"github.com/Azure/ARO-RP/pkg/database"
 	"github.com/Azure/ARO-RP/pkg/env"
 	frontendmiddleware "github.com/Azure/ARO-RP/pkg/frontend/middleware"
 	"github.com/Azure/ARO-RP/pkg/metrics"
+	"github.com/Azure/ARO-RP/pkg/portal/cluster"
 	"github.com/Azure/ARO-RP/pkg/portal/kubeconfig"
 	"github.com/Azure/ARO-RP/pkg/portal/middleware"
 	"github.com/Azure/ARO-RP/pkg/portal/prometheus"
@@ -161,7 +161,7 @@ func (p *portal) setupRouter() error {
 	aadAuthenticatedRouter.Use(p.aad.AAD)
 	aadAuthenticatedRouter.Use(middleware.Log(p.env, p.audit, p.baseAccessLog))
 	aadAuthenticatedRouter.Use(p.aad.CheckAuthentication)
-	aadAuthenticatedRouter.Use(csrf.Protect(p.sessionKey, csrf.SameSite(csrf.SameSiteStrictMode), csrf.MaxAge(0)))
+	aadAuthenticatedRouter.Use(csrf.Protect(p.sessionKey, csrf.SameSite(csrf.SameSiteStrictMode), csrf.MaxAge(0), csrf.Path("/")))
 
 	p.aadAuthenticatedRoutes(aadAuthenticatedRouter)
 
@@ -252,79 +252,58 @@ func (p *portal) unauthenticatedRoutes(r *mux.Router) {
 func (p *portal) aadAuthenticatedRoutes(r *mux.Router) {
 	for _, name := range AssetNames() {
 		if name == "index.html" {
+			r.NewRoute().Methods(http.MethodGet).Path("/").HandlerFunc(p.serve(name))
 			continue
 		}
 
 		r.NewRoute().Methods(http.MethodGet).Path("/" + name).HandlerFunc(p.serve(name))
 	}
 
-	r.NewRoute().Methods(http.MethodGet).Path("/").HandlerFunc(p.index)
-
 	r.NewRoute().Methods(http.MethodGet).Path("/api/clusters").HandlerFunc(p.clusters)
-}
+	r.NewRoute().Methods(http.MethodGet).Path("/api/info").HandlerFunc(p.info)
 
-func (p *portal) serve(path string) func(w http.ResponseWriter, r *http.Request) {
-	return func(w http.ResponseWriter, r *http.Request) {
-		b, err := Asset(path)
-		if err != nil {
-			p.internalServerError(w, err)
-			return
-		}
-
-		http.ServeContent(w, r, path, time.Time{}, bytes.NewReader(b))
-	}
+	// Cluster-specific routes
+	r.NewRoute().PathPrefix("/api/{subscription}/{resourceGroup}/{name}/clusteroperators").HandlerFunc(p.clusterOperators)
+	r.NewRoute().Methods(http.MethodGet).Path("/api/{subscription}/{resourceGroup}/{name}").HandlerFunc(p.clusterInfo)
 }
 
-func (p *portal) index(w http.ResponseWriter, r *http.Request) {
-	buf := &bytes.Buffer{}
-
-	err := p.t.ExecuteTemplate(buf, "index.html", map[string]interface{}{
-		"location":       p.env.Location(),
-		csrf.TemplateTag: csrf.TemplateField(r),
-	})
-	if err != nil {
-		p.internalServerError(w, err)
-		return
+// makeFetcher creates a cluster.FetchClient suitable for use by the Portal REST API
+func (p *portal) makeFetcher(ctx context.Context, r *http.Request) (cluster.FetchClient, error) {
+	resourceID := strings.Join(strings.Split(r.URL.Path, "/")[:9], "/")
+	if !validate.RxClusterID.MatchString(resourceID) {
+		return nil, fmt.Errorf("invalid resource ID")
 	}
 
-	http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(buf.Bytes()))
-}
-
-func (p *portal) clusters(w http.ResponseWriter, r *http.Request) {
-	ctx := r.Context()
-
-	docs, err := p.dbOpenShiftClusters.ListAll(ctx)
+	doc, err := p.dbOpenShiftClusters.Get(ctx, resourceID)
 	if err != nil {
-		p.internalServerError(w, err)
-		return
+		return nil, err
 	}
 
-	clusters := make([]string, 0, len(docs.OpenShiftClusterDocuments))
-	for _, doc := range docs.OpenShiftClusterDocuments {
-		ps := doc.OpenShiftCluster.Properties.ProvisioningState
-		fps := doc.OpenShiftCluster.Properties.FailedProvisioningState
-
-		switch {
-		case ps == api.ProvisioningStateCreating,
-			ps == api.ProvisioningStateDeleting,
-			ps == api.ProvisioningStateFailed &&
-				(fps == api.ProvisioningStateCreating ||
-					fps == api.ProvisioningStateDeleting):
-		default:
-			clusters = append(clusters, doc.OpenShiftCluster.ID)
+	// In development mode, we can have localhost "fake" APIServers which don't
+	// get proxied, so use a direct dialer for this
+	var dialer proxy.Dialer
+	if p.env.IsLocalDevelopmentMode() && doc.OpenShiftCluster.Properties.APIServerProfile.IP == "127.0.0.1" {
+		dialer, err = proxy.NewDialer(false)
+		if err != nil {
+			return nil, err
 		}
+	} else {
+		dialer = p.dialer
 	}
 
-	sort.Strings(clusters)
+	return cluster.NewFetchClient(p.log, dialer, doc)
+}
 
-	b, err := json.MarshalIndent(clusters, "", "    ")
-	if err != nil {
-		p.internalServerError(w, err)
-		return
-	}
+func (p *portal) serve(path string) func(w http.ResponseWriter, r *http.Request) {
+	return func(w http.ResponseWriter, r *http.Request) {
+		b, err := Asset(path)
+		if err != nil {
+			p.internalServerError(w, err)
+			return
+		}
 
-	w.Header().Set("Content-Type", "application/json")
-	_, _ = w.Write(b)
+		http.ServeContent(w, r, path, time.Time{}, bytes.NewReader(b))
+	}
 }
 
 func (p *portal) internalServerError(w http.ResponseWriter, err error) {
diff --git a/pkg/util/dynamichelper/discovery/bindata.go b/pkg/util/dynamichelper/discovery/bindata.go
index 8676e84fc..534462b47 100644
--- a/pkg/util/dynamichelper/discovery/bindata.go
+++ b/pkg/util/dynamichelper/discovery/bindata.go
@@ -67,6 +67,7 @@
 // cache/packages.operators.coreos.com/v1/serverresources.json
 // cache/policy/v1/serverresources.json
 // cache/policy/v1beta1/serverresources.json
+// cache/preview.aro.openshift.io/v1alpha1/serverresources.json
 // cache/project.openshift.io/v1/serverresources.json
 // cache/quota.openshift.io/v1/serverresources.json
 // cache/rbac.authorization.k8s.io/v1/serverresources.json
@@ -363,7 +364,7 @@ func aroOpenshiftIoV1alpha1ServerresourcesJson() (*asset, error) {
 	return a, nil
 }
 
-var _assets_version = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x32\xd1\xb3\xd0\x33\x34\xe4\x02\x04\x00\x00\xff\xff\x38\x70\x7b\x2e\x07\x00\x00\x00")
+var _assets_version = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x32\xd1\xb3\xd0\x33\xb4\xe0\x02\x04\x00\x00\xff\xff\x71\xcb\xb9\xff\x07\x00\x00\x00")
 
 func assets_versionBytes() ([]byte, error) {
 	return bindataRead(
@@ -1503,6 +1504,26 @@ func policyV1beta1ServerresourcesJson() (*asset, error) {
 	return a, nil
 }
 
+var _previewAroOpenshiftIoV1alpha1ServerresourcesJson = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\xbc\x51\xbd\x6e\xb3\x30\x14\xdd\x79\x0a\x8b\x39\x22\x62\xf9\x86\x48\xdf\xd0\xa5\x3f\x52\x55\x85\x4a\x6d\x87\xaa\xc3\xad\xb9\x80\x55\x17\x5b\xf7\xda\x44\x6a\x94\x77\xaf\x0c\x04\x02\x22\x8d\xba\x94\x09\xce\x1f\xf6\x39\xfb\x48\x08\x21\xe2\x0f\x55\xe7\xf1\x46\xc4\x57\xdb\xbb\x47\x64\xe3\x49\xe2\xbd\x62\x17\xaf\x3a\x1a\xac\x7a\x46\x62\x65\xea\x20\x6a\xd2\x23\x5e\x92\xf1\xf6\x84\xb1\x84\x8d\xc2\x5d\x02\x64\x12\x63\xb1\xe6\x4a\x15\x2e\x51\x66\xdd\xa4\xa0\x6d\x05\x83\x91\xfa\x9f\x70\xbc\x11\xaf\x2d\x14\x9e\xfd\xf0\xd6\x8a\x6a\xf8\xc4\x93\xd4\x02\xc1\x79\x42\xee\x33\x06\x19\xab\xba\xf4\x1a\xe8\x61\x51\x3e\x57\x87\x50\xb6\x20\x31\xdc\xb7\x00\xcd\x38\x13\x1c\xab\xd8\x76\x31\xd7\xcb\x31\x0d\xd2\xfb\xf4\xf0\x03\x25\x09\xc1\xcd\x0d\x2d\x93\xa3\xc6\x9f\x18\x69\xb4\x46\xe9\x42\x99\x0b\x9a\x12\xdd\x12\xac\xc7\x9d\x26\xb8\x05\x27\xab\x25\xc2\xdb\xfc\xcc\xf9\x76\xad\x65\x82\xbf\xcd\xcb\x76\x86\xa0\xc4\x7e\xf3\x5b\xe0\x2a\x74\x45\xfc\x94\xfd\x4b\xbf\x5e\x6e\xb2\xec\xff\xe8\x3f\xac\x7e\xb9\xec\x9a\x1d\x38\x7f\x71\xe0\xbf\x9f\xf4\x4c\xf5\x97\x2a\x9e\x36\x39\xf6\x12\x75\xdf\x87\xe8\x3b\x00\x00\xff\xff\xbd\x92\xe9\xe1\x7e\x03\x00\x00")
+
+func previewAroOpenshiftIoV1alpha1ServerresourcesJsonBytes() ([]byte, error) {
+	return bindataRead(
+		_previewAroOpenshiftIoV1alpha1ServerresourcesJson,
+		"preview.aro.openshift.io/v1alpha1/serverresources.json",
+	)
+}
+
+func previewAroOpenshiftIoV1alpha1ServerresourcesJson() (*asset, error) {
+	bytes, err := previewAroOpenshiftIoV1alpha1ServerresourcesJsonBytes()
+	if err != nil {
+		return nil, err
+	}
+
+	info := bindataFileInfo{name: "preview.aro.openshift.io/v1alpha1/serverresources.json", size: 0, mode: os.FileMode(0), modTime: time.Unix(0, 0)}
+	a := &asset{bytes: bytes, info: info}
+	return a, nil
+}
+
 var _projectOpenshiftIoV1ServerresourcesJson = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\xb4\x50\xb1\x4e\xc3\x30\x14\xdc\xf3\x15\x96\xe7\xaa\x88\xb5\x1b\x23\x12\x42\x55\x07\x16\xc4\x60\x9c\x6b\x6a\x08\xb6\x79\xcf\x2e\x43\xd5\x7f\x47\x8e\x53\xb7\xb5\x12\x89\x81\x66\xca\xbb\xbb\x77\xe7\x7b\x87\x46\x08\x21\xe4\xa7\xb1\xad\x5c\x09\xf9\xb0\x7e\xdc\x80\x5d\x24\x8d\x27\xc3\x41\x2e\x32\xad\xbc\x79\x01\xb1\x71\x36\x89\xf6\xf7\x27\xbc\x23\x17\xfd\x05\xe3\xc9\x7d\x40\x87\xa5\xf3\xb0\xbc\x33\xdb\xb0\x34\xee\xee\x2c\xa7\xd1\x9a\xe5\x4a\xbc\x0e\x50\xfa\x0e\xe5\x6f\x10\x59\xf5\x85\x0b\x2f\xc2\x77\x04\x07\x1e\x3d\x8a\x8c\x8d\xed\x62\xaf\xe8\x79\x94\xd7\x7c\xb2\x61\xaf\x34\x52\xaf\xad\xea\x19\x95\xe0\x54\x79\x9d\x73\x36\x39\xa7\xb6\xd9\x83\xde\xaf\x9f\x5b\x28\x4d\x50\x01\xd5\xc2\xc0\xf4\xe9\x74\x57\xf0\x5b\x99\x8e\x8b\x3f\x16\xbf\x79\xe3\x7f\xa9\xda\xa2\xc7\x34\xd3\xa1\x4e\x38\xdf\x66\x02\xf7\x2a\xe8\xdd\x14\x11\x7d\x3b\x93\xfd\x33\xac\xcc\xdd\xb9\xc9\xf3\xb1\xf9\x0d\x00\x00\xff\xff\x6e\x73\x36\xe7\xe3\x02\x00\x00")
 
 func projectOpenshiftIoV1ServerresourcesJsonBytes() ([]byte, error) {
@@ -1703,7 +1724,7 @@ func securityOpenshiftIoV1ServerresourcesJson() (*asset, error) {
 	return a, nil
 }
 
-var _servergroupsJson = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\xcc\xdc\xbf\x72\xe3\x36\x13\x00\xf0\xde\x4f\x81\x51\xfd\x8d\xbe\x89\xd3\xdc\x5c\x97\x2a\x93\x99\x14\xa9\xd2\x64\xae\x80\x29\x48\x42\x4c\x01\x0c\x00\xca\x71\x6e\xee\xdd\x33\xfa\x63\x3b\x14\x57\xdc\x25\x17\x38\xec\x35\xe7\xf3\xd1\x8b\x9f\x77\x97\x22\x08\x50\xfa\xfa\xa0\x94\x52\x2b\xdd\xd9\xdf\x4d\x88\xd6\xbb\xd5\x67\xb5\x3a\xfe\xb0\xfa\xdf\xe5\xfb\xbb\xe0\xfb\x2e\xae\x3e\xab\x3f\xce\xff\x3e\xfd\xf9\xfa\xfe\xd5\xf9\x08\xa7\x0f\xe6\xf4\x33\xd7\x9f\x78\xff\x7e\x17\xcc\xd6\x84\x60\x36\x1f\x71\x87\x3f\xf9\x11\x1f\x1a\x79\x70\xd4\x71\x70\xc0\xe0\xff\xbf\xdd\x0c\x7b\x3d\x74\x48\x86\xe9\xf3\x10\x18\xe4\x8c\x19\x7c\xe7\xcb\x03\x80\xbc\x93\x3d\xdd\xd9\x60\x76\x36\xa6\xa0\x93\xf5\x6e\xfd\xfc\x29\xae\xad\xcf\x93\x53\x38\xf6\xff\x45\xa4\x7a\x8e\x0d\xf3\x01\xc6\x1c\x92\x27\x93\x34\x8d\x73\x39\x32\x6b\x57\x74\x31\x57\x0f\x74\x51\x4a\xc5\xef\x49\x30\x8d\x62\xe5\xd2\x1c\x8d\x4b\x31\xeb\x89\x35\x08\x29\x23\xbb\x04\x12\xc6\x02\x68\x0c\x40\xcd\xb3\xa7\x4f\x7b\xe3\x92\x6d\x0a\xbc\xa4\x42\xa1\x65\x74\xc0\x0c\x1a\xc6\x03\x88\x19\x20\x95\x3b\xc2\x07\xfb\x4f\x99\x86\xb8\x8d\x2c\xa7\x1f\x68\x32\x4c\x07\x08\xf9\x8e\xba\xdd\xe0\x63\xa3\x5b\xeb\x76\xd9\x9a\xe0\x2d\xa0\x98\xda\x4f\x83\x30\x14\x00\x5b\x3a\xfc\x23\xb9\xd2\x8f\xf7\x2a\x9d\x11\xf2\x48\x86\x3c\xe6\x6c\xb9\x27\x9d\x9a\x7d\x9e\x66\x3b\x87\x92\xd1\x66\x13\x14\x8c\x03\x90\x16\x0c\x5c\xf1\x45\xa4\x31\x21\xd9\xed\xe9\x22\x67\xf2\x4e\x2e\x81\xc0\x32\xaa\x4d\x86\x61\x38\x00\xc8\x66\x54\xec\x04\x67\xd2\x8b\x0f\xcf\xd6\xed\xb2\xf6\xc1\x28\xac\x8c\x2e\x20\xb2\x30\x1a\xc0\x63\x22\x2a\x76\x80\xf9\x3b\x19\x77\xc9\x6c\x9e\xfb\xcb\xf7\x78\x13\xbf\x17\xfa\x3b\x15\xb9\xcd\xa4\xc8\x48\x3a\xc5\xca\x78\xe7\x5b\xdb\xbc\xe6\xc9\xf6\x25\x96\x8c\xb3\x6b\xca\x82\x79\x00\xd3\x92\x91\x2b\x56\x35\x3c\xe9\x66\x5d\xee\x5e\xed\x6e\x78\x19\xb5\x9f\xc9\xc3\x88\x00\x33\x13\xa6\x62\x87\xc4\xe4\x83\xde\x99\xac\x6d\x31\x8c\x29\xa3\x17\x28\x26\xcc\x05\xd8\x38\x82\x9a\x37\xec\x9b\x83\x8d\xa7\xf8\xe5\xb6\x4a\x26\x46\x90\xd1\x11\xf3\x85\x98\x12\x90\xe6\xf3\x54\xdd\x3c\xb1\x1f\xd3\x94\xdc\x1b\x6a\xa3\xc8\x42\xba\x83\x2c\xc3\x74\x80\x90\xef\xa8\x79\xc5\x68\xf6\x66\xd3\xb7\xb9\xef\xce\x46\x61\x65\xf4\x01\x91\x85\xd1\x00\x1e\x13\x51\x73\xa5\xc6\xfb\xb0\xb1\x2e\xff\x45\x03\x08\x2c\xa3\x0b\xc8\x30\x0c\x07\x00\xd9\x8c\x9a\x2b\x35\x7e\x93\x77\xea\xf8\x9f\x80\x32\x2a\x8f\x82\x30\x14\x00\x5b\x3c\x7c\xc5\x4a\x6f\x6c\x6c\xfc\xd1\x84\xd7\xac\xe5\xbe\x8d\x2a\xa3\xe6\x34\x15\x26\x03\x74\x3c\x43\xc5\xea\x6f\x5b\xff\xd2\x78\x97\x82\x6f\xd7\xba\xb3\xd1\x84\xa3\x09\x59\x3b\x61\x6a\x04\x69\x6b\x76\x0b\xad\x24\xaf\x62\x3f\xe6\xb4\xf6\x9d\x71\x71\x6f\xb7\x29\xe3\x34\xfd\x26\xac\x8c\xf3\x94\xc8\xc2\x68\x2a\xe3\x93\x10\x05\x72\x7f\x37\xbe\x90\x22\xcc\xf4\x61\x46\xc5\xdb\x16\xee\x6d\xbb\x29\x50\x85\x71\x5c\x19\xd9\xa7\xba\x30\x9b\x62\x65\xdd\x1e\xf4\xce\x14\xc8\xfa\x38\xae\x8c\xac\x53\x5d\x98\x4d\xb1\xb2\xee\x4f\xa7\x5e\x81\xac\x8f\xe3\xca\xc8\x3a\xd5\x85\xd9\x14\x6f\xa3\x2c\xf8\x3f\x4d\x93\x0a\xe4\x1d\x8a\x2c\x23\xf3\x74\x19\xa6\x53\xac\xdc\xff\xd5\xfb\xa4\x0b\x64\x7e\x1c\x57\x46\xde\xa9\x2e\xcc\xa6\x78\x9b\x88\xbe\x4f\x25\x5e\xdd\xc7\x71\x65\x64\x9d\xea\xc2\x6c\x8a\xb7\xcc\x6a\x9a\x3e\xd8\xf4\x5a\x20\xf1\x60\x68\x19\xb9\x9f\x41\xc3\x78\x8a\x95\xfe\x64\x0e\x5d\xab\x8b\xf4\x3d\x18\x5a\x46\xfa\x67\xd0\x30\x9e\x62\xa5\xbf\x8f\x26\x14\x48\xfd\x28\xac\x8c\xb4\x13\x59\x18\x4d\xf1\x26\x36\xba\x79\xd6\x3b\x73\xbe\xa3\x0e\x3a\xf9\x10\xd7\x8d\x0f\xc6\x9f\xfe\x3a\x64\x9a\xe1\x4c\x0d\x21\xa3\x14\x0b\x88\x18\x53\x31\x37\x5b\xdc\xd6\xee\x0a\x9c\x0b\x40\x60\x19\x25\x20\xc3\x30\x9c\xe2\xdd\x5e\x5d\x1b\xa0\xc4\x1d\x16\x14\x5a\x46\xf2\x67\xd0\x30\x1e\x40\xcc\x00\xd1\x6d\xb7\x27\x2e\xaf\x5e\x0f\xcd\xd8\x13\x1f\xab\xbd\x25\x16\x59\xa1\xd8\x32\xba\x62\x8e\x0d\xf3\xa9\x5c\x6f\x35\x2a\xb3\xd8\x0a\x46\x17\x52\x85\x59\x3a\x4c\x08\x28\xf3\x58\x6a\x3e\x99\xd0\xfa\x7e\xd3\x04\xb3\x31\x2e\x59\xdd\x96\xb8\x6a\x4e\x8c\x20\xa3\x4b\xe6\x0b\x31\xa5\xe2\x4e\x60\xa2\x6f\x4b\xdc\x48\x41\x91\x85\xd4\x80\x2c\xc3\x74\x80\x90\xef\xa8\x7a\x0d\x3d\x2f\x9e\x5f\x9f\x71\x7c\x5d\x17\x9c\x65\x11\x06\x92\xd1\x2d\x8b\xa1\x18\x56\xf1\x0a\xe5\x76\xc1\xc4\x58\xb4\x44\x53\x43\x08\x29\xce\x7c\x22\xc6\x54\xac\xb2\x3c\x7f\x8a\xeb\xc6\xd9\x75\xe3\x9a\x6d\xb6\x42\xdc\x04\x95\x91\x7a\x12\x0a\x83\x29\x56\xb2\x0f\xba\xd9\x5b\x67\x2e\xb7\xa4\x7d\x28\xb5\xc9\x8f\x0e\x23\xa3\x20\x0b\x99\x18\x55\xf1\x4a\xe4\x9d\x4d\x3e\x9c\x26\xa2\xb9\xd7\xab\xc0\xd0\x42\x4a\x41\xa7\x61\x3c\x80\x98\x01\x52\x75\x8e\x71\x7d\x3b\x6b\x81\x33\x15\x8a\x2c\xa3\x23\xe8\x32\x4c\xa7\x72\xe5\xbe\xd4\xb4\x61\x72\x08\x71\xd5\x90\x31\x6d\x28\xb7\xaa\x0f\xaf\x94\x43\x9f\x0b\x32\xf8\xed\x1e\xbf\xdf\x62\x26\x2e\xc3\x74\x80\x90\xed\xa8\xb7\xa6\x1a\x81\x97\x6a\x52\x56\xde\x0e\xfd\x4e\xa6\x5a\x97\x8f\xa8\x0f\x5d\x6b\x8a\xde\xf9\x4c\x0e\x21\xe3\x25\x6c\x01\x11\x63\xaa\x3c\x8f\x44\x58\x97\x4c\x70\x45\xd6\xf7\xa6\xc7\x10\x52\x98\x05\x46\xcc\xa9\x78\x95\x71\xba\x8b\x7b\x9f\xd6\x45\xde\x4e\x0e\x07\x17\x52\x8b\x39\x38\x0c\x08\x20\xb3\x50\x2a\xae\xc8\xa7\xde\x99\x12\x0f\x64\x8f\xe3\xca\xe8\x07\xaa\x0b\xb3\x29\xde\x2e\x59\xf0\x25\x76\xc7\x6e\xa2\x4e\x5d\xa6\xf1\x4b\x74\x91\x1d\xb2\x19\x42\x9a\x52\x71\xf7\x3e\x52\xf0\x6d\xd7\x6a\x67\x4a\xce\x26\xf0\x71\xe4\xd5\x8a\x67\xa6\xb9\x15\x6f\x65\xc9\x24\xdd\xfe\x98\x6f\x91\xef\x2d\x9c\xbc\x5a\x90\x68\x34\x9e\xe2\xa5\xdc\xee\x0a\x7c\x18\xc9\x6d\x54\x81\x05\x98\x21\xa4\x29\x15\xab\x0e\x2f\x7b\x13\x8c\x7e\xf2\x7d\x2a\xb0\xd9\x70\x27\xb8\xbc\xaa\x2c\x80\xd2\xb0\x8a\x55\x9c\xbd\x69\x0f\x05\x2e\x22\xa3\xb0\xd2\xde\x77\x3b\x07\x48\x42\xaa\x1c\x7b\x43\xe5\xb6\x83\x44\x17\x63\xa6\x91\xe4\x54\xdc\xcb\x75\xb0\x4d\xde\x0f\x28\x1a\xc6\x14\x57\x03\xaa\x8e\x24\x54\x53\xd9\x3f\x7f\xf5\xe5\x12\x79\xf5\x6c\xdd\xe6\x14\xe6\xa7\xdf\x7e\xf9\xf9\x44\xfa\xd5\xc6\xb4\x7a\xf8\xf6\xf0\x6f\x00\x00\x00\xff\xff\x70\x17\x20\xf6\x03\x67\x00\x00")
+var _servergroupsJson = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\xcc\xdc\xbf\x72\xe3\x36\x13\x00\xf0\xde\x4f\x81\x51\xfd\x0d\xbf\x89\xd3\xdc\x5c\x97\x2a\x93\x99\x14\xa9\xd2\x64\xae\x80\xa9\x95\x84\x98\x02\x18\x00\x94\xe3\xdc\xdc\xbb\x67\xf4\xc7\x76\x24\xae\xb8\x4b\x02\x38\xec\x35\xe7\xf3\xd1\x8b\x9f\x77\x97\x22\x08\x50\xfa\xfa\xa0\x94\x52\x2b\xdd\x9b\xdf\xc1\x07\xe3\xec\xea\xb3\x5a\x1d\x7e\x58\xfd\xef\xfc\xfd\xad\x77\x43\x1f\x56\x9f\xd5\x1f\xa7\x7f\x1f\xff\x7c\x7d\xff\xea\x74\x84\xd5\x7b\x38\xfe\xcc\xe5\x27\xde\xbf\xdf\x7b\xd8\x80\xf7\xb0\xfe\x88\x7b\xfd\x93\x1f\xf1\xb1\x91\xaf\x8e\x3a\x5c\x1d\x70\xf5\xff\xdf\x6e\x86\xbd\x1c\x7a\x4d\xc6\xe9\xf3\x10\x14\xe4\x84\xb9\xfa\xce\x97\x07\x04\x79\x27\x7b\xba\x37\x1e\xb6\x26\x44\xaf\xa3\x71\xb6\x79\xfe\x14\x1a\xe3\xf2\xe4\x14\x8f\xfd\x7f\x11\xa9\x9e\x63\xa3\x7c\x88\x31\x87\xe4\x09\xa2\xe6\x71\xce\x47\x66\xed\x8a\x3e\xe4\xea\x81\x3e\x48\xa9\xf8\x3d\x09\xa5\x51\x49\xb9\x84\x03\xd8\x18\xb2\x9e\x58\x57\x21\x65\x64\x97\x41\xa2\x58\x08\x2d\x01\x50\xf3\xec\x19\xe2\x0e\x6c\x34\x6d\x81\x97\x54\x2c\xb4\x8c\x0e\x98\x41\xa3\x78\x08\x31\x03\xa4\x72\x47\x38\x6f\xfe\x29\xd3\x10\xb7\x91\xe5\xf4\x03\x4f\x46\xe9\x10\x61\xba\xa3\x6e\x37\xb8\xd0\xea\xce\xd8\x6d\xb6\x26\x78\x0b\x28\xa6\xf6\xd3\x20\x0a\x85\xc0\x96\x0e\xff\xc8\xae\xf4\xe3\xbd\x4a\x67\x84\x3c\xb2\x21\x8f\x39\x5b\xee\x49\xc7\x76\x97\xa7\xd9\x4e\xa1\x64\xb4\xd9\x04\x85\xe2\x20\xa4\x05\x03\x57\x7c\x11\x69\xc1\x47\xb3\x39\x5e\xe4\x20\xef\xe4\x12\x09\x2c\xa3\xda\x6c\x18\x85\x43\x80\xc9\x8c\x8a\x9d\x60\x21\xbe\x38\xff\x6c\xec\x36\x6b\x1f\x8c\xc2\xca\xe8\x02\x26\x8b\xa2\x21\xbc\x44\x44\xc5\x0e\x80\xbf\x23\xd8\x73\x66\xf3\xdc\x5f\xbe\xc7\x9b\xf8\xbd\xc8\xdf\xa9\xc8\x6d\x26\x47\xc6\xd2\xa9\xa4\x8c\xf7\xae\x33\xed\x6b\x9e\x6c\x9f\x63\xc9\x38\xbb\xa6\x2c\x94\x07\x31\x2d\x19\xb9\x62\x55\xfd\x93\x6e\x9b\x72\xf7\x6a\x77\xc3\xcb\xa8\xfd\x4c\x1e\x45\x44\x98\x99\x30\x15\x3b\x24\x44\xe7\xf5\x16\xb2\xb6\xc5\x75\x4c\x19\xbd\xc0\x31\x51\x2e\xc4\x96\x22\xa8\x79\xc3\xbe\xde\x9b\x70\x8c\x5f\x6e\xab\x64\x62\x04\x19\x1d\x31\x5f\x48\x29\x11\x69\x3e\x4f\xd5\xcd\x13\xf3\x31\x4d\xc9\xbd\xa1\x36\x8a\x2c\xa4\x3b\xd8\x32\x4a\x87\x08\xd3\x1d\x35\xaf\x18\xed\x0e\xd6\x43\x97\xfb\xee\x6c\x14\x56\x46\x1f\x30\x59\x14\x0d\xe1\x25\x22\x6a\xae\xd4\x38\xe7\xd7\xc6\xe6\xbf\x68\x20\x81\x65\x74\x01\x1b\x46\xe1\x10\x60\x32\xa3\xe6\x4a\x8d\x5b\xe7\x9d\x3a\xfe\x27\xa0\x8c\xca\x93\x20\x0a\x85\xc0\x16\x0f\x5f\xb1\xd2\x6b\x13\x5a\x77\x00\xff\x9a\xb5\xdc\xb7\x51\x65\xd4\x9c\xa7\xa2\x64\x88\x2e\xcd\x50\xb1\xfa\x9b\xce\xbd\xb4\xce\x46\xef\xba\x46\xf7\x26\x80\x3f\x80\xcf\xda\x09\x53\x23\x48\x5b\xb3\x5b\x68\x65\x79\x55\xf2\x63\x4e\x8d\xeb\xc1\x86\x9d\xd9\xc4\x8c\xd3\xf4\x9b\xb0\x32\xce\x53\x26\x8b\xa2\xa9\x8c\x4f\x42\x14\xc8\xfd\xdd\xf8\x42\x8a\x30\xd3\x47\x19\x55\xda\xb6\xf0\x60\xba\x75\x81\x2a\x8c\xe3\xca\xc8\x3e\xd7\x45\xd9\x54\x52\xd6\xcd\x5e\x6f\xa1\x40\xd6\xc7\x71\x65\x64\x9d\xeb\xa2\x6c\x2a\x29\xeb\xee\x78\xea\x15\xc8\xfa\x38\xae\x8c\xac\x73\x5d\x94\x4d\xa5\x6d\x94\x79\xf7\x27\xb4\xb1\x40\xde\xb1\xc8\x32\x32\xcf\x97\x51\x3a\x95\x94\xfb\xbf\x06\x17\x75\x81\xcc\x8f\xe3\xca\xc8\x3b\xd7\x45\xd9\x54\xda\x26\xa2\x1b\x62\x89\x57\xf7\x71\x5c\x19\x59\xe7\xba\x28\x9b\x4a\x5b\x66\x85\x76\xf0\x26\xbe\x16\x48\x3c\x1a\x5a\x46\xee\x67\xd0\x28\x9e\x4a\x4a\x7f\x84\x7d\xdf\xe9\x22\x7d\x8f\x86\x96\x91\xfe\x19\x34\x8a\xa7\x92\xd2\x3f\x04\xf0\x05\x52\x3f\x0a\x2b\x23\xed\x4c\x16\x45\x53\x69\x13\x1b\xdd\x3e\xeb\x2d\x9c\xee\xa8\xbd\x8e\xce\x87\xa6\x75\x1e\xdc\xf1\xaf\x7d\xa6\x19\xce\xd4\x10\x32\x4a\xb1\x80\x48\x31\x55\xe2\x66\x8b\xdd\x98\x6d\x81\x73\x01\x09\x2c\xa3\x04\x6c\x18\x85\x53\x69\xb7\x57\x97\x06\x28\x71\x87\x85\x85\x96\x91\xfc\x19\x34\x8a\x87\x10\x33\x40\x74\xd7\xef\x98\xcb\xab\x97\x43\x33\xf6\xc4\xc7\x6a\x6f\x89\x45\x56\x2c\xb6\x8c\xae\x98\x63\xa3\x7c\x2a\xd7\x5b\x8d\xca\x2c\xb6\xa2\xd1\x85\x54\x61\x96\x8e\x12\x22\xca\x3c\x96\x9a\x4f\x26\x74\x6e\x58\xb7\x1e\xd6\x60\xa3\xd1\x5d\x89\xab\xe6\xc4\x08\x32\xba\x64\xbe\x90\x52\xaa\xd4\x09\x4c\x70\x5d\x89\x1b\x29\x2c\xb2\x90\x1a\xb0\x65\x94\x0e\x11\xa6\x3b\xaa\x5e\x43\x4f\x8b\xe7\x97\x67\x1c\x5f\x9b\x82\xb3\x2c\xc6\x40\x32\xba\x65\x31\x94\xc2\xaa\xb4\x42\xd9\xad\x87\x10\x8a\x96\x68\x6a\x08\x21\xc5\x99\x4f\xa4\x98\x2a\xa9\x2c\xcf\x9f\x42\xd3\x5a\xd3\xb4\xb6\xdd\x64\x2b\xc4\x4d\x50\x19\xa9\x67\xa1\x28\x98\x4a\x4a\xf6\x5e\xb7\x3b\x63\xe1\x7c\x4b\x3a\xf8\x52\x9b\xfc\xe4\x30\x32\x0a\xb2\x90\x49\x51\x55\x5a\x89\x9c\x35\xd1\xf9\xe3\x44\x34\xf7\x7a\x15\x1a\x5a\x48\x29\xf8\x34\x8a\x87\x10\x33\x40\xaa\xce\x31\x2e\x6f\x67\x2d\x70\xa6\x62\x91\x65\x74\x04\x5f\x46\xe9\x54\xae\xdc\x97\x9a\x36\x4c\x0e\x21\xae\x1a\x32\xa6\x0d\xe5\x56\xf5\xf1\x95\x72\xec\x73\x41\xae\x7e\xbb\xc7\xef\xb7\x98\x49\xcb\x28\x1d\x22\x4c\x76\xd4\x5b\x53\x0d\xc8\x4b\x35\x2b\x2b\x6f\x87\x7e\x27\x53\xad\xcb\x47\xd0\xfb\xbe\x83\xa2\x77\x3e\x93\x43\xc8\x78\x09\x5b\x40\xa4\x98\x2a\xcf\x23\x11\xc6\x46\xf0\xb6\xc8\xfa\xde\xf4\x18\x42\x0a\xb3\xc0\x48\x39\x55\x5a\x65\xac\xee\xc3\xce\xc5\xa6\xc8\xdb\xc9\xf1\xe0\x42\x6a\x31\x07\x47\x01\x11\x64\x16\x4a\xc5\x15\xf9\x38\x58\x28\xf1\x40\xf6\x38\xae\x8c\x7e\xe0\xba\x28\x9b\x4a\xdb\x25\xf3\xae\xc4\xee\xd8\x4d\xd4\xa9\xcb\x34\x7d\x89\x2e\xb2\x43\x36\x43\xc8\x53\xaa\xd4\xbd\x8f\xe8\x5d\xd7\x77\xda\x42\xc9\xd9\x04\x3d\x8e\xbc\x5a\xa5\x99\x79\x6e\x95\xb6\xb2\x04\x51\x77\x3f\xe6\x5b\xe4\x7b\x0b\x27\xaf\x16\x2c\x1a\x8f\xa7\xd2\x52\x6e\xb6\x05\x3e\x8c\xe4\x36\xaa\xc0\x02\xcc\x10\xf2\x94\x2a\xf1\x5d\x0e\x70\x30\xf0\xd2\x94\xb9\x90\xdc\x8b\x2e\xaf\x2e\x4b\xa4\x3c\xad\x4a\xaa\xcf\xcb\x0e\x3c\xe8\x27\x37\xc4\x02\x9b\x41\x77\x82\xcb\xab\xce\x02\x28\x0f\xab\x92\x8a\xb3\x83\x6e\x5f\xe0\xac\x19\x85\x95\xf6\xbe\xe8\x39\x40\x16\x52\xe5\xd8\xbb\x2b\xb7\x5d\x27\xba\x18\x33\x8d\x2c\xa7\x4a\x9d\x4e\x79\xd3\xe6\xfd\x00\xa9\xeb\x98\xe2\x6a\xc0\xd5\xb1\x84\x6a\x2a\xfb\xa7\xaf\xbe\x9c\x23\xaf\x9e\x8d\x5d\x1f\xc3\xfc\xf4\xdb\x2f\x3f\x1f\x49\xbf\x9a\x10\x57\x0f\xdf\x1e\xfe\x0d\x00\x00\xff\xff\x27\x6e\xbd\xeb\xa3\x68\x00\x00")
 
 func servergroupsJsonBytes() ([]byte, error) {
 	return bindataRead(
@@ -2022,6 +2043,7 @@ var _bindata = map[string]func() (*asset, error){
 	"packages.operators.coreos.com/v1/serverresources.json":            packagesOperatorsCoreosComV1ServerresourcesJson,
 	"policy/v1/serverresources.json":                                   policyV1ServerresourcesJson,
 	"policy/v1beta1/serverresources.json":                              policyV1beta1ServerresourcesJson,
+	"preview.aro.openshift.io/v1alpha1/serverresources.json":           previewAroOpenshiftIoV1alpha1ServerresourcesJson,
 	"project.openshift.io/v1/serverresources.json":                     projectOpenshiftIoV1ServerresourcesJson,
 	"quota.openshift.io/v1/serverresources.json":                       quotaOpenshiftIoV1ServerresourcesJson,
 	"rbac.authorization.k8s.io/v1/serverresources.json":                rbacAuthorizationK8sIoV1ServerresourcesJson,
@@ -2372,6 +2394,11 @@ var _bintree = &bintree{nil, map[string]*bintree{
 			"serverresources.json": {policyV1beta1ServerresourcesJson, map[string]*bintree{}},
 		}},
 	}},
+	"preview.aro.openshift.io": {nil, map[string]*bintree{
+		"v1alpha1": {nil, map[string]*bintree{
+			"serverresources.json": {previewAroOpenshiftIoV1alpha1ServerresourcesJson, map[string]*bintree{}},
+		}},
+	}},
 	"project.openshift.io": {nil, map[string]*bintree{
 		"v1": {nil, map[string]*bintree{
 			"serverresources.json": {projectOpenshiftIoV1ServerresourcesJson, map[string]*bintree{}},
diff --git a/pkg/util/dynamichelper/discovery/cache/assets_version b/pkg/util/dynamichelper/discovery/cache/assets_version
index c1b61435c..b5de5f0c6 100644
--- a/pkg/util/dynamichelper/discovery/cache/assets_version
+++ b/pkg/util/dynamichelper/discovery/cache/assets_version
@@ -1,1 +1,1 @@
-4.8.11
+4.8.18
diff --git a/pkg/util/dynamichelper/discovery/cache/preview.aro.openshift.io/v1alpha1/serverresources.json b/pkg/util/dynamichelper/discovery/cache/preview.aro.openshift.io/v1alpha1/serverresources.json
new file mode 100644
index 000000000..29cb8ff32
--- /dev/null
+++ b/pkg/util/dynamichelper/discovery/cache/preview.aro.openshift.io/v1alpha1/serverresources.json
@@ -0,0 +1,35 @@
+{
+    "kind": "APIResourceList",
+    "apiVersion": "v1",
+    "groupVersion": "preview.aro.openshift.io/v1alpha1",
+    "resources": [
+        {
+            "name": "previewfeatures",
+            "singularName": "previewfeature",
+            "namespaced": false,
+            "kind": "PreviewFeature",
+            "verbs": [
+                "create",
+                "delete",
+                "deletecollection",
+                "get",
+                "list",
+                "patch",
+                "update",
+                "watch"
+            ],
+            "storageVersionHash": "rsUQ61zWGQQ="
+        },
+        {
+            "name": "previewfeatures/status",
+            "singularName": "",
+            "namespaced": false,
+            "kind": "PreviewFeature",
+            "verbs": [
+                "get",
+                "patch",
+                "update"
+            ]
+        }
+    ]
+}
diff --git a/pkg/util/dynamichelper/discovery/cache/servergroups.json b/pkg/util/dynamichelper/discovery/cache/servergroups.json
index 4de5480c2..2b89163b1 100644
--- a/pkg/util/dynamichelper/discovery/cache/servergroups.json
+++ b/pkg/util/dynamichelper/discovery/cache/servergroups.json
@@ -820,6 +820,19 @@
                 }
             ]
         },
+        {
+            "name": "preview.aro.openshift.io",
+            "preferredVersion": {
+                "groupVersion": "preview.aro.openshift.io/v1alpha1",
+                "version": "v1alpha1"
+            },
+            "versions": [
+                {
+                    "groupVersion": "preview.aro.openshift.io/v1alpha1",
+                    "version": "v1alpha1"
+                }
+            ]
+        },
         {
             "name": "whereabouts.cni.cncf.io",
             "preferredVersion": {
diff --git a/pkg/util/pullsecret/pullsecret.go b/pkg/util/pullsecret/pullsecret.go
index fd0855d7b..6c25a8c59 100644
--- a/pkg/util/pullsecret/pullsecret.go
+++ b/pkg/util/pullsecret/pullsecret.go
@@ -37,7 +37,9 @@ func UnmarshalSecretData(ps *corev1.Secret) (map[string]string, error) {
 
 	secretData := map[string]string{}
 	for k, v := range pullSecretData.Auths {
-		secretData[k] = v["auth"].(string)
+		if v["auth"] != nil {
+			secretData[k] = v["auth"].(string)
+		}
 	}
 
 	return secretData, nil
diff --git a/pkg/util/version/const.go b/pkg/util/version/const.go
index 0a7112dc2..4804218c5 100644
--- a/pkg/util/version/const.go
+++ b/pkg/util/version/const.go
@@ -27,8 +27,8 @@ var GitCommit = "unknown"
 
 // InstallStream describes stream we are defaulting to for all new clusters
 var InstallStream = &Stream{
-	Version:  NewVersion(4, 8, 11),
-	PullSpec: "quay.io/openshift-release-dev/ocp-release@sha256:26f9da8c2567ddf15f917515008563db8b3c9e43120d3d22f9d00a16b0eb9b97",
+	Version:  NewVersion(4, 8, 18),
+	PullSpec: "quay.io/openshift-release-dev/ocp-release@sha256:321aae3d3748c589bc2011062cee9fd14e106f258807dc2d84ced3f7461160ea",
 }
 
 // UpgradeStreams describes list of streams we support for upgrades


Test patch:
diff --git a/pkg/cluster/kubeconfig_test.go b/pkg/cluster/kubeconfig_test.go
index 7eac1b450..5b3cbcb8b 100644
--- a/pkg/cluster/kubeconfig_test.go
+++ b/pkg/cluster/kubeconfig_test.go
@@ -4,8 +4,10 @@ package cluster
 // Licensed under the Apache License 2.0.
 import (
 	"crypto/x509"
+	"crypto/x509/pkix"
 	"encoding/pem"
 	"reflect"
+	"strings"
 	"testing"
 	"time"
 
@@ -14,7 +16,9 @@ import (
 	"github.com/openshift/installer/pkg/asset/tls"
 	clientcmdv1 "k8s.io/client-go/tools/clientcmd/api/v1"
 
+	"github.com/Azure/ARO-RP/pkg/api"
 	"github.com/Azure/ARO-RP/pkg/cluster/graph"
+	"github.com/Azure/ARO-RP/pkg/util/cmp"
 	utilpem "github.com/Azure/ARO-RP/pkg/util/pem"
 	utiltls "github.com/Azure/ARO-RP/pkg/util/tls"
 )
@@ -35,7 +39,7 @@ func TestGenerateAROServiceKubeconfig(t *testing.T) {
 		},
 	}
 
-	apiserverURL := "https://api.hash.rg.mydomain:6443"
+	apiserverURL := "https://api-int.hash.rg.mydomain:6443"
 	clusterName := "api-hash-rg-mydomain:6443"
 	serviceName := "system:aro-service"
 
@@ -46,7 +50,7 @@ func TestGenerateAROServiceKubeconfig(t *testing.T) {
 				Name: clusterName,
 				Cluster: clientcmdv1.Cluster{
 					Server:                   apiserverURL,
-					CertificateAuthorityData: nil,
+					CertificateAuthorityData: []byte("internal API Cert"),
 				},
 			},
 		},
@@ -127,6 +131,247 @@ func TestGenerateAROServiceKubeconfig(t *testing.T) {
 	want := adminInternalClient.Config
 
 	if !reflect.DeepEqual(got, want) {
-		t.Fatal("invalid internal client.")
+		t.Fatal(cmp.Diff(got, want))
+	}
+}
+
+func TestGenerateUserAdminKubeconfig(t *testing.T) {
+	validCaKey, validCaCerts, err := utiltls.GenerateKeyAndCertificate("validca", nil, nil, true, false)
+	if err != nil {
+		t.Fatal(err)
+	}
+	b := x509.MarshalPKCS1PrivateKey(validCaKey)
+
+	ca := &tls.AdminKubeConfigSignerCertKey{
+		SelfSignedCertKey: tls.SelfSignedCertKey{
+			CertKey: tls.CertKey{
+				CertRaw: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: validCaCerts[0].Raw}),
+				KeyRaw:  pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: b}),
+			},
+		},
+	}
+
+	apiserverURL := "https://api-int.hash.rg.mydomain:6443"
+	clusterName := "api-hash-rg-mydomain:6443"
+	serviceName := "system:admin"
+
+	adminInternalClient := &kubeconfig.AdminInternalClient{}
+	adminInternalClient.Config = &clientcmdv1.Config{
+		Clusters: []clientcmdv1.NamedCluster{
+			{
+				Name: clusterName,
+				Cluster: clientcmdv1.Cluster{
+					Server:                   apiserverURL,
+					CertificateAuthorityData: []byte("internal API Cert"),
+				},
+			},
+		},
+		AuthInfos: []clientcmdv1.NamedAuthInfo{},
+		Contexts: []clientcmdv1.NamedContext{
+			{
+				Name: serviceName,
+				Context: clientcmdv1.Context{
+					Cluster:  clusterName,
+					AuthInfo: serviceName,
+				},
+			},
+		},
+		CurrentContext: serviceName,
+	}
+
+	pg := graph.PersistedGraph{}
+
+	err = pg.Set(ca, adminInternalClient)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	m := &manager{}
+
+	aroServiceInternalClient, err := m.generateUserAdminKubeconfig(pg)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	var got *clientcmdv1.Config
+	err = yaml.Unmarshal(aroServiceInternalClient.File.Data, &got)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	innerpem := string(got.AuthInfos[0].AuthInfo.ClientCertificateData) + string(got.AuthInfos[0].AuthInfo.ClientKeyData)
+	innerkey, innercert, err := utilpem.Parse([]byte(innerpem))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if innerkey == nil {
+		t.Error("Client Key is invalid.")
+	}
+
+	// validate the result in 2 stages: first verify the key and certificate
+	// are valid (signed by CA, have proper validity period, etc)
+	// then remove the AuthInfo struct from the result and validate
+	// rest of the fields by comparing with the template.
+
+	err = innercert[0].CheckSignatureFrom(validCaCerts[0])
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	issuer := innercert[0].Issuer.String()
+	if issuer != "CN=validca" {
+		t.Error(issuer)
+	}
+
+	subject := innercert[0].Subject.String()
+	if subject != "CN=system:admin" {
+		t.Error(subject)
+	}
+
+	if !innercert[0].NotAfter.After(time.Now().AddDate(0, 11, 0)) {
+		t.Error(innercert[0].NotAfter)
+	}
+
+	keyUsage := innercert[0].KeyUsage
+	expectedKeyUsage := x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature
+	if keyUsage != expectedKeyUsage {
+		t.Error("Invalid keyUsage.")
+	}
+
+	// validate the rest of the struct
+	got.AuthInfos = []clientcmdv1.NamedAuthInfo{}
+	want := adminInternalClient.Config
+	want.Clusters[0].Cluster.CertificateAuthorityData = nil
+	want.Clusters[0].Cluster.Server = "https://api.hash.rg.mydomain:6443"
+
+	if !reflect.DeepEqual(got, want) {
+		t.Fatal(cmp.Diff(got, want))
+	}
+}
+
+func TestCheckUserAdminKubeconfigUpdated(t *testing.T) {
+	validCaKey, validCaCerts, err := utiltls.GenerateKeyAndCertificate("validca", nil, nil, true, false)
+	if err != nil {
+		t.Fatal(err)
+	}
+	b := x509.MarshalPKCS1PrivateKey(validCaKey)
+
+	ca := &tls.AdminKubeConfigSignerCertKey{
+		SelfSignedCertKey: tls.SelfSignedCertKey{
+			CertKey: tls.CertKey{
+				CertRaw: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: validCaCerts[0].Raw}),
+				KeyRaw:  pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: b}),
+			},
+		},
+	}
+
+	for _, tt := range []struct {
+		name           string
+		validity       time.Duration
+		mutateConfig   func(*clientcmdv1.Config) *clientcmdv1.Config
+		expectedResult bool
+	}{
+		{
+			name:           "valid and updated",
+			validity:       tls.ValidityOneYear,
+			expectedResult: true,
+		},
+		{
+			name:           "clientauth cert expires soon",
+			validity:       89 * tls.ValidityOneDay,
+			expectedResult: false,
+		},
+		{
+			name:     "url needs updating and cacert populated",
+			validity: tls.ValidityOneYear,
+			mutateConfig: func(c *clientcmdv1.Config) *clientcmdv1.Config {
+				c.Clusters[0].Cluster.Server = "https://api-int.hash.rg.mydomain:6443"
+				c.Clusters[0].Cluster.CertificateAuthorityData = []byte("unexpected content")
+				return c
+			},
+			expectedResult: false,
+		},
+		{
+			name:     "empty client key",
+			validity: tls.ValidityOneYear,
+			mutateConfig: func(c *clientcmdv1.Config) *clientcmdv1.Config {
+				c.AuthInfos[0].AuthInfo.ClientKeyData = nil
+				return c
+			},
+			expectedResult: false,
+		},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			clusterName := "api-hash-rg-mydomain:6443"
+			serviceName := "system:admin"
+
+			cfg := &tls.CertCfg{
+				Subject:      pkix.Name{CommonName: serviceName, Organization: nil},
+				KeyUsages:    x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
+				ExtKeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
+				Validity:     tt.validity,
+			}
+
+			var clientCertKey tls.AdminKubeConfigClientCertKey
+
+			err = clientCertKey.SignedCertKey.Generate(cfg, ca, strings.ReplaceAll(serviceName, ":", "-"), tls.DoNotAppendParent)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			userAdminKubeconfig := &clientcmdv1.Config{
+				Clusters: []clientcmdv1.NamedCluster{
+					{
+						Name: clusterName,
+						Cluster: clientcmdv1.Cluster{
+							Server: "https://api.hash.rg.mydomain:6443",
+						},
+					},
+				},
+				AuthInfos: []clientcmdv1.NamedAuthInfo{
+					{
+						Name: serviceName,
+						AuthInfo: clientcmdv1.AuthInfo{
+							ClientCertificateData: clientCertKey.CertRaw,
+							ClientKeyData:         clientCertKey.KeyRaw,
+						},
+					},
+				},
+				Contexts: []clientcmdv1.NamedContext{
+					{
+						Name: serviceName,
+						Context: clientcmdv1.Context{
+							Cluster:  clusterName,
+							AuthInfo: serviceName,
+						},
+					},
+				},
+				CurrentContext: serviceName,
+			}
+
+			if tt.mutateConfig != nil {
+				userAdminKubeconfig = tt.mutateConfig(userAdminKubeconfig)
+			}
+
+			data, err := yaml.Marshal(userAdminKubeconfig)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			m := &manager{}
+
+			m.doc = &api.OpenShiftClusterDocument{
+				OpenShiftCluster: &api.OpenShiftCluster{
+					Properties: api.OpenShiftClusterProperties{
+						UserAdminKubeconfig: data,
+					},
+				},
+			}
+
+			got := m.checkUserAdminKubeconfigUpdated()
+			if tt.expectedResult != got {
+				t.Errorf("Expected %t, got %t", tt.expectedResult, got)
+			}
+		})
 	}
 }
diff --git a/pkg/frontend/openshiftcluster_putorpatch_test.go b/pkg/frontend/openshiftcluster_putorpatch_test.go
index 21114c5c3..6d7a61777 100644
--- a/pkg/frontend/openshiftcluster_putorpatch_test.go
+++ b/pkg/frontend/openshiftcluster_putorpatch_test.go
@@ -376,7 +376,7 @@ func TestPutOrPatchOpenShiftCluster(t *testing.T) {
 								EncryptionAtHost: api.EncryptionAtHostDisabled,
 							},
 							FeatureProfile: api.FeatureProfile{
-								GatewayEnabled: true,
+								GatewayEnabled: false,
 							},
 						},
 					},
diff --git a/pkg/portal/cluster/clusteroperators_test.go b/pkg/portal/cluster/clusteroperators_test.go
new file mode 100644
index 000000000..42790549b
--- /dev/null
+++ b/pkg/portal/cluster/clusteroperators_test.go
@@ -0,0 +1,89 @@
+package cluster
+
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache License 2.0.
+
+import (
+	"context"
+	"encoding/json"
+	"sort"
+	"testing"
+
+	"github.com/go-test/deep"
+	configv1 "github.com/openshift/api/config/v1"
+	configfake "github.com/openshift/client-go/config/clientset/versioned/fake"
+	kruntime "k8s.io/apimachinery/pkg/runtime"
+
+	testlog "github.com/Azure/ARO-RP/test/util/log"
+)
+
+func TestClusterOperators(t *testing.T) {
+	ctx := context.Background()
+
+	txt, _ := clusteroperatorJsonBytes()
+
+	var operators configv1.ClusterOperatorList
+	err := json.Unmarshal(txt, &operators)
+	if err != nil {
+		t.Error(err)
+	}
+
+	// lol golang
+	converted := make([]kruntime.Object, len(operators.Items))
+	for i := range operators.Items {
+		converted[i] = &operators.Items[i]
+	}
+
+	configcli := configfake.NewSimpleClientset(converted...)
+
+	_, log := testlog.New()
+
+	rf := &realFetcher{
+		configcli: configcli,
+		log:       log,
+	}
+
+	c := &client{fetcher: rf, log: log}
+
+	info, err := c.ClusterOperators(ctx)
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	expected := &ClusterOperatorsInformation{
+		Operators: []OperatorInformation{
+			{
+				Name:      "authentication",
+				Available: "True",
+			},
+			{
+				Name:      "cluster-autoscaler",
+				Available: "True",
+			},
+			{
+				Name:      "cloud-credential",
+				Available: "True",
+			},
+			{
+				Name:      "config-operator",
+				Available: "True",
+			},
+			{
+				Name:      "console",
+				Available: "True",
+			},
+			{
+				Name:      "aro",
+				Available: "True",
+			},
+		},
+	}
+
+	sort.SliceStable(info.Operators, func(i, j int) bool { return info.Operators[i].Name < info.Operators[j].Name })
+	sort.SliceStable(expected.Operators, func(i, j int) bool { return expected.Operators[i].Name < expected.Operators[j].Name })
+
+	for _, r := range deep.Equal(expected, info) {
+		t.Error(r)
+	}
+}
diff --git a/pkg/portal/cluster/testdocs/clusteroperator.json b/pkg/portal/cluster/testdocs/clusteroperator.json
new file mode 100644
index 000000000..bbe97c7e9
--- /dev/null
+++ b/pkg/portal/cluster/testdocs/clusteroperator.json
@@ -0,0 +1,588 @@
+{
+  "apiVersion": "config.openshift.io/v1",
+  "items": [
+    {
+      "apiVersion": "config.openshift.io/v1",
+      "kind": "ClusterOperator",
+      "metadata": {
+        "creationTimestamp": "2021-02-03T08:26:02Z",
+        "generation": 1,
+        "name": "aro",
+        "ownerReferences": [
+          {
+            "apiVersion": "aro.openshift.io/v1alpha1",
+            "blockOwnerDeletion": true,
+            "controller": true,
+            "kind": "Cluster",
+            "name": "cluster",
+            "uid": "4a5f1cd4-d1a1-457a-8869-aa503b063907"
+          }
+        ],
+        "resourceVersion": "149177061",
+        "selfLink": "/apis/config.openshift.io/v1/clusteroperators/aro",
+        "uid": "54ffe267-a06f-4a97-949b-7333856e7aea"
+      },
+      "spec": {},
+      "status": {
+        "conditions": [
+          {
+            "lastTransitionTime": "2021-05-04T09:53:11Z",
+            "reason": "AsExpected",
+            "status": "True",
+            "type": "Available"
+          },
+          {
+            "lastTransitionTime": "2021-05-04T09:53:11Z",
+            "reason": "AsExpected",
+            "status": "False",
+            "type": "Progressing"
+          },
+          {
+            "lastTransitionTime": "2021-05-04T09:53:11Z",
+            "reason": "AsExpected",
+            "status": "False",
+            "type": "Degraded"
+          },
+          {
+            "lastTransitionTime": "2021-05-04T09:53:11Z",
+            "reason": "AsExpected",
+            "status": "True",
+            "type": "Upgradeable"
+          }
+        ],
+        "extension": null
+      }
+    },
+    {
+      "apiVersion": "config.openshift.io/v1",
+      "kind": "ClusterOperator",
+      "metadata": {
+        "creationTimestamp": "2020-05-05T22:51:18Z",
+        "generation": 1,
+        "name": "authentication",
+        "resourceVersion": "176785383",
+        "selfLink": "/apis/config.openshift.io/v1/clusteroperators/authentication",
+        "uid": "cbf1bec8-82be-4b66-9926-866c1752b22a"
+      },
+      "spec": {},
+      "status": {
+        "conditions": [
+          {
+            "lastTransitionTime": "2021-05-21T08:10:25Z",
+            "reason": "AsExpected",
+            "status": "False",
+            "type": "Degraded"
+          },
+          {
+            "lastTransitionTime": "2021-06-04T22:48:41Z",
+            "reason": "AsExpected",
+            "status": "False",
+            "type": "Progressing"
+          },
+          {
+            "lastTransitionTime": "2021-07-11T21:29:57Z",
+            "message": "OAuthServerDeploymentAvailable: availableReplicas==2",
+            "reason": "AsExpected",
+            "status": "True",
+            "type": "Available"
+          },
+          {
+            "lastTransitionTime": "2020-05-05T22:51:18Z",
+            "reason": "AsExpected",
+            "status": "True",
+            "type": "Upgradeable"
+          }
+        ],
+        "extension": null,
+        "relatedObjects": [
+          {
+            "group": "operator.openshift.io",
+            "name": "cluster",
+            "resource": "authentications"
+          },
+          {
+            "group": "config.openshift.io",
+            "name": "cluster",
+            "resource": "authentications"
+          },
+          {
+            "group": "config.openshift.io",
+            "name": "cluster",
+            "resource": "infrastructures"
+          },
+          {
+            "group": "config.openshift.io",
+            "name": "cluster",
+            "resource": "oauths"
+          },
+          {
+            "group": "route.openshift.io",
+            "name": "oauth-openshift",
+            "namespace": "openshift-authentication",
+            "resource": "routes"
+          },
+          {
+            "group": "",
+            "name": "oauth-openshift",
+            "namespace": "openshift-authentication",
+            "resource": "services"
+          },
+          {
+            "group": "",
+            "name": "openshift-config",
+            "resource": "namespaces"
+          },
+          {
+            "group": "",
+            "name": "openshift-config-managed",
+            "resource": "namespaces"
+          },
+          {
+            "group": "",
+            "name": "openshift-authentication",
+            "resource": "namespaces"
+          },
+          {
+            "group": "",
+            "name": "openshift-authentication-operator",
+            "resource": "namespaces"
+          },
+          {
+            "group": "",
+            "name": "openshift-ingress",
+            "resource": "namespaces"
+          },
+          {
+            "group": "",
+            "name": "openshift-oauth-apiserver",
+            "resource": "namespaces"
+          }
+        ],
+        "versions": [
+          {
+            "name": "operator",
+            "version": "4.6.26"
+          },
+          {
+            "name": "oauth-openshift",
+            "version": "4.6.26_openshift"
+          },
+          {
+            "name": "oauth-apiserver",
+            "version": "4.6.26"
+          }
+        ]
+      }
+    },
+    {
+      "apiVersion": "config.openshift.io/v1",
+      "kind": "ClusterOperator",
+      "metadata": {
+        "creationTimestamp": "2020-05-05T22:42:34Z",
+        "generation": 1,
+        "name": "cloud-credential",
+        "resourceVersion": "176927593",
+        "selfLink": "/apis/config.openshift.io/v1/clusteroperators/cloud-credential",
+        "uid": "9af60ead-7186-4bb2-a6b0-e89630303f87"
+      },
+      "spec": {},
+      "status": {
+        "conditions": [
+          {
+            "lastTransitionTime": "2020-05-05T22:42:34Z",
+            "status": "True",
+            "type": "Available"
+          },
+          {
+            "lastTransitionTime": "2020-12-23T13:51:46Z",
+            "status": "False",
+            "type": "Degraded"
+          },
+          {
+            "lastTransitionTime": "2021-05-21T07:44:15Z",
+            "status": "False",
+            "type": "Progressing"
+          },
+          {
+            "lastTransitionTime": "2020-05-05T22:42:34Z",
+            "status": "True",
+            "type": "Upgradeable"
+          }
+        ],
+        "extension": null,
+        "relatedObjects": [
+          {
+            "group": "operator.openshift.io",
+            "name": "cluster",
+            "resource": "cloudcredentials"
+          },
+          {
+            "group": "",
+            "name": "cloud-credential-operator-config",
+            "namespace": "openshift-cloud-credential-operator",
+            "resource": "configmap"
+          },
+          {
+            "group": "",
+            "name": "openshift-cloud-credential-operator",
+            "resource": "namespaces"
+          },
+          {
+            "group": "cloudcredential.openshift.io",
+            "name": "openshift-machine-api-vsphere",
+            "namespace": "openshift-cloud-credential-operator",
+            "resource": "credentialsrequests"
+          },
+          {
+            "group": "cloudcredential.openshift.io",
+            "name": "openshift-machine-api-gcp",
+            "namespace": "openshift-cloud-credential-operator",
+            "resource": "credentialsrequests"
+          },
+          {
+            "group": "cloudcredential.openshift.io",
+            "name": "cloud-credential-operator-s3",
+            "namespace": "openshift-cloud-credential-operator",
+            "resource": "credentialsrequests"
+          },
+          {
+            "group": "cloudcredential.openshift.io",
+            "name": "openshift-image-registry-azure",
+            "namespace": "openshift-cloud-credential-operator",
+            "resource": "credentialsrequests"
+          },
+          {
+            "group": "cloudcredential.openshift.io",
+            "name": "aws-ebs-csi-driver-operator",
+            "namespace": "openshift-cloud-credential-operator",
+            "resource": "credentialsrequests"
+          },
+          {
+            "group": "cloudcredential.openshift.io",
+            "name": "manila-csi-driver-operator",
+            "namespace": "openshift-cloud-credential-operator",
+            "resource": "credentialsrequests"
+          },
+          {
+            "group": "cloudcredential.openshift.io",
+            "name": "openshift-image-registry-openstack",
+            "namespace": "openshift-cloud-credential-operator",
+            "resource": "credentialsrequests"
+          },
+          {
+            "group": "cloudcredential.openshift.io",
+            "name": "openshift-ingress-gcp",
+            "namespace": "openshift-cloud-credential-operator",
+            "resource": "credentialsrequests"
+          },
+          {
+            "group": "cloudcredential.openshift.io",
+            "name": "openshift-machine-api-aws",
+            "namespace": "openshift-cloud-credential-operator",
+            "resource": "credentialsrequests"
+          },
+          {
+            "group": "cloudcredential.openshift.io",
+            "name": "openshift-machine-api-azure",
+            "namespace": "openshift-cloud-credential-operator",
+            "resource": "credentialsrequests"
+          },
+          {
+            "group": "cloudcredential.openshift.io",
+            "name": "ovirt-csi-driver-operator",
+            "namespace": "openshift-cloud-credential-operator",
+            "resource": "credentialsrequests"
+          },
+          {
+            "group": "cloudcredential.openshift.io",
+            "name": "openshift-ingress",
+            "namespace": "openshift-cloud-credential-operator",
+            "resource": "credentialsrequests"
+          },
+          {
+            "group": "cloudcredential.openshift.io",
+            "name": "openshift-ingress-azure",
+            "namespace": "openshift-cloud-credential-operator",
+            "resource": "credentialsrequests"
+          },
+          {
+            "group": "cloudcredential.openshift.io",
+            "name": "openshift-machine-api-openstack",
+            "namespace": "openshift-cloud-credential-operator",
+            "resource": "credentialsrequests"
+          },
+          {
+            "group": "cloudcredential.openshift.io",
+            "name": "openshift-image-registry",
+            "namespace": "openshift-cloud-credential-operator",
+            "resource": "credentialsrequests"
+          },
+          {
+            "group": "cloudcredential.openshift.io",
+            "name": "openshift-machine-api-ovirt",
+            "namespace": "openshift-cloud-credential-operator",
+            "resource": "credentialsrequests"
+          },
+          {
+            "group": "cloudcredential.openshift.io",
+            "name": "cloud-credential-operator-iam-ro",
+            "namespace": "openshift-cloud-credential-operator",
+            "resource": "credentialsrequests"
+          },
+          {
+            "group": "cloudcredential.openshift.io",
+            "name": "openshift-image-registry-gcs",
+            "namespace": "openshift-cloud-credential-operator",
+            "resource": "credentialsrequests"
+          },
+          {
+            "group": "cloudcredential.openshift.io",
+            "name": "openshift-network",
+            "namespace": "openshift-cloud-credential-operator",
+            "resource": "credentialsrequests"
+          }
+        ],
+        "versions": [
+          {
+            "name": "operator",
+            "version": "4.6.26"
+          }
+        ]
+      }
+    },
+    {
+      "apiVersion": "config.openshift.io/v1",
+      "kind": "ClusterOperator",
+      "metadata": {
+        "creationTimestamp": "2020-05-05T22:52:21Z",
+        "generation": 1,
+        "name": "cluster-autoscaler",
+        "resourceVersion": "155972237",
+        "selfLink": "/apis/config.openshift.io/v1/clusteroperators/cluster-autoscaler",
+        "uid": "ee778127-c545-4e15-a158-9b3a397e39fd"
+      },
+      "spec": {},
+      "status": {
+        "conditions": [
+          {
+            "lastTransitionTime": "2020-05-05T22:52:21Z",
+            "message": "at version 4.6.26",
+            "reason": "AsExpected",
+            "status": "True",
+            "type": "Available"
+          },
+          {
+            "lastTransitionTime": "2021-05-21T07:44:20Z",
+            "status": "False",
+            "type": "Progressing"
+          },
+          {
+            "lastTransitionTime": "2020-05-05T22:52:21Z",
+            "status": "False",
+            "type": "Degraded"
+          },
+          {
+            "lastTransitionTime": "2020-05-05T22:52:21Z",
+            "status": "True",
+            "type": "Upgradeable"
+          }
+        ],
+        "extension": null,
+        "relatedObjects": [
+          {
+            "group": "autoscaling.openshift.io",
+            "name": "",
+            "namespace": "openshift-machine-api",
+            "resource": "machineautoscalers"
+          },
+          {
+            "group": "autoscaling.openshift.io",
+            "name": "",
+            "namespace": "openshift-machine-api",
+            "resource": "clusterautoscalers"
+          },
+          {
+            "group": "",
+            "name": "openshift-machine-api",
+            "resource": "namespaces"
+          }
+        ],
+        "versions": [
+          {
+            "name": "operator",
+            "version": "4.6.26"
+          }
+        ]
+      }
+    },
+    {
+      "apiVersion": "config.openshift.io/v1",
+      "kind": "ClusterOperator",
+      "metadata": {
+        "annotations": {
+          "exclude.release.openshift.io/internal-openshift-hosted": "true"
+        },
+        "creationTimestamp": "2020-12-23T12:02:28Z",
+        "generation": 1,
+        "name": "config-operator",
+        "resourceVersion": "155965236",
+        "selfLink": "/apis/config.openshift.io/v1/clusteroperators/config-operator",
+        "uid": "6dd52f64-6cf8-4d34-9936-da2707af884d"
+      },
+      "spec": {},
+      "status": {
+        "conditions": [
+          {
+            "lastTransitionTime": "2020-12-23T12:20:07Z",
+            "reason": "AsExpected",
+            "status": "False",
+            "type": "Degraded"
+          },
+          {
+            "lastTransitionTime": "2020-12-23T12:20:07Z",
+            "reason": "AsExpected",
+            "status": "False",
+            "type": "Progressing"
+          },
+          {
+            "lastTransitionTime": "2020-12-23T12:20:07Z",
+            "reason": "AsExpected",
+            "status": "True",
+            "type": "Available"
+          },
+          {
+            "lastTransitionTime": "2020-12-23T12:20:07Z",
+            "reason": "AsExpected",
+            "status": "True",
+            "type": "Upgradeable"
+          }
+        ],
+        "extension": null,
+        "relatedObjects": [
+          {
+            "group": "operator.openshift.io",
+            "name": "cluster",
+            "resource": "configs"
+          },
+          {
+            "group": "",
+            "name": "openshift-config",
+            "resource": "namespaces"
+          },
+          {
+            "group": "",
+            "name": "openshift-config-operator",
+            "resource": "namespaces"
+          }
+        ],
+        "versions": [
+          {
+            "name": "operator",
+            "version": "4.6.26"
+          }
+        ]
+      }
+    },
+    {
+      "apiVersion": "config.openshift.io/v1",
+      "kind": "ClusterOperator",
+      "metadata": {
+        "creationTimestamp": "2020-05-05T22:51:32Z",
+        "generation": 1,
+        "name": "console",
+        "resourceVersion": "161855030",
+        "selfLink": "/apis/config.openshift.io/v1/clusteroperators/console",
+        "uid": "cafd409d-d30d-4f4d-b1b9-72966bf981d9"
+      },
+      "spec": {},
+      "status": {
+        "conditions": [
+          {
+            "lastTransitionTime": "2021-04-01T22:35:31Z",
+            "reason": "AsExpected",
+            "status": "False",
+            "type": "Degraded"
+          },
+          {
+            "lastTransitionTime": "2021-06-04T22:48:32Z",
+            "reason": "AsExpected",
+            "status": "False",
+            "type": "Progressing"
+          },
+          {
+            "lastTransitionTime": "2021-06-04T22:48:32Z",
+            "reason": "AsExpected",
+            "status": "True",
+            "type": "Available"
+          },
+          {
+            "lastTransitionTime": "2020-05-05T22:51:32Z",
+            "reason": "AsExpected",
+            "status": "True",
+            "type": "Upgradeable"
+          }
+        ],
+        "extension": null,
+        "relatedObjects": [
+          {
+            "group": "operator.openshift.io",
+            "name": "cluster",
+            "resource": "consoles"
+          },
+          {
+            "group": "config.openshift.io",
+            "name": "cluster",
+            "resource": "consoles"
+          },
+          {
+            "group": "config.openshift.io",
+            "name": "cluster",
+            "resource": "infrastructures"
+          },
+          {
+            "group": "config.openshift.io",
+            "name": "cluster",
+            "resource": "proxies"
+          },
+          {
+            "group": "config.openshift.io",
+            "name": "cluster",
+            "resource": "oauths"
+          },
+          {
+            "group": "oauth.openshift.io",
+            "name": "console",
+            "resource": "oauthclients"
+          },
+          {
+            "group": "",
+            "name": "openshift-console-operator",
+            "resource": "namespaces"
+          },
+          {
+            "group": "",
+            "name": "openshift-console",
+            "resource": "namespaces"
+          },
+          {
+            "group": "",
+            "name": "console-public",
+            "namespace": "openshift-config-managed",
+            "resource": "configmaps"
+          }
+        ],
+        "versions": [
+          {
+            "name": "operator",
+            "version": "4.6.26"
+          }
+        ]
+      }
+    }
+  ],
+  "kind": "ClusterOperatorList",
+  "metadata": {
+    "resourceVersion": "",
+    "selfLink": ""
+  }
+}
diff --git a/pkg/portal/cluster_test.go b/pkg/portal/cluster_test.go
index 473a90b00..da7487429 100644
--- a/pkg/portal/cluster_test.go
+++ b/pkg/portal/cluster_test.go
@@ -8,38 +8,47 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"testing"
+	"time"
 
 	"github.com/go-test/deep"
+	"github.com/gorilla/mux"
 
 	"github.com/Azure/ARO-RP/pkg/api"
 	testdatabase "github.com/Azure/ARO-RP/test/database"
 )
 
-func TestClusters(t *testing.T) {
+func TestClusterList(t *testing.T) {
 	dbOpenShiftClusters, _ := testdatabase.NewFakeOpenShiftClusters()
 
 	fixture := testdatabase.NewFixture().
 		WithOpenShiftClusters(dbOpenShiftClusters)
 
-	fixture.AddOpenShiftClusterDocuments(&api.OpenShiftClusterDocument{
-		ID:  "00000000-0000-0000-0000-000000000000",
-		Key: "/subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/resourcegroupname/providers/microsoft.redhatopenshift/openshiftclusters/succeeded",
-		OpenShiftCluster: &api.OpenShiftCluster{
-			ID: "/subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/resourceGroupName/providers/microsoft.redhatopenshift/openshiftclusters/succeeded",
-			Properties: api.OpenShiftClusterProperties{
-				ProvisioningState: api.ProvisioningStateSucceeded,
+	parsedTime, err := time.Parse(time.RFC3339, "2011-01-02T01:03:00Z")
+	if err != nil {
+		t.Error(err)
+	}
+
+	fixture.AddOpenShiftClusterDocuments(
+		&api.OpenShiftClusterDocument{
+			ID:  "00000000-0000-0000-0000-000000000000",
+			Key: "/subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/resourcegroupname/providers/microsoft.redhatopenshift/openshiftclusters/succeeded",
+			OpenShiftCluster: &api.OpenShiftCluster{
+				ID: "/subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/resourceGroupName/providers/microsoft.redhatopenshift/openshiftclusters/succeeded",
+				Properties: api.OpenShiftClusterProperties{
+					ProvisioningState: api.ProvisioningStateSucceeded,
+					CreatedAt:         parsedTime,
+				},
 			},
-		},
-	}, &api.OpenShiftClusterDocument{
-		ID:  "00000000-0000-0000-0000-000000000001",
-		Key: "/subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/resourcegroupname/providers/microsoft.redhatopenshift/openshiftclusters/creating",
-		OpenShiftCluster: &api.OpenShiftCluster{
-			ID: "/subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/resourceGroupName/providers/microsoft.redhatopenshift/openshiftclusters/creating",
-			Properties: api.OpenShiftClusterProperties{
-				ProvisioningState: api.ProvisioningStateCreating,
+		}, &api.OpenShiftClusterDocument{
+			ID:  "00000000-0000-0000-0000-000000000001",
+			Key: "/subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/resourcegroupname/providers/microsoft.redhatopenshift/openshiftclusters/creating",
+			OpenShiftCluster: &api.OpenShiftCluster{
+				ID: "/subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/resourceGroupName/providers/microsoft.redhatopenshift/openshiftclusters/creating",
+				Properties: api.OpenShiftClusterProperties{
+					ProvisioningState: api.ProvisioningStateCreating,
+				},
 			},
 		},
-	},
 		&api.OpenShiftClusterDocument{
 			ID:  "00000000-0000-0000-0000-000000000002",
 			Key: "/subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/resourcegroupname/providers/microsoft.redhatopenshift/openshiftclusters/failedcreate",
@@ -52,7 +61,7 @@ func TestClusters(t *testing.T) {
 			},
 		})
 
-	err := fixture.Create()
+	err = fixture.Create()
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -61,22 +70,159 @@ func TestClusters(t *testing.T) {
 		dbOpenShiftClusters: dbOpenShiftClusters,
 	}
 
+	req, err := http.NewRequest("GET", "/api/clusters", nil)
+	if err != nil {
+		t.Error(err)
+	}
+
+	aadAuthenticatedRouter := mux.NewRouter()
+	p.aadAuthenticatedRoutes(aadAuthenticatedRouter)
 	w := httptest.NewRecorder()
+	aadAuthenticatedRouter.ServeHTTP(w, req)
+
+	if w.Header().Get("Content-Type") != "application/json" {
+		t.Error(w.Header().Get("Content-Type"))
+	}
+
+	var r []AdminOpenShiftCluster
+	err = json.NewDecoder(w.Body).Decode(&r)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	expected := []AdminOpenShiftCluster{
+		{
+			Key:               "00000000-0000-0000-0000-000000000000",
+			ResourceId:        "/subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/resourceGroupName/providers/microsoft.redhatopenshift/openshiftclusters/succeeded",
+			Name:              "succeeded",
+			ResourceGroup:     "resourceGroupName",
+			Subscription:      "00000000-0000-0000-0000-000000000000",
+			CreatedAt:         "2011-01-02T01:03:00Z",
+			ProvisioningState: api.ProvisioningStateSucceeded.String(),
+		},
+		{
+			Key:               "00000000-0000-0000-0000-000000000001",
+			ResourceId:        "/subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/resourceGroupName/providers/microsoft.redhatopenshift/openshiftclusters/creating",
+			Name:              "creating",
+			ResourceGroup:     "resourceGroupName",
+			Subscription:      "00000000-0000-0000-0000-000000000000",
+			CreatedAt:         "Unknown",
+			ProvisioningState: api.ProvisioningStateCreating.String(),
+		},
+
+		{
+			Key:                     "00000000-0000-0000-0000-000000000002",
+			ResourceId:              "/subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/resourceGroupName/providers/microsoft.redhatopenshift/openshiftclusters/failedcreate",
+			Name:                    "failedcreate",
+			ResourceGroup:           "resourceGroupName",
+			Subscription:            "00000000-0000-0000-0000-000000000000",
+			CreatedAt:               "Unknown",
+			ProvisioningState:       api.ProvisioningStateFailed.String(),
+			FailedProvisioningState: api.ProvisioningStateCreating.String(),
+		},
+	}
+
+	for _, l := range deep.Equal(expected, r) {
+		t.Error(l)
+	}
+}
+
+func TestClusterDetail(t *testing.T) {
+	dbOpenShiftClusters, _ := testdatabase.NewFakeOpenShiftClusters()
 
-	p.clusters(w, &http.Request{})
+	fixture := testdatabase.NewFixture().
+		WithOpenShiftClusters(dbOpenShiftClusters)
+
+	parsedTime, err := time.Parse(time.RFC3339, "2011-01-02T01:03:00Z")
+	if err != nil {
+		t.Error(err)
+	}
+
+	fixture.AddOpenShiftClusterDocuments(
+		&api.OpenShiftClusterDocument{
+			ID:  "00000000-0000-0000-0000-000000000000",
+			Key: "/subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/resourcegroupname/providers/microsoft.redhatopenshift/openshiftclusters/succeeded",
+			OpenShiftCluster: &api.OpenShiftCluster{
+				ID: "/subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/resourceGroupName/providers/microsoft.redhatopenshift/openshiftclusters/succeeded",
+				Properties: api.OpenShiftClusterProperties{
+					LastProvisioningState: api.ProvisioningStateCreating,
+					ProvisioningState:     api.ProvisioningStateSucceeded,
+					CreatedAt:             parsedTime,
+					ProvisionedBy:         "aaaaab",
+					InfraID:               "blah",
+					ArchitectureVersion:   2,
+					CreatedBy:             "aaa",
+					ClusterProfile: api.ClusterProfile{
+						Version: "4.4.4",
+					},
+					APIServerProfile: api.APIServerProfile{
+						IP:         "1.2.3.4",
+						IntIP:      "2.3.4.5",
+						Visibility: api.VisibilityPrivate,
+						URL:        "example.com",
+					},
+				},
+			},
+		},
+		&api.OpenShiftClusterDocument{
+			ID:  "00000000-0000-0000-0000-000000000002",
+			Key: "/subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/resourcegroupname/providers/microsoft.redhatopenshift/openshiftclusters/failedcreate",
+			OpenShiftCluster: &api.OpenShiftCluster{
+				ID: "/subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/resourceGroupName/providers/microsoft.redhatopenshift/openshiftclusters/failedcreate",
+				Properties: api.OpenShiftClusterProperties{
+					ProvisioningState:       api.ProvisioningStateFailed,
+					FailedProvisioningState: api.ProvisioningStateCreating,
+				},
+			},
+		})
+
+	err = fixture.Create()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	p := &portal{
+		dbOpenShiftClusters: dbOpenShiftClusters,
+	}
+
+	req, err := http.NewRequest("GET", "/api/00000000-0000-0000-0000-000000000000/resourcegroupname/succeeded", nil)
+	if err != nil {
+		t.Error(err)
+	}
+
+	aadAuthenticatedRouter := mux.NewRouter()
+	p.aadAuthenticatedRoutes(aadAuthenticatedRouter)
+	w := httptest.NewRecorder()
+	aadAuthenticatedRouter.ServeHTTP(w, req)
 
 	if w.Header().Get("Content-Type") != "application/json" {
+		t.Error(w.Body)
 		t.Error(w.Header().Get("Content-Type"))
 	}
 
-	var r []string
+	var r map[string]string
 	err = json.NewDecoder(w.Body).Decode(&r)
 	if err != nil {
 		t.Fatal(err)
 	}
 
-	expected := []string{
-		"/subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/resourceGroupName/providers/microsoft.redhatopenshift/openshiftclusters/succeeded",
+	expected := map[string]string{
+		"resourceId":              "/subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/resourcegroupname/providers/microsoft.redhatopenshift/openshiftclusters/succeeded",
+		"name":                    "succeeded",
+		"resourceGroup":           "resourcegroupname",
+		"subscription":            "00000000-0000-0000-0000-000000000000",
+		"createdAt":               "2011-01-02T01:03:00Z",
+		"infraId":                 "blah",
+		"version":                 "4.4.4",
+		"createdBy":               "aaa",
+		"provisionedBy":           "aaaaab",
+		"architectureVersion":     "2",
+		"failedProvisioningState": "",
+		"apiServerVisibility":     "Private",
+		"lastAdminUpdateError":    "",
+		"lastProvisioningState":   api.ProvisioningStateCreating.String(),
+		"provisioningState":       api.ProvisioningStateSucceeded.String(),
+		"installStatus":           "Installed",
 	}
 
 	for _, l := range deep.Equal(expected, r) {
diff --git a/pkg/portal/http_test.go b/pkg/portal/http_test.go
new file mode 100644
index 000000000..5969a51f3
--- /dev/null
+++ b/pkg/portal/http_test.go
@@ -0,0 +1,120 @@
+package portal
+
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache License 2.0.
+
+import (
+	"context"
+	"log"
+	"net"
+	"net/http"
+	"testing"
+	"time"
+
+	"github.com/sirupsen/logrus/hooks/test"
+
+	"github.com/Azure/ARO-RP/pkg/database"
+	"github.com/Azure/ARO-RP/pkg/env"
+	frontendmiddleware "github.com/Azure/ARO-RP/pkg/frontend/middleware"
+	"github.com/Azure/ARO-RP/test/util/listener"
+	testlog "github.com/Azure/ARO-RP/test/util/log"
+)
+
+type testPortal struct {
+	p             *portal
+	l             *listener.Listener
+	auditHook     *test.Hook
+	portalLogHook *test.Hook
+}
+
+func NewTestPortal(_env env.Core, dbOpenShiftClusters database.OpenShiftClusters, dbPortal database.Portal) *testPortal {
+	_, portalAccessLog := testlog.New()
+	portalLogHook, portalLog := testlog.New()
+	auditHook, portalAuditLog := testlog.NewAudit()
+
+	l := listener.NewListener()
+	p := NewPortal(_env, portalAuditLog, portalLog, portalAccessLog, l, nil, nil, "", nil, nil, "", nil, nil, make([]byte, 32), nil, nonElevatedGroupIDs, elevatedGroupIDs, dbOpenShiftClusters, dbPortal, nil, nil).(*portal)
+
+	return &testPortal{
+		p:             p,
+		l:             l,
+		auditHook:     auditHook,
+		portalLogHook: portalLogHook,
+	}
+}
+
+func (p *testPortal) DumpLogs(t *testing.T) {
+	for _, l := range p.portalLogHook.Entries {
+		t.Error(l)
+	}
+}
+
+func (p *testPortal) Run(ctx context.Context) error {
+	err := p.p.setupRouter()
+	if err != nil {
+		return err
+	}
+
+	s := &http.Server{
+		Handler:     frontendmiddleware.Lowercase(p.p.baseRouter),
+		ReadTimeout: 10 * time.Second,
+		IdleTimeout: 2 * time.Minute,
+		ErrorLog:    log.New(p.p.log.Writer(), "", 0),
+		BaseContext: func(net.Listener) context.Context { return ctx },
+	}
+
+	go func() {
+		err := s.Serve(p.l)
+		if err != nil {
+			p.p.log.Error(err)
+		}
+	}()
+
+	return nil
+}
+
+func (p *testPortal) Request(method string, path string, authenticated bool, elevated bool) (*http.Response, error) {
+	p.portalLogHook.Reset()
+
+	req, err := http.NewRequest(method, "http://server"+path, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	err = addCSRF(req)
+	if err != nil {
+		return nil, err
+	}
+
+	if authenticated {
+		var groups []string
+		if elevated {
+			groups = elevatedGroupIDs
+		} else {
+			groups = nonElevatedGroupIDs
+		}
+		err = addAuth(req, groups)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	c := &http.Client{
+		Transport: &http.Transport{
+			DialContext: p.p.l.(*listener.Listener).DialContext,
+		},
+		CheckRedirect: func(*http.Request, []*http.Request) error {
+			return http.ErrUseLastResponse
+		},
+	}
+
+	resp, err := c.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	return resp, err
+}
+
+func (p *testPortal) Cleanup() {
+	p.l.Close()
+}
diff --git a/pkg/portal/info_test.go b/pkg/portal/info_test.go
new file mode 100644
index 000000000..e98e3e67a
--- /dev/null
+++ b/pkg/portal/info_test.go
@@ -0,0 +1,107 @@
+package portal
+
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache License 2.0.
+
+import (
+	"context"
+	"encoding/json"
+	"testing"
+
+	"github.com/go-test/deep"
+	"github.com/golang/mock/gomock"
+
+	"github.com/Azure/ARO-RP/pkg/util/azureclient"
+	mock_env "github.com/Azure/ARO-RP/pkg/util/mocks/env"
+	"github.com/Azure/ARO-RP/pkg/util/version"
+	testdatabase "github.com/Azure/ARO-RP/test/database"
+)
+
+func TestInfo(t *testing.T) {
+	ctx := context.Background()
+
+	controller := gomock.NewController(t)
+	defer controller.Finish()
+
+	_env := mock_env.NewMockCore(controller)
+	_env.EXPECT().IsLocalDevelopmentMode().AnyTimes().Return(false)
+	_env.EXPECT().Location().AnyTimes().Return("eastus")
+	_env.EXPECT().TenantID().AnyTimes().Return("00000000-0000-0000-0000-000000000001")
+	_env.EXPECT().Environment().AnyTimes().Return(&azureclient.PublicCloud)
+	_env.EXPECT().Hostname().AnyTimes().Return("testhost")
+
+	dbOpenShiftClusters, _ := testdatabase.NewFakeOpenShiftClusters()
+	dbPortal, _ := testdatabase.NewFakePortal()
+
+	p := NewTestPortal(_env, dbOpenShiftClusters, dbPortal)
+	defer p.Cleanup()
+	err := p.Run(ctx)
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	for _, tt := range []struct {
+		name               string
+		expectedResponse   PortalInfo
+		expectedStatusCode int
+		authenticated      bool
+		elevated           bool
+	}{
+		{
+			name:               "basic",
+			authenticated:      true,
+			elevated:           false,
+			expectedStatusCode: 200,
+			expectedResponse: PortalInfo{
+				Location:  "eastus",
+				Username:  "username",
+				Elevated:  false,
+				RPVersion: version.GitCommit,
+			},
+		},
+		{
+			name:               "elevated",
+			authenticated:      true,
+			elevated:           true,
+			expectedStatusCode: 200,
+			expectedResponse: PortalInfo{
+				Location:  "eastus",
+				Username:  "username",
+				Elevated:  true,
+				RPVersion: version.GitCommit,
+			},
+		},
+	} {
+		resp, err := p.Request("GET", "/api/info", tt.authenticated, tt.elevated)
+		if err != nil {
+			p.DumpLogs(t)
+			t.Error(err)
+		}
+		defer resp.Body.Close()
+
+		if resp.StatusCode != tt.expectedStatusCode {
+			t.Errorf("%d != %d", resp.StatusCode, tt.expectedStatusCode)
+		}
+
+		if resp.Header.Get("Content-Type") != "application/json" {
+			t.Error(resp.Header.Get("Content-Type"))
+		}
+
+		var readResp PortalInfo
+		err = json.NewDecoder(resp.Body).Decode(&readResp)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		// copy through the CSRF token if it's non-blank, since we can't make it
+		// a known value
+		if readResp.CSRFToken != "" {
+			tt.expectedResponse.CSRFToken = readResp.CSRFToken
+		}
+
+		for _, l := range deep.Equal(readResp, tt.expectedResponse) {
+			t.Error(l)
+		}
+	}
+}
diff --git a/pkg/portal/security_test.go b/pkg/portal/security_test.go
index f7ae78e1b..3093739cc 100644
--- a/pkg/portal/security_test.go
+++ b/pkg/portal/security_test.go
@@ -34,7 +34,8 @@ import (
 )
 
 var (
-	elevatedGroupIDs = []string{"00000000-0000-0000-0000-000000000000"}
+	nonElevatedGroupIDs = []string{"00000000-1111-1111-1111-000000000000"}
+	elevatedGroupIDs    = []string{"00000000-0000-0000-0000-000000000000"}
 )
 
 func TestSecurity(t *testing.T) {
diff --git a/pkg/util/pullsecret/pullsecret_test.go b/pkg/util/pullsecret/pullsecret_test.go
index ed5815807..4a448b11e 100644
--- a/pkg/util/pullsecret/pullsecret_test.go
+++ b/pkg/util/pullsecret/pullsecret_test.go
@@ -338,6 +338,17 @@ func TestUnmarshalSecretData(t *testing.T) {
 				"registry.redhat.io": "ZnJlZDplbnRlcg==",
 			},
 		},
+		{
+			name: "bad secret",
+			ps: &corev1.Secret{
+				Data: map[string][]byte{
+					corev1.DockerConfigJsonKey: []byte(`{"auths":{"arosvc.azurecr.io":{}, "registry.redhat.io":{"auth":"ZnJlZDplbnRlcg=="}}}`),
+				},
+			},
+			wantAuth: map[string]string{
+				"registry.redhat.io": "ZnJlZDplbnRlcg==",
+			},
+		},
 		{
 			name: "broken secret",
 			ps: &corev1.Secret{
diff --git a/test/e2e/adminapi_redeployvm.go b/test/e2e/adminapi_redeployvm.go
index 9809d9da4..d9d7762b7 100644
--- a/test/e2e/adminapi_redeployvm.go
+++ b/test/e2e/adminapi_redeployvm.go
@@ -5,7 +5,6 @@ package e2e
 
 import (
 	"context"
-	"fmt"
 	"net/http"
 	"net/url"
 	"time"
@@ -20,7 +19,7 @@ import (
 	"github.com/Azure/ARO-RP/pkg/util/stringutils"
 )
 
-var _ = XDescribe("[Admin API] VM redeploy action", func() {
+var _ = Describe("[Admin API] VM redeploy action", func() {
 	BeforeEach(skipIfNotInDevelopmentEnv)
 
 	It("should trigger a selected VM to redeploy", func() {
@@ -45,30 +44,48 @@ var _ = XDescribe("[Admin API] VM redeploy action", func() {
 		Expect(err).NotTo(HaveOccurred())
 		Expect(resp.StatusCode).To(Equal(http.StatusOK))
 
-		By("verifying through Azure activity logs that the redeployment happened")
-		// Can be material delays in shipping activity logs, hence healthy 15 min wait.
-		// https://docs.microsoft.com/en-us/azure/azure-monitor/logs/data-ingestion-time#azure-activity-logs-resource-logs-and-metrics
-		err = wait.PollImmediate(10*time.Second, 20*time.Minute, func() (bool, error) {
-			filter := fmt.Sprintf(
-				"eventTimestamp ge '%s' and resourceId eq '%s'",
-				startTime.Format(time.RFC3339),
-				*vm.ID,
-			)
-
-			activityLogs, err := clients.ActivityLogs.List(ctx, filter, "status,operationName")
+		By("verifying through cluster events that the redeployment happened")
+		err = wait.PollImmediate(10*time.Second, 10*time.Minute, func() (bool, error) {
+			events, err := clients.Kubernetes.EventsV1().Events("default").List(ctx, metav1.ListOptions{})
+
 			if err != nil {
 				return false, err
 			}
 
-			var count int
-			for _, activityLog := range activityLogs {
-				if *activityLog.OperationName.Value == "Microsoft.Compute/virtualMachines/redeploy/action" &&
-					*activityLog.Status.Value == "Succeeded" {
-					count++
+			var nodeKillTime metav1.MicroTime
+
+			for _, event := range events.Items {
+				if nodeKillTime.IsZero() &&
+					event.Reason == "TerminationStart" &&
+					!event.CreationTimestamp.After(startTime) {
+					nodeKillTime = metav1.MicroTime(event.CreationTimestamp)
+					break
+				}
+
+			}
+
+			var nodeNotReady, rebooted, nodeReady bool
+
+			for _, event := range events.Items {
+				if event.CreationTimestamp.After(nodeKillTime.Time) {
+					if !nodeNotReady &&
+						event.Reason == "NodeNotReady" &&
+						event.Regarding.Name == *vm.Name {
+						nodeNotReady = true
+					} else if !rebooted &&
+						event.Reason == "Rebooted" &&
+						event.Regarding.Name == *vm.Name {
+						rebooted = true
+					} else if !nodeReady &&
+						event.Reason == "NodeReady" &&
+						event.Regarding.Name == *vm.Name {
+						nodeReady = true
+						break
+					}
 				}
 			}
 
-			return count == 1, nil
+			return nodeNotReady && rebooted && nodeReady, nil
 		})
 		Expect(err).NotTo(HaveOccurred())