Repository: Azure/ARO-RP
Pull request #1682 (2021-08-17 00:32:50)

Implement the REST portions of Admin Portal 2.0
### Which issue this PR addresses:

See https://github.com/Azure/ARO-RP/pull/1676

See also ADO-9777019

### What this PR does / why we need it:

Implements the REST and unit test portions of the Admin Portal REST API.

### Test plan for issue:

Unit tests

### Is there any documentation that needs to be updated for this PR?

Includes some for making more tests

github-actions[bot]: Please rebase pull request.
Makdaam: /lgtm
jewzaam: Looks good to me, but need to hold on the current RP release, starting today.  Then we can get it merged. Soon!!!

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/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) {


Test patch:
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) {