diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 0791f1d8964..de3a32944e4 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -29,7 +29,7 @@ Public listener for the ACM/MCE console. It owns TLS, health probes, config, aut | `internal/clusterinfo` | `/hub`, `/cluster-version`, `/hypershift-status`, MCH/MCE components, `/operatorCheck`, `/apiPaths` | | `internal/cors` | Development CORS middleware (OPTIONS preflight for standalone dev) | | `internal/events/rbac` | `GET /events/rbac` SSE: ClusterRole informer (`vm-clusterroles` label) + per-user SSAR | -| `internal/events/hub` | `GET /events` SSE: informer fan-out, snapshot packets, per-user SSAR (60s TTL). DELETED is not RBAC-filtered | +| `internal/events/hub` | `GET /events` SSE: informer fan-out, snapshot packets, per-user RBAC (cluster `list` SSAR → SelfSubjectRulesReview → SSAR fallback; 60s TTL). DELETED is not RBAC-filtered | | `internal/aggregate` | `POST /aggregate/{applications,statuses,appSetData}`: informer cache + Search SA GraphQL, Fuse.js-compatible filter, windowed SSAR | | `internal/searchapi` | Search GraphQL client used by the aggregator (`/searchapi/graphql` or `/federated`) | | `internal/searchproxy` | `POST /proxy/search` and graphql-ws relay to search-api with the **user** token (`connection_init` Authorization injection) | @@ -98,7 +98,7 @@ The Go process starts hub list/watch **after** the public listener is bound. Sta Long-tail HTTP is always registered. Auth is GET `/api` (401 empty body). ROSA wizard POSTs exchange OCM client credentials at SSO then call `api.openshift.com`. `POST /ansibletower` reads the credential Secret with the **user** token, allow-lists AAP pathnames, and GETs the tower with `InsecureSkipVerify`. `POST /placement-debug` reverse-proxies to `PLACEMENT_DEBUG_URL` (or the in-cluster placement service) with the OCM CA ConfigMap `open-cluster-management-hub/ca-bundle-configmap`; missing CA → 503. `POST /upgrade-risks-prediction` lists `openshift-config` secrets with the **SA**, extracts `pull-secret` `cloud.openshift.com` auth, and POSTs Insights in chunks of 100 (`UPGRADE_RISKS_PREDICTION_URL` or console.redhat.com). -`GET /events` framing: `id:` + `data:` (no space), gzip when `Accept-Encoding` includes gzip, keepalive `:\n\n` every 10s, snapshot `START` → `SETTINGS` → priority packets with `EOP` → `LOADED`, live `MODIFIED`/`DELETED` then `LOADED`. Creates and updates are both `MODIFIED` (not `ADDED`). **DELETED events are broadcast without per-user SSAR** — a known gap; do not “fix” it in this stream without a follow-up. +`GET /events` framing: `id:` + `data:` (no space), gzip when `Accept-Encoding` includes gzip, keepalive `:\n\n` every 10s, snapshot `START` → `SETTINGS` → priority packets with `EOP` → `LOADED`, live `MODIFIED`/`DELETED` then `LOADED`. Creates and updates are both `MODIFIED` (not `ADDED`). Per-user RBAC: cluster-scoped `list` SSAR, then one SelfSubjectRulesReview per token+namespace (`deny-all` / `allow-all` / `allow-names`; empty OpenShift rules are deny-all). Cluster-scoped kinds that are not deny-all are confirmed with SSAR `get` so a RoleBinding in `default` cannot impersonate cluster access. Incomplete reviews fall back to namespaced `list` then `get`. **DELETED events are broadcast without per-user SSAR** — a known gap; do not “fix” it in this stream without a follow-up. ## Shared artifacts diff --git a/backend/internal/events/hub/access.go b/backend/internal/events/hub/access.go index 7ed8488c246..ee79dbd3b67 100644 --- a/backend/internal/events/hub/access.go +++ b/backend/internal/events/hub/access.go @@ -17,6 +17,7 @@ import ( "k8s.io/client-go/rest" "github.com/stolostron/console/backend/internal/auth" + "github.com/stolostron/console/backend/internal/informers" ) const ( @@ -25,7 +26,10 @@ const ( prefetchConcurrency = 32 ) -var accessCacheMaxTokens = 1000 +var ( + accessCacheMaxTokens = 1000 + accessCacheMaxEntriesPerToken = 2000 +) // AccessChecker decides whether a user may receive an SSE event. type AccessChecker interface { @@ -43,7 +47,7 @@ func (AllowAllAccess) Allow(context.Context, string, Event) (bool, error) { func (AllowAllAccess) Prefetch(context.Context, string, []Event) {} type ssarKey struct { - kind, namespace, name string + verb, group, kind, namespace, name string } type cacheEntry struct { @@ -58,12 +62,15 @@ type inflight struct { } type tokenState struct { - last time.Time - entries map[ssarKey]cacheEntry - flight map[ssarKey]*inflight - client kubernetes.Interface - clientErr error - clientWait chan struct{} + last time.Time + entries map[ssarKey]cacheEntry + flight map[ssarKey]*inflight + rules map[string]timedRules + rulesFlight map[string]*rulesInflight + kindAccess map[kindAccessKey]timedKindAccess + client kubernetes.Interface + clientErr error + clientWait chan struct{} } type prefetchJob struct { @@ -75,9 +82,10 @@ type prefetchJob struct { namespace string } -// SSARAccess ports Node eventFilter / canAccess (list cluster → list namespaced → get). +// SSARAccess ports Node eventFilter / canGetResource (list cluster → SelfSubjectRulesReview → SSAR fallback). type SSARAccess struct { - newClient func(userToken string) (kubernetes.Interface, error) + newClient func(userToken string) (kubernetes.Interface, error) + isClusterScoped func(kind string) bool mu sync.Mutex byToken map[string]*tokenState @@ -94,8 +102,9 @@ func NewSSARAccess(base *rest.Config) *SSARAccess { func NewSSARAccessWithClient(newClient func(userToken string) (kubernetes.Interface, error)) *SSARAccess { return &SSARAccess{ - byToken: map[string]*tokenState{}, - newClient: newClient, + byToken: map[string]*tokenState{}, + newClient: newClient, + isClusterScoped: informers.IsClusterScopedKind, } } @@ -164,13 +173,14 @@ func (a *SSARAccess) Prefetch(ctx context.Context, token string, events []Event) if kind == "" || resource == "" { continue } - key := ssarKey{kind: kind} + group := apiGroup(apiVersion) + key := ssarKey{verb: "list", group: group, kind: kind} if _, ok := jobs[key]; ok { continue } jobs[key] = prefetchJob{ key: key, - group: apiGroup(apiVersion), + group: group, resource: resource, verb: "list", } @@ -204,39 +214,61 @@ func (a *SSARAccess) canSee(ctx context.Context, token string, ev Event) (bool, } group := apiGroup(apiVersion) - allowed, err := a.ssar(ctx, token, ssarKey{kind: kind}, group, resource, "list", "", "") + allowed, err := a.ssarListCluster(ctx, token, group, resource, kind) if err != nil { return false, err } if allowed { return true, nil } + return a.canGetResource(ctx, token, group, resource, kind, name, namespace) +} + +func ssarNamespace(kind, name, namespace string) string { + if kind == "Namespace" { + return name + } + return namespace +} + +func (a *SSARAccess) ssarListCluster(ctx context.Context, token, group, resource, kind string) (bool, error) { + key := ssarKey{verb: "list", group: group, kind: kind} + return a.ssar(ctx, token, key, group, resource, "list", "", "") +} + +func (a *SSARAccess) ssarListNamespaced(ctx context.Context, token, group, resource, kind, namespace string) (bool, error) { + key := ssarKey{verb: "list", group: group, kind: kind, namespace: namespace} + return a.ssar(ctx, token, key, group, resource, "list", "", namespace) +} + +func (a *SSARAccess) ssarGet(ctx context.Context, token, group, resource, kind, name, namespace string) (bool, error) { + key := ssarKey{verb: "get", group: group, kind: kind, namespace: namespace, name: name} + return a.ssar(ctx, token, key, group, resource, "get", name, ssarNamespace(kind, name, namespace)) +} + +func (a *SSARAccess) incompleteFallback(ctx context.Context, token, group, resource, kind, name, namespace string) (bool, error) { if namespace == "" { - return a.ssar(ctx, token, ssarKey{kind: kind, name: name}, group, resource, "get", name, ssarNamespace(kind, name, namespace)) + return a.ssarGet(ctx, token, group, resource, kind, name, namespace) } - allowed, err = a.ssar(ctx, token, ssarKey{kind: kind, namespace: namespace}, group, resource, "list", "", namespace) + allowed, err := a.ssarListNamespaced(ctx, token, group, resource, kind, namespace) if err != nil { return false, err } if allowed { return true, nil } - return a.ssar(ctx, token, ssarKey{kind: kind, namespace: namespace, name: name}, group, resource, "get", name, ssarNamespace(kind, name, namespace)) -} - -func ssarNamespace(kind, name, namespace string) string { - if kind == "Namespace" { - return name - } - return namespace + return a.ssarGet(ctx, token, group, resource, kind, name, namespace) } func (a *SSARAccess) ensureTokenLocked(th string) *tokenState { st := a.byToken[th] if st == nil { st = &tokenState{ - entries: map[ssarKey]cacheEntry{}, - flight: map[ssarKey]*inflight{}, + entries: map[ssarKey]cacheEntry{}, + flight: map[ssarKey]*inflight{}, + rules: map[string]timedRules{}, + rulesFlight: map[string]*rulesInflight{}, + kindAccess: map[kindAccessKey]timedKindAccess{}, } a.byToken[th] = st } @@ -246,6 +278,15 @@ func (a *SSARAccess) ensureTokenLocked(th string) *tokenState { if st.flight == nil { st.flight = map[ssarKey]*inflight{} } + if st.rules == nil { + st.rules = map[string]timedRules{} + } + if st.rulesFlight == nil { + st.rulesFlight = map[string]*rulesInflight{} + } + if st.kindAccess == nil { + st.kindAccess = map[kindAccessKey]timedKindAccess{} + } return st } @@ -341,12 +382,37 @@ func (a *SSARAccess) finishFlight(th string, key ssarKey, f *inflight, allowed b st.last = time.Now() if cache && err == nil { st.entries[key] = cacheEntry{allowed: allowed, expiry: time.Now().Add(accessCacheTTL)} + st.enforceEntryCap() } } a.mu.Unlock() close(f.done) } +func (st *tokenState) enforceEntryCap() { + if len(st.entries) <= accessCacheMaxEntriesPerToken { + return + } + type pair struct { + key ssarKey + exp time.Time + } + all := make([]pair, 0, len(st.entries)) + for k, e := range st.entries { + all = append(all, pair{k, e.expiry}) + } + sort.Slice(all, func(i, j int) bool { return all[i].exp.Before(all[j].exp) }) + extra := len(all) - accessCacheMaxEntriesPerToken + for i := 0; i < extra; i++ { + delete(st.entries, all[i].key) + } +} + +func (st *tokenState) empty() bool { + return len(st.entries) == 0 && len(st.flight) == 0 && + len(st.rules) == 0 && len(st.rulesFlight) == 0 && len(st.kindAccess) == 0 +} + func (a *SSARAccess) StartCleanup(ctx context.Context) { if a == nil { return @@ -374,7 +440,18 @@ func (a *SSARAccess) cleanup(now time.Time) { delete(st.entries, k) } } - if len(st.entries) == 0 && len(st.flight) == 0 { + st.enforceEntryCap() + for ns, e := range st.rules { + if !e.expiry.After(now) { + delete(st.rules, ns) + } + } + for k, e := range st.kindAccess { + if !e.expiry.After(now) { + delete(st.kindAccess, k) + } + } + if st.empty() { delete(a.byToken, th) } } diff --git a/backend/internal/events/hub/access_rules.go b/backend/internal/events/hub/access_rules.go new file mode 100644 index 00000000000..fa6ecd4c70c --- /dev/null +++ b/backend/internal/events/hub/access_rules.go @@ -0,0 +1,250 @@ +// Copyright Contributors to the Open Cluster Management project + +package hub + +import ( + "context" + "slices" + "time" + + authzv1 "k8s.io/api/authorization/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + applog "github.com/stolostron/console/backend/internal/log" +) + +// SSRR requires a namespace; cluster-scoped kinds are reviewed in this probe namespace only. +const clusterScopedRulesNamespace = "default" + +type kindAccessType int + +const ( + kindAccessDenyAll kindAccessType = iota + kindAccessAllowAll + kindAccessAllowNames + kindAccessIncomplete +) + +type kindGetAccess struct { + typ kindAccessType + names map[string]struct{} +} + +type subjectRulesStatus struct { + incomplete bool + unavailable bool + evaluationError string + resourceRules []authzv1.ResourceRule +} + +type kindAccessKey struct { + namespace, group, resource string +} + +type timedRules struct { + status subjectRulesStatus + expiry time.Time +} + +type timedKindAccess struct { + access kindGetAccess + expiry time.Time +} + +type rulesInflight struct { + done chan struct{} + status subjectRulesStatus +} + +func (a *SSARAccess) clusterScoped(kind string) bool { + if a != nil && a.isClusterScoped != nil { + return a.isClusterScoped(kind) + } + return false +} + +func rulesNamespaceFor(kind, namespace string, clusterScoped bool) string { + if clusterScoped { + return clusterScopedRulesNamespace + } + if namespace != "" { + return namespace + } + return clusterScopedRulesNamespace +} + +func ruleGrantsKindAccess(rule authzv1.ResourceRule, group, resource string) (allowAll bool, names []string) { + if !slices.Contains(rule.Verbs, "*") && !slices.Contains(rule.Verbs, "get") && + !slices.Contains(rule.Verbs, "list") && !slices.Contains(rule.Verbs, "watch") { + return false, nil + } + if !slices.Contains(rule.APIGroups, "*") && !slices.Contains(rule.APIGroups, group) { + return false, nil + } + if !slices.Contains(rule.Resources, "*") && !slices.Contains(rule.Resources, resource) { + return false, nil + } + if len(rule.ResourceNames) == 0 || slices.Contains(rule.ResourceNames, "*") { + return true, nil + } + return false, rule.ResourceNames +} + +func evaluateKindGetAccess(rules subjectRulesStatus, group, resource string) kindGetAccess { + if rules.evaluationError != "" { + if len(rules.resourceRules) == 0 { + return kindGetAccess{typ: kindAccessDenyAll} + } + return kindGetAccess{typ: kindAccessIncomplete} + } + + names := map[string]struct{}{} + for _, rule := range rules.resourceRules { + allowAll, ruleNames := ruleGrantsKindAccess(rule, group, resource) + if allowAll { + return kindGetAccess{typ: kindAccessAllowAll} + } + for _, name := range ruleNames { + names[name] = struct{}{} + } + } + if len(names) > 0 { + return kindGetAccess{typ: kindAccessAllowNames, names: names} + } + if rules.unavailable { + return kindGetAccess{typ: kindAccessIncomplete} + } + if len(rules.resourceRules) == 0 { + return kindGetAccess{typ: kindAccessDenyAll} + } + if rules.incomplete { + return kindGetAccess{typ: kindAccessIncomplete} + } + return kindGetAccess{typ: kindAccessDenyAll} +} + +func (a *SSARAccess) getSubjectRules(ctx context.Context, token, namespace string) subjectRulesStatus { + now := time.Now() + th := hashToken(token) + a.mu.Lock() + st := a.ensureTokenLocked(th) + if e, ok := st.rules[namespace]; ok && e.expiry.After(now) { + status := e.status + a.mu.Unlock() + return status + } + if f, ok := st.rulesFlight[namespace]; ok { + a.mu.Unlock() + select { + case <-f.done: + return f.status + case <-ctx.Done(): + return subjectRulesStatus{incomplete: true, unavailable: true} + } + } + f := &rulesInflight{done: make(chan struct{})} + st.rulesFlight[namespace] = f + a.mu.Unlock() + + status := a.fetchSubjectRules(ctx, token, th, namespace) + + a.mu.Lock() + if st = a.byToken[th]; st != nil { + delete(st.rulesFlight, namespace) + st.last = time.Now() + if !status.unavailable { + if st.rules == nil { + st.rules = map[string]timedRules{} + } + st.rules[namespace] = timedRules{status: status, expiry: time.Now().Add(accessCacheTTL)} + } + } + a.mu.Unlock() + f.status = status + close(f.done) + return status +} + +func (a *SSARAccess) fetchSubjectRules(ctx context.Context, token, th, namespace string) subjectRulesStatus { + client, err := a.clientFor(token, th) + if err != nil { + applog.Logger().Warn("selfsubjectrulesreview failed; falling back to per-object SSAR", "error", err) + return subjectRulesStatus{incomplete: true, unavailable: true} + } + review, err := client.AuthorizationV1().SelfSubjectRulesReviews().Create(ctx, &authzv1.SelfSubjectRulesReview{ + Spec: authzv1.SelfSubjectRulesReviewSpec{Namespace: namespace}, + }, metav1.CreateOptions{}) + if err != nil { + applog.Logger().Warn("selfsubjectrulesreview failed; falling back to per-object SSAR", "error", err) + return subjectRulesStatus{incomplete: true, unavailable: true} + } + if review == nil { + return subjectRulesStatus{incomplete: true, unavailable: true} + } + return subjectRulesStatus{ + incomplete: review.Status.Incomplete, + evaluationError: review.Status.EvaluationError, + resourceRules: review.Status.ResourceRules, + } +} + +func (a *SSARAccess) resolveKindGetAccess(ctx context.Context, token, kind, namespace, group, resource string) kindGetAccess { + ns := rulesNamespaceFor(kind, namespace, a.clusterScoped(kind)) + key := kindAccessKey{namespace: ns, group: group, resource: resource} + now := time.Now() + th := hashToken(token) + a.mu.Lock() + st := a.ensureTokenLocked(th) + if e, ok := st.kindAccess[key]; ok && e.expiry.After(now) { + access := e.access + a.mu.Unlock() + return access + } + a.mu.Unlock() + + rules := a.getSubjectRules(ctx, token, ns) + access := evaluateKindGetAccess(rules, group, resource) + + a.mu.Lock() + st = a.ensureTokenLocked(th) + if !rules.unavailable { + if st.kindAccess == nil { + st.kindAccess = map[kindAccessKey]timedKindAccess{} + } + st.kindAccess[key] = timedKindAccess{access: access, expiry: time.Now().Add(accessCacheTTL)} + } + a.mu.Unlock() + return access +} + +func (a *SSARAccess) applyKindGetAccess( + ctx context.Context, + token string, + access kindGetAccess, + group, resource, kind, name, namespace string, +) (bool, error) { + switch access.typ { + case kindAccessDenyAll: + return false, nil + case kindAccessAllowAll: + return true, nil + case kindAccessAllowNames: + if name == "" || access.names == nil { + return false, nil + } + _, ok := access.names[name] + return ok, nil + case kindAccessIncomplete: + return a.incompleteFallback(ctx, token, group, resource, kind, name, namespace) + default: + return false, nil + } +} + +func (a *SSARAccess) canGetResource(ctx context.Context, token, group, resource, kind, name, namespace string) (bool, error) { + access := a.resolveKindGetAccess(ctx, token, kind, namespace, group, resource) + if a.clusterScoped(kind) && access.typ != kindAccessDenyAll { + return a.ssarGet(ctx, token, group, resource, kind, name, namespace) + } + return a.applyKindGetAccess(ctx, token, access, group, resource, kind, name, namespace) +} diff --git a/backend/internal/events/hub/access_rules_test.go b/backend/internal/events/hub/access_rules_test.go new file mode 100644 index 00000000000..2f7445f09ea --- /dev/null +++ b/backend/internal/events/hub/access_rules_test.go @@ -0,0 +1,91 @@ +// Copyright Contributors to the Open Cluster Management project + +package hub + +import ( + "testing" + + authzv1 "k8s.io/api/authorization/v1" +) + +func TestEvaluateKindGetAccess(t *testing.T) { + empty := subjectRulesStatus{} + secretRule := authzv1.ResourceRule{Verbs: []string{"get"}, APIGroups: []string{""}, Resources: []string{"secrets"}} + named := authzv1.ResourceRule{ + Verbs: []string{"get"}, + APIGroups: []string{"cluster.open-cluster-management.io"}, + Resources: []string{"managedclusters"}, + ResourceNames: []string{"allowed-cluster"}, + } + star := authzv1.ResourceRule{Verbs: []string{"*"}, APIGroups: []string{"*"}, Resources: []string{"*"}} + + cases := []struct { + name string + rules subjectRulesStatus + group string + resource string + want kindAccessType + wantName string + }{ + {name: "empty deny-all", rules: empty, group: "", resource: "secrets", want: kindAccessDenyAll}, + {name: "empty incomplete deny-all", rules: subjectRulesStatus{incomplete: true}, group: "", resource: "secrets", want: kindAccessDenyAll}, + {name: "unavailable incomplete", rules: subjectRulesStatus{unavailable: true}, group: "", resource: "secrets", want: kindAccessIncomplete}, + {name: "evaluationError empty deny-all", rules: subjectRulesStatus{evaluationError: "webhook"}, group: "", resource: "secrets", want: kindAccessDenyAll}, + { + name: "evaluationError non-empty incomplete", + rules: subjectRulesStatus{evaluationError: "webhook", resourceRules: []authzv1.ResourceRule{secretRule}}, + group: "", + resource: "secrets", + want: kindAccessIncomplete, + }, + {name: "allow-all secrets", rules: subjectRulesStatus{resourceRules: []authzv1.ResourceRule{secretRule}}, group: "", resource: "secrets", want: kindAccessAllowAll}, + {name: "star allow-all", rules: subjectRulesStatus{resourceRules: []authzv1.ResourceRule{star}}, group: "cluster.open-cluster-management.io", resource: "managedclusters", want: kindAccessAllowAll}, + { + name: "allow-names", + rules: subjectRulesStatus{resourceRules: []authzv1.ResourceRule{named}}, + group: "cluster.open-cluster-management.io", + resource: "managedclusters", + want: kindAccessAllowNames, + wantName: "allowed-cluster", + }, + { + name: "incomplete non-empty unmatched", + rules: subjectRulesStatus{incomplete: true, resourceRules: []authzv1.ResourceRule{secretRule}}, + group: "cluster.open-cluster-management.io", + resource: "managedclusters", + want: kindAccessIncomplete, + }, + { + name: "complete unmatched deny-all", + rules: subjectRulesStatus{resourceRules: []authzv1.ResourceRule{secretRule}}, + group: "cluster.open-cluster-management.io", + resource: "managedclusters", + want: kindAccessDenyAll, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := evaluateKindGetAccess(tc.rules, tc.group, tc.resource) + if got.typ != tc.want { + t.Fatalf("typ %v want %v", got.typ, tc.want) + } + if tc.want == kindAccessAllowNames { + if _, ok := got.names[tc.wantName]; !ok { + t.Fatalf("missing name %q in %v", tc.wantName, got.names) + } + } + }) + } +} + +func TestRulesNamespaceFor(t *testing.T) { + if got := rulesNamespaceFor("ManagedCluster", "other", true); got != clusterScopedRulesNamespace { + t.Fatalf("cluster-scoped %q", got) + } + if got := rulesNamespaceFor("Secret", "ns-a", false); got != "ns-a" { + t.Fatalf("namespaced %q", got) + } + if got := rulesNamespaceFor("Secret", "", false); got != clusterScopedRulesNamespace { + t.Fatalf("missing ns %q", got) + } +} diff --git a/backend/internal/events/hub/access_ssrr_test.go b/backend/internal/events/hub/access_ssrr_test.go new file mode 100644 index 00000000000..61ba50af597 --- /dev/null +++ b/backend/internal/events/hub/access_ssrr_test.go @@ -0,0 +1,728 @@ +// Copyright Contributors to the Open Cluster Management project + +package hub + +import ( + "context" + "errors" + "fmt" + "sync" + "testing" + "time" + + authzv1 "k8s.io/api/authorization/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/fake" + ktesting "k8s.io/client-go/testing" +) + +type ssarCall struct { + verb, group, resource, name, namespace string +} + +type authRec struct { + mu sync.Mutex + ssar []ssarCall + ssrrNS []string +} + +func (r *authRec) snapshot() (ssar []ssarCall, ssrr []string) { + r.mu.Lock() + defer r.mu.Unlock() + ssar = append([]ssarCall(nil), r.ssar...) + ssrr = append([]string(nil), r.ssrrNS...) + return ssar, ssrr +} + +func countVerb(calls []ssarCall, verb string) int { + n := 0 + for _, c := range calls { + if c.verb == verb { + n++ + } + } + return n +} + +func emptyRules() authzv1.SubjectRulesReviewStatus { + return authzv1.SubjectRulesReviewStatus{Incomplete: false, ResourceRules: []authzv1.ResourceRule{}} +} + +func secretGetRules() authzv1.SubjectRulesReviewStatus { + return authzv1.SubjectRulesReviewStatus{ + ResourceRules: []authzv1.ResourceRule{{ + Verbs: []string{"get"}, + APIGroups: []string{""}, + Resources: []string{"secrets"}, + }}, + } +} + +func clusterAdminRules() authzv1.SubjectRulesReviewStatus { + return authzv1.SubjectRulesReviewStatus{ + ResourceRules: []authzv1.ResourceRule{{ + Verbs: []string{"*"}, + APIGroups: []string{"*"}, + Resources: []string{"*"}, + }}, + } +} + +func namedManagedClusterRule(name string) authzv1.SubjectRulesReviewStatus { + return authzv1.SubjectRulesReviewStatus{ + ResourceRules: []authzv1.ResourceRule{{ + Verbs: []string{"get"}, + APIGroups: []string{"cluster.open-cluster-management.io"}, + Resources: []string{"managedclusters"}, + ResourceNames: []string{name}, + }}, + } +} + +func managedClusterAllowAll() authzv1.SubjectRulesReviewStatus { + return authzv1.SubjectRulesReviewStatus{ + ResourceRules: []authzv1.ResourceRule{{ + Verbs: []string{"get", "list", "watch"}, + APIGroups: []string{"cluster.open-cluster-management.io"}, + Resources: []string{"managedclusters"}, + }}, + } +} + +func modifiedCluster(name string) Event { + return Event{ + Type: TypeModified, + GVR: schema.GroupVersionResource{Group: "cluster.open-cluster-management.io", Version: "v1", Resource: "managedclusters"}, + Object: map[string]any{ + "kind": "ManagedCluster", "apiVersion": "cluster.open-cluster-management.io/v1", + "metadata": map[string]any{"name": name}, + }, + } +} + +func modifiedSecret(ns, name string) Event { + return Event{ + Type: TypeModified, + GVR: schema.GroupVersionResource{Version: "v1", Resource: "secrets"}, + Object: map[string]any{ + "kind": "Secret", "apiVersion": "v1", + "metadata": map[string]any{"name": name, "namespace": ns}, + }, + } +} + +func modifiedMCI(cluster string) Event { + return Event{ + Type: TypeModified, + GVR: schema.GroupVersionResource{ + Group: "internal.open-cluster-management.io", Version: "v1beta1", Resource: "managedclusterinfos", + }, + Object: map[string]any{ + "kind": "ManagedClusterInfo", "apiVersion": "internal.open-cluster-management.io/v1beta1", + "metadata": map[string]any{"name": cluster, "namespace": cluster}, + }, + } +} + +func modifiedPlacement(ns, name string) Event { + return Event{ + Type: TypeModified, + GVR: schema.GroupVersionResource{ + Group: "cluster.open-cluster-management.io", Version: "v1beta1", Resource: "placements", + }, + Object: map[string]any{ + "kind": "Placement", "apiVersion": "cluster.open-cluster-management.io/v1beta1", + "metadata": map[string]any{"name": name, "namespace": ns}, + }, + } +} + +func modifiedClusterExtension(name string) Event { + return Event{ + Type: TypeModified, + GVR: schema.GroupVersionResource{Group: "olm.operatorframework.io", Version: "v1", Resource: "clusterextensions"}, + Object: map[string]any{ + "kind": "ClusterExtension", "apiVersion": "olm.operatorframework.io/v1", + "metadata": map[string]any{"name": name}, + }, + } +} + +func newAuthClient(rec *authRec, ssrr func(ns string) (authzv1.SubjectRulesReviewStatus, error), getAllowed func(ssarCall) bool) *fake.Clientset { + client := fake.NewSimpleClientset() + client.PrependReactor("create", "selfsubjectrulesreviews", func(action ktesting.Action) (bool, runtime.Object, error) { + create := action.(ktesting.CreateAction) + review := create.GetObject().(*authzv1.SelfSubjectRulesReview) + ns := review.Spec.Namespace + rec.mu.Lock() + rec.ssrrNS = append(rec.ssrrNS, ns) + rec.mu.Unlock() + status, err := ssrr(ns) + if err != nil { + return true, nil, err + } + return true, &authzv1.SelfSubjectRulesReview{Status: status}, nil + }) + client.PrependReactor("create", "selfsubjectaccessreviews", func(action ktesting.Action) (bool, runtime.Object, error) { + create := action.(ktesting.CreateAction) + review := create.GetObject().(*authzv1.SelfSubjectAccessReview) + attr := review.Spec.ResourceAttributes + call := ssarCall{ + verb: attr.Verb, + group: attr.Group, + resource: attr.Resource, + name: attr.Name, + namespace: attr.Namespace, + } + rec.mu.Lock() + rec.ssar = append(rec.ssar, call) + rec.mu.Unlock() + allowed := false + if attr.Verb == "list" { + allowed = false + } else if getAllowed != nil { + allowed = getAllowed(call) + } + return true, &authzv1.SelfSubjectAccessReview{ + Status: authzv1.SubjectAccessReviewStatus{Allowed: allowed}, + }, nil + }) + return client +} + +func mustAllow(t *testing.T, a *SSARAccess, token string, ev Event) bool { + t.Helper() + ok, err := a.Allow(context.Background(), token, ev) + if err != nil { + t.Fatalf("Allow err=%v", err) + } + return ok +} + +func TestSSRRDenyAllSkipsGet(t *testing.T) { + rec := &authRec{} + client := newAuthClient(rec, func(string) (authzv1.SubjectRulesReviewStatus, error) { + return emptyRules(), nil + }, func(ssarCall) bool { return true }) + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { return client, nil }) + if mustAllow(t, a, "none-user-token", modifiedCluster("cluster-1")) { + t.Fatal("expected deny") + } + ssar, ssrr := rec.snapshot() + if len(ssrr) != 1 || ssrr[0] != clusterScopedRulesNamespace { + t.Fatalf("ssrr %v", ssrr) + } + if countVerb(ssar, "get") != 0 { + t.Fatalf("gets %v", ssar) + } + if countVerb(ssar, "list") != 1 { + t.Fatalf("lists %v", ssar) + } +} + +func TestSSRRScaleClusterScopedGets(t *testing.T) { + rec := &authRec{} + client := newAuthClient(rec, func(string) (authzv1.SubjectRulesReviewStatus, error) { + return emptyRules(), nil + }, func(ssarCall) bool { return true }) + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { return client, nil }) + errs := make(chan error, 500) + var wg sync.WaitGroup + for i := 0; i < 500; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + ok, err := a.Allow(context.Background(), "scale-none-token", modifiedCluster(fmt.Sprintf("cluster-%d", i))) + if err != nil { + errs <- err + return + } + if ok { + errs <- fmt.Errorf("cluster-%d allowed", i) + } + }(i) + } + wg.Wait() + close(errs) + for err := range errs { + t.Fatal(err) + } + ssar, ssrr := rec.snapshot() + if len(ssrr) != 1 { + t.Fatalf("ssrr calls %d want 1", len(ssrr)) + } + if countVerb(ssar, "get") != 0 { + t.Fatalf("gets %d", countVerb(ssar, "get")) + } + if countVerb(ssar, "list") != 1 { + t.Fatalf("lists %d", countVerb(ssar, "list")) + } +} + +func TestSSRRScaleSameNamespace(t *testing.T) { + rec := &authRec{} + client := newAuthClient(rec, func(string) (authzv1.SubjectRulesReviewStatus, error) { + return emptyRules(), nil + }, nil) + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { return client, nil }) + for i := 0; i < 200; i++ { + ev := Event{ + Type: TypeModified, + GVR: schema.GroupVersionResource{ + Group: "internal.open-cluster-management.io", Version: "v1beta1", Resource: "managedclusterinfos", + }, + Object: map[string]any{ + "kind": "ManagedClusterInfo", "apiVersion": "internal.open-cluster-management.io/v1beta1", + "metadata": map[string]any{"name": fmt.Sprintf("info-%d", i), "namespace": "acm39327-mc-01"}, + }, + } + if mustAllow(t, a, "same-ns-none-token", ev) { + t.Fatal("expected deny") + } + } + _, ssrr := rec.snapshot() + if len(ssrr) != 1 { + t.Fatalf("ssrr calls %d want 1", len(ssrr)) + } +} + +func TestSSRROneReviewPerNamespace(t *testing.T) { + rec := &authRec{} + client := newAuthClient(rec, func(string) (authzv1.SubjectRulesReviewStatus, error) { + return emptyRules(), nil + }, func(ssarCall) bool { return true }) + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { return client, nil }) + for i := 0; i < 50; i++ { + if mustAllow(t, a, "namespaced-none-token", modifiedMCI(fmt.Sprintf("cluster-%d", i))) { + t.Fatal("expected deny") + } + } + ssar, ssrr := rec.snapshot() + if len(ssrr) != 50 { + t.Fatalf("ssrr %d want 50", len(ssrr)) + } + if countVerb(ssar, "get") != 0 { + t.Fatalf("gets %v", ssar) + } +} + +func TestSSRRSecretsDefaultNotOtherNamespaces(t *testing.T) { + rec := &authRec{} + client := newAuthClient(rec, func(ns string) (authzv1.SubjectRulesReviewStatus, error) { + if ns == "default" { + return secretGetRules(), nil + } + return emptyRules(), nil + }, nil) + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { return client, nil }) + if !mustAllow(t, a, "user1-token", modifiedSecret("default", "default-cred")) { + t.Fatal("default secret") + } + if mustAllow(t, a, "user1-token", modifiedSecret("kube-system", "other-cred")) { + t.Fatal("kube-system secret") + } + if mustAllow(t, a, "user1-token", modifiedSecret("acm39327-mc-01", "cluster-cred")) { + t.Fatal("cluster secret") + } + ssar, _ := rec.snapshot() + if countVerb(ssar, "get") != 0 { + t.Fatalf("unexpected gets %v", ssar) + } +} + +func TestSSRRNamespacedAdminNotClusterScoped(t *testing.T) { + rec := &authRec{} + client := newAuthClient(rec, func(ns string) (authzv1.SubjectRulesReviewStatus, error) { + if ns == "acm39327-mc-01" { + return clusterAdminRules(), nil + } + return emptyRules(), nil + }, func(ssarCall) bool { return true }) + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { return client, nil }) + if !mustAllow(t, a, "cluster-admin-token", modifiedMCI("acm39327-mc-01")) { + t.Fatal("cluster ns admin") + } + if mustAllow(t, a, "cluster-admin-token", modifiedMCI("other-cluster")) { + t.Fatal("other cluster ns") + } + if mustAllow(t, a, "cluster-admin-token", modifiedCluster("acm39327-mc-01")) { + t.Fatal("ManagedCluster must not follow default-ns RoleBinding; empty default SSRR is deny-all") + } + ssar, _ := rec.snapshot() + if countVerb(ssar, "get") != 0 { + t.Fatalf("gets %v", ssar) + } +} + +func TestSSRRNamedClusterScopedConfirmedWithSSAR(t *testing.T) { + rec := &authRec{} + client := newAuthClient(rec, func(string) (authzv1.SubjectRulesReviewStatus, error) { + return namedManagedClusterRule("allowed-cluster"), nil + }, func(c ssarCall) bool { + return c.group == "cluster.open-cluster-management.io" && c.resource == "managedclusters" && c.name == "allowed-cluster" + }) + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { return client, nil }) + if !mustAllow(t, a, "partial-user-token", modifiedCluster("allowed-cluster")) { + t.Fatal("allowed-cluster") + } + ssar, _ := rec.snapshot() + if countVerb(ssar, "get") != 1 { + t.Fatalf("gets %v", ssar) + } +} + +func TestSSRRNamedClusterScopedDeniedBySSAR(t *testing.T) { + rec := &authRec{} + client := newAuthClient(rec, func(string) (authzv1.SubjectRulesReviewStatus, error) { + return namedManagedClusterRule("allowed-cluster"), nil + }, func(c ssarCall) bool { return false }) + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { return client, nil }) + if mustAllow(t, a, "partial-user-token", modifiedCluster("other-cluster")) { + t.Fatal("other-cluster") + } + ssar, _ := rec.snapshot() + if countVerb(ssar, "get") != 1 { + t.Fatalf("gets %v", ssar) + } +} + +func TestSSRRNamespacedAllowAllWithoutGet(t *testing.T) { + rec := &authRec{} + client := newAuthClient(rec, func(string) (authzv1.SubjectRulesReviewStatus, error) { + return authzv1.SubjectRulesReviewStatus{ + ResourceRules: []authzv1.ResourceRule{{ + Verbs: []string{"get", "list", "watch"}, + APIGroups: []string{""}, + Resources: []string{"secrets"}, + }}, + }, nil + }, func(ssarCall) bool { return false }) + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { return client, nil }) + if !mustAllow(t, a, "viewer-token", modifiedSecret("default", "any-secret")) { + t.Fatal("secret allow-all") + } + ssar, _ := rec.snapshot() + if countVerb(ssar, "get") != 0 { + t.Fatalf("gets %v", ssar) + } +} + +func TestSSRRClusterScopedAllowAllConfirmedWithSSAR(t *testing.T) { + rec := &authRec{} + client := newAuthClient(rec, func(string) (authzv1.SubjectRulesReviewStatus, error) { + return managedClusterAllowAll(), nil + }, func(ssarCall) bool { return false }) + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { return client, nil }) + if mustAllow(t, a, "default-role-token", modifiedCluster("any-cluster")) { + t.Fatal("RoleBinding in default must not grant ManagedCluster") + } + ssar, _ := rec.snapshot() + if countVerb(ssar, "get") != 1 { + t.Fatalf("gets %v", ssar) + } +} + +func TestSSRRKindAccessReusedAcrossAPIVersions(t *testing.T) { + rec := &authRec{} + client := newAuthClient(rec, func(string) (authzv1.SubjectRulesReviewStatus, error) { + return emptyRules(), nil + }, nil) + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { return client, nil }) + ev := func(apiVersion string) Event { + return Event{ + Type: TypeModified, + GVR: schema.GroupVersionResource{ + Group: "cluster.open-cluster-management.io", Version: "v1beta1", Resource: "placements", + }, + Object: map[string]any{ + "kind": "Placement", "apiVersion": apiVersion, + "metadata": map[string]any{"name": "p", "namespace": "ns"}, + }, + } + } + if mustAllow(t, a, "version-token", ev("cluster.open-cluster-management.io/v1beta1")) { + t.Fatal("v1beta1") + } + if mustAllow(t, a, "version-token", ev("cluster.open-cluster-management.io/v1alpha1")) { + t.Fatal("v1alpha1") + } + _, ssrr := rec.snapshot() + if len(ssrr) != 1 { + t.Fatalf("ssrr %d want 1", len(ssrr)) + } +} + +func TestSSRRIncompleteNonEmptyFallsBackToSSAR(t *testing.T) { + rec := &authRec{} + client := newAuthClient(rec, func(string) (authzv1.SubjectRulesReviewStatus, error) { + return incompletePodRules(), nil + }, func(c ssarCall) bool { + return c.verb == "get" && c.resource == "managedclusters" + }) + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { return client, nil }) + if !mustAllow(t, a, "incomplete-user-token", modifiedCluster("cluster-1")) { + t.Fatal("expected SSAR allow") + } + ssar, _ := rec.snapshot() + if countVerb(ssar, "get") != 1 { + t.Fatalf("gets %v", ssar) + } +} + +func TestSSRREmptyIncompleteIsDenyAll(t *testing.T) { + rec := &authRec{} + client := newAuthClient(rec, func(string) (authzv1.SubjectRulesReviewStatus, error) { + return authzv1.SubjectRulesReviewStatus{Incomplete: true, ResourceRules: []authzv1.ResourceRule{}}, nil + }, func(ssarCall) bool { return true }) + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { return client, nil }) + if mustAllow(t, a, "openshift-none-token", modifiedCluster("cluster-1")) { + t.Fatal("expected deny-all") + } + ssar, _ := rec.snapshot() + if countVerb(ssar, "get") != 0 { + t.Fatalf("gets %v", ssar) + } +} + +func TestSSRRRequestFailureFallsBackToSSAR(t *testing.T) { + rec := &authRec{} + client := newAuthClient(rec, func(string) (authzv1.SubjectRulesReviewStatus, error) { + return authzv1.SubjectRulesReviewStatus{}, errors.New("internal error") + }, func(c ssarCall) bool { return c.verb == "get" }) + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { return client, nil }) + if !mustAllow(t, a, "ssrr-fail-token", modifiedCluster("cluster-1")) { + t.Fatal("expected SSAR fallback allow") + } + ssar, _ := rec.snapshot() + if countVerb(ssar, "get") != 1 { + t.Fatalf("gets %v", ssar) + } +} + +func TestSSRRCachesExpireOnCleanup(t *testing.T) { + rec := &authRec{} + client := newAuthClient(rec, func(string) (authzv1.SubjectRulesReviewStatus, error) { + return emptyRules(), nil + }, nil) + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { return client, nil }) + if mustAllow(t, a, "cache-expiry-token", modifiedCluster("c1")) { + t.Fatal("c1") + } + a.mu.Lock() + for _, st := range a.byToken { + for k, e := range st.entries { + e.expiry = time.Now().Add(-time.Second) + st.entries[k] = e + } + for ns, e := range st.rules { + e.expiry = time.Now().Add(-time.Second) + st.rules[ns] = e + } + for k, e := range st.kindAccess { + e.expiry = time.Now().Add(-time.Second) + st.kindAccess[k] = e + } + } + a.mu.Unlock() + a.cleanup(time.Now()) + if mustAllow(t, a, "cache-expiry-token", modifiedCluster("c2")) { + t.Fatal("c2") + } + _, ssrr := rec.snapshot() + if len(ssrr) != 2 { + t.Fatalf("ssrr %d want 2", len(ssrr)) + } +} + +func TestSSRRManagedClusterNamespaceStillClusterScoped(t *testing.T) { + rec := &authRec{} + client := newAuthClient(rec, func(ns string) (authzv1.SubjectRulesReviewStatus, error) { + if ns != clusterScopedRulesNamespace { + t.Errorf("SSRR namespace %q want default", ns) + } + return namedManagedClusterRule("acm39327-mc-02"), nil + }, func(ssarCall) bool { return false }) + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { return client, nil }) + ev := Event{ + Type: TypeModified, + GVR: schema.GroupVersionResource{Group: "cluster.open-cluster-management.io", Version: "v1", Resource: "managedclusters"}, + Object: map[string]any{ + "kind": "ManagedCluster", "apiVersion": "cluster.open-cluster-management.io/v1", + "metadata": map[string]any{"name": "acm39327-mc-02", "namespace": "default"}, + }, + } + if mustAllow(t, a, "user1-token", ev) { + t.Fatal("must confirm with SSAR") + } + ssar, _ := rec.snapshot() + if countVerb(ssar, "get") != 1 { + t.Fatalf("gets %v", ssar) + } +} + +func TestSSRREvaluationErrorIncompleteConfirmsSSAR(t *testing.T) { + rec := &authRec{} + client := newAuthClient(rec, func(string) (authzv1.SubjectRulesReviewStatus, error) { + return authzv1.SubjectRulesReviewStatus{ + EvaluationError: "webhook authorizer does not support user rule resolution", + ResourceRules: []authzv1.ResourceRule{{ + Verbs: []string{"get"}, + APIGroups: []string{""}, + Resources: []string{"pods"}, + }}, + }, nil + }, func(c ssarCall) bool { + return c.resource == "managedclusters" && c.name == "cluster-1" + }) + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { return client, nil }) + if !mustAllow(t, a, "evaluation-error-token", modifiedCluster("cluster-1")) { + t.Fatal("expected SSAR allow") + } + ssar, _ := rec.snapshot() + if countVerb(ssar, "get") != 1 { + t.Fatalf("gets %v", ssar) + } +} + +func TestSSRRIncompleteNamedClusterScopedStillSSAR(t *testing.T) { + rec := &authRec{} + client := newAuthClient(rec, func(string) (authzv1.SubjectRulesReviewStatus, error) { + status := namedManagedClusterRule("cluster-1") + status.Incomplete = true + return status, nil + }, func(ssarCall) bool { return false }) + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { return client, nil }) + if mustAllow(t, a, "incomplete-named-token", modifiedCluster("cluster-1")) { + t.Fatal("SSAR must decide") + } + ssar, _ := rec.snapshot() + if countVerb(ssar, "get") != 1 { + t.Fatalf("gets %v", ssar) + } +} + +func TestSSARKeysIncludeAPIGroup(t *testing.T) { + rec := &authRec{} + client := newAuthClient(rec, func(string) (authzv1.SubjectRulesReviewStatus, error) { + return incompletePodRules(), nil + }, func(c ssarCall) bool { + return c.group == "app.k8s.io" + }) + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { return client, nil }) + app := func(apiVersion, group string) Event { + return Event{ + Type: TypeModified, + GVR: schema.GroupVersionResource{Group: group, Version: "v1beta1", Resource: "applications"}, + Object: map[string]any{ + "kind": "Application", "apiVersion": apiVersion, + "metadata": map[string]any{"name": "app", "namespace": "ns"}, + }, + } + } + if !mustAllow(t, a, "group-collision-token", app("app.k8s.io/v1beta1", "app.k8s.io")) { + t.Fatal("app.k8s.io") + } + if mustAllow(t, a, "group-collision-token", app("argoproj.io/v1alpha1", "argoproj.io")) { + t.Fatal("argoproj.io") + } + ssar, _ := rec.snapshot() + var gets []ssarCall + for _, c := range ssar { + if c.verb == "get" { + gets = append(gets, c) + } + } + if len(gets) != 2 { + t.Fatalf("gets %v", gets) + } +} + +func TestSSRRPlacementAllowAllIsNamespaced(t *testing.T) { + rec := &authRec{} + placementAllowAll := authzv1.SubjectRulesReviewStatus{ + ResourceRules: []authzv1.ResourceRule{{ + Verbs: []string{"get", "list", "watch"}, + APIGroups: []string{"cluster.open-cluster-management.io"}, + Resources: []string{"placements"}, + }}, + } + client := newAuthClient(rec, func(ns string) (authzv1.SubjectRulesReviewStatus, error) { + if ns == "default" { + return placementAllowAll, nil + } + return emptyRules(), nil + }, nil) + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { return client, nil }) + if !mustAllow(t, a, "placement-token", modifiedPlacement("default", "p-default")) { + t.Fatal("default placement") + } + if mustAllow(t, a, "placement-token", modifiedPlacement("other-ns", "p-other")) { + t.Fatal("other-ns placement") + } +} + +func TestSSRRClusterExtensionConfirmedWithSSAR(t *testing.T) { + rec := &authRec{} + client := newAuthClient(rec, func(string) (authzv1.SubjectRulesReviewStatus, error) { + return authzv1.SubjectRulesReviewStatus{ + ResourceRules: []authzv1.ResourceRule{{ + Verbs: []string{"get", "list", "watch"}, + APIGroups: []string{"olm.operatorframework.io"}, + Resources: []string{"clusterextensions"}, + }}, + }, nil + }, func(c ssarCall) bool { + return c.group == "olm.operatorframework.io" && c.resource == "clusterextensions" + }) + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { return client, nil }) + if !mustAllow(t, a, "cluster-extension-token", modifiedClusterExtension("ext-1")) { + t.Fatal("extension") + } + ssar, _ := rec.snapshot() + if countVerb(ssar, "get") != 1 { + t.Fatalf("gets %v", ssar) + } +} + +func TestSSRRRetryAfterUnavailable(t *testing.T) { + rec := &authRec{} + var ssrrCalls int + client := newAuthClient(rec, func(string) (authzv1.SubjectRulesReviewStatus, error) { + ssrrCalls++ + if ssrrCalls == 1 { + return authzv1.SubjectRulesReviewStatus{}, errors.New("internal error") + } + return emptyRules(), nil + }, func(ssarCall) bool { return false }) + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { return client, nil }) + if mustAllow(t, a, "ssrr-retry-token", modifiedCluster("cluster-1")) { + t.Fatal("first fallback SSAR deny") + } + if mustAllow(t, a, "ssrr-retry-token", modifiedCluster("cluster-2")) { + t.Fatal("second deny-all") + } + ssar, ssrr := rec.snapshot() + if len(ssrr) != 2 { + t.Fatalf("ssrr %d want 2", len(ssrr)) + } + if countVerb(ssar, "get") != 1 { + t.Fatalf("only first call should SSAR get, got %v", ssar) + } +} + +func TestSSRRDenyAllNamespaceSkipsGet(t *testing.T) { + rec := &authRec{} + client := newAuthClient(rec, func(string) (authzv1.SubjectRulesReviewStatus, error) { + return emptyRules(), nil + }, func(ssarCall) bool { return true }) + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { return client, nil }) + if mustAllow(t, a, "none-ns-token", modifiedNS("default")) { + t.Fatal("expected deny") + } + ssar, _ := rec.snapshot() + if countVerb(ssar, "get") != 0 { + t.Fatalf("gets %v", ssar) + } +} diff --git a/backend/internal/events/hub/access_test.go b/backend/internal/events/hub/access_test.go index 3cc0d4c5ae7..cf5f6a8985d 100644 --- a/backend/internal/events/hub/access_test.go +++ b/backend/internal/events/hub/access_test.go @@ -45,10 +45,28 @@ func TestAllowControlAndDeleted(t *testing.T) { } } +func incompletePodRules() authzv1.SubjectRulesReviewStatus { + return authzv1.SubjectRulesReviewStatus{ + Incomplete: true, + ResourceRules: []authzv1.ResourceRule{{ + Verbs: []string{"get"}, + APIGroups: []string{""}, + Resources: []string{"pods"}, + }}, + } +} + +func attachIncompleteRules(client *fake.Clientset) { + client.PrependReactor("create", "selfsubjectrulesreviews", func(action ktesting.Action) (bool, runtime.Object, error) { + return true, &authzv1.SelfSubjectRulesReview{Status: incompletePodRules()}, nil + }) +} + func TestSSARCascadeListThenGetNamespace(t *testing.T) { var verbs []string var namespaces []string client := fake.NewSimpleClientset() + attachIncompleteRules(client) client.PrependReactor("create", "selfsubjectaccessreviews", func(action ktesting.Action) (bool, runtime.Object, error) { create := action.(ktesting.CreateAction) review := create.GetObject().(*authzv1.SelfSubjectAccessReview) @@ -78,6 +96,7 @@ func TestSSARNamespacedListThenGet(t *testing.T) { var namespaces []string var names []string client := fake.NewSimpleClientset() + attachIncompleteRules(client) client.PrependReactor("create", "selfsubjectaccessreviews", func(action ktesting.Action) (bool, runtime.Object, error) { create := action.(ktesting.CreateAction) review := create.GetObject().(*authzv1.SelfSubjectAccessReview) @@ -300,3 +319,51 @@ func TestAllowUnknownTypeDenied(t *testing.T) { t.Fatalf("ok=%v err=%v", ok, err) } } + +func TestSSARPerTokenEntryCap(t *testing.T) { + orig := accessCacheMaxEntriesPerToken + accessCacheMaxEntriesPerToken = 3 + t.Cleanup(func() { accessCacheMaxEntriesPerToken = orig }) + + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { + return fake.NewSimpleClientset(), nil + }) + th := hashToken("cap-token") + a.mu.Lock() + st := a.ensureTokenLocked(th) + now := time.Now().Add(time.Hour) + for i := 0; i < 5; i++ { + st.entries[ssarKey{name: string(rune('a' + i))}] = cacheEntry{ + allowed: false, + expiry: now.Add(time.Duration(i) * time.Second), + } + } + st.enforceEntryCap() + n := len(st.entries) + a.mu.Unlock() + if n != 3 { + t.Fatalf("entries %d want 3", n) + } +} + +func TestAccessCacheHashesToken(t *testing.T) { + client := fake.NewSimpleClientset() + client.PrependReactor("create", "selfsubjectaccessreviews", func(action ktesting.Action) (bool, runtime.Object, error) { + return true, &authzv1.SelfSubjectAccessReview{ + Status: authzv1.SubjectAccessReviewStatus{Allowed: true}, + }, nil + }) + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { return client, nil }) + const raw = "raw-jwt-token" + if _, err := a.Allow(context.Background(), raw, modifiedNS("default")); err != nil { + t.Fatal(err) + } + a.mu.Lock() + defer a.mu.Unlock() + if _, ok := a.byToken[raw]; ok { + t.Fatal("raw token must not be a cache key") + } + if _, ok := a.byToken[hashToken(raw)]; !ok { + t.Fatal("expected hashed token key") + } +} diff --git a/backend/internal/informers/specs.go b/backend/internal/informers/specs.go index 384477da6a8..1fb32ad98a0 100644 --- a/backend/internal/informers/specs.go +++ b/backend/internal/informers/specs.go @@ -8,6 +8,7 @@ package informers import ( "sort" "strings" + "sync" ) // WatchSpec is one events.ts definition (selectors included). @@ -18,6 +19,7 @@ type WatchSpec struct { FieldSelector map[string]string Polled bool ForwardEventsToClients bool + ClusterScoped bool } func (s WatchSpec) SpecKey() string { @@ -65,6 +67,11 @@ func (s WatchSpec) cacheOnly() WatchSpec { return s } +func (s WatchSpec) cluster() WatchSpec { + s.ClusterScoped = true + return s +} + // ShouldForward is true when watch events should be fanned out to GET /events clients. func (s WatchSpec) ShouldForward() bool { return s.ForwardEventsToClients && !s.Polled @@ -78,13 +85,46 @@ func pairsToMap(pairs []string) map[string]string { return m } +var ( + clusterScopedOnce sync.Once + clusterScopedKinds map[string]struct{} +) + +func initClusterScopedKinds() { + clusterScopedOnce.Do(func() { + clusterScopedKinds = map[string]struct{}{} + for _, spec := range DefaultWatchSpecs() { + if spec.ClusterScoped { + clusterScopedKinds[spec.Kind] = struct{}{} + } + } + }) +} + +// ClusterScopedKinds is the set of watch kinds marked cluster-scoped (Node CLUSTER_SCOPED_KINDS). +func ClusterScopedKinds() map[string]struct{} { + initClusterScopedKinds() + out := make(map[string]struct{}, len(clusterScopedKinds)) + for k, v := range clusterScopedKinds { + out[k] = v + } + return out +} + +// IsClusterScopedKind reports whether kind is cluster-scoped in DefaultWatchSpecs. +func IsClusterScopedKind(kind string) bool { + initClusterScopedKinds() + _, ok := clusterScopedKinds[kind] + return ok +} + // DefaultWatchSpecs is the source of truth for hub list/watch specs (GET /events, POST /aggregate). func DefaultWatchSpecs() []WatchSpec { return []WatchSpec{ - watch("ClusterManagementAddOn", "addon.open-cluster-management.io/v1alpha1"), + watch("ClusterManagementAddOn", "addon.open-cluster-management.io/v1alpha1").cluster(), watch("ManagedClusterAddOn", "addon.open-cluster-management.io/v1alpha1"), watch("Agent", "agent-install.openshift.io/v1beta1"), - watch("AgentServiceConfig", "agent-install.openshift.io/v1beta1"), + watch("AgentServiceConfig", "agent-install.openshift.io/v1beta1").cluster(), watch("InfraEnv", "agent-install.openshift.io/v1beta1"), watch("NMStateConfig", "agent-install.openshift.io/v1beta1"), watch("Application", "app.k8s.io/v1beta1"), @@ -96,32 +136,32 @@ func DefaultWatchSpecs() []WatchSpec { watch("Application", "argoproj.io/v1alpha1").polled(), watch("ApplicationSet", "argoproj.io/v1alpha1").polled(), watch("ArgoCD", "argoproj.io/v1alpha1"), - watch("Authentication", "config.openshift.io/v1").cacheOnly(), + watch("Authentication", "config.openshift.io/v1").cacheOnly().cluster(), watch("MultiClusterHub", "operator.open-cluster-management.io/v1").cacheOnly(), - watch("Infrastructure", "config.openshift.io/v1"), - watch("CertificateSigningRequest", "certificates.k8s.io/v1").labels("open-cluster-management.io/cluster-name", ""), - watch("ManagedCluster", "cluster.open-cluster-management.io/v1"), + watch("Infrastructure", "config.openshift.io/v1").cluster(), + watch("CertificateSigningRequest", "certificates.k8s.io/v1").labels("open-cluster-management.io/cluster-name", "").cluster(), + watch("ManagedCluster", "cluster.open-cluster-management.io/v1").cluster(), watch("Placement", "cluster.open-cluster-management.io/v1beta1"), watch("PlacementDecision", "cluster.open-cluster-management.io/v1beta1"), watch("ManagedClusterSetBinding", "cluster.open-cluster-management.io/v1beta2"), - watch("ManagedClusterSet", "cluster.open-cluster-management.io/v1beta2"), + watch("ManagedClusterSet", "cluster.open-cluster-management.io/v1beta2").cluster(), watch("ClusterCurator", "cluster.open-cluster-management.io/v1beta1"), watch("Subscription", "operators.coreos.com/v1alpha1"), - watch("ClusterExtension", "olm.operatorframework.io/v1"), + watch("ClusterExtension", "olm.operatorframework.io/v1").cluster(), watch("DiscoveredCluster", "discovery.open-cluster-management.io/v1"), watch("DiscoveryConfig", "discovery.open-cluster-management.io/v1"), watch("AgentClusterInstall", "extensions.hive.openshift.io/v1beta1"), watch("ClusterClaim", "hive.openshift.io/v1"), watch("ClusterDeployment", "hive.openshift.io/v1"), - watch("ClusterImageSet", "hive.openshift.io/v1"), + watch("ClusterImageSet", "hive.openshift.io/v1").cluster(), watch("ClusterPool", "hive.openshift.io/v1"), watch("ClusterProvision", "hive.openshift.io/v1"), watch("MachinePool", "hive.openshift.io/v1"), watch("ManagedClusterInfo", "internal.open-cluster-management.io/v1beta1"), watch("BareMetalHost", "metal3.io/v1alpha1"), - watch("MultiClusterEngine", "multicluster.openshift.io/v1"), - watch("ClusterVersion", "config.openshift.io/v1"), - watch("StorageClass", "storage.k8s.io/v1"), + watch("MultiClusterEngine", "multicluster.openshift.io/v1").cluster(), + watch("ClusterVersion", "config.openshift.io/v1").cluster(), + watch("StorageClass", "storage.k8s.io/v1").cluster(), watch("PlacementBinding", "policy.open-cluster-management.io/v1"), watch("Policy", "policy.open-cluster-management.io/v1"), watch("PolicyAutomation", "policy.open-cluster-management.io/v1beta1"), @@ -132,7 +172,7 @@ func DefaultWatchSpecs() []WatchSpec { watch("ConfigMap", "v1").fields("metadata.name", "assisted-service"), watch("ConfigMap", "v1").fields("metadata.namespace", "openshift-config-managed", "metadata.name", "console-public"), watch("ConfigMap", "v1").fields("metadata.name", "console-search-config"), - watch("Namespace", "v1"), + watch("Namespace", "v1").cluster(), watch("Secret", "v1").labels("cluster.open-cluster-management.io/credentials", ""), watch("Secret", "v1").labels("cluster.open-cluster-management.io/type", "ans"), watch("Secret", "v1").fields("metadata.name", "auto-import-secret"), @@ -146,8 +186,8 @@ func DefaultWatchSpecs() []WatchSpec { watch("ConfigMap", "v1").fields("metadata.name", "grafana-dashboard-acm-openshift-virtualization-clusters-overview"), watch("ConfigMap", "v1").fields("metadata.name", "grafana-dashboard-acm-openshift-virtualization-single-vm-view"), watch("MulticlusterRoleAssignment", "rbac.open-cluster-management.io/v1beta1"), - watch("User", "user.openshift.io/v1"), - watch("Group", "user.openshift.io/v1"), + watch("User", "user.openshift.io/v1").cluster(), + watch("Group", "user.openshift.io/v1").cluster(), watch("Service", "v1").fields("metadata.name", "cluster-proxy-addon-user", "metadata.namespace", "multicluster-engine"), } } diff --git a/backend/internal/informers/specs_test.go b/backend/internal/informers/specs_test.go index d1a26e58465..2edd21e0764 100644 --- a/backend/internal/informers/specs_test.go +++ b/backend/internal/informers/specs_test.go @@ -39,7 +39,8 @@ func TestWatchSpecBuilders(t *testing.T) { labels("cluster.open-cluster-management.io/type", "ans"). fields("metadata.name", "auto-import-secret"). polled(). - cacheOnly() + cacheOnly(). + cluster() if s.LabelSelector["cluster.open-cluster-management.io/type"] != "ans" { t.Fatal("labels") } @@ -49,6 +50,9 @@ func TestWatchSpecBuilders(t *testing.T) { if !s.Polled || s.ForwardEventsToClients { t.Fatal("polled/cacheOnly") } + if !s.ClusterScoped { + t.Fatal("cluster") + } key := s.SpecKey() want := "v1|Secret|cluster.open-cluster-management.io/type=ans|metadata.name=auto-import-secret" if key != want { @@ -112,3 +116,31 @@ func TestSelectorQueryOrder(t *testing.T) { t.Fatal(got) } } + +func TestClusterScopedKindsMatchNode(t *testing.T) { + var n int + for _, spec := range DefaultWatchSpecs() { + if spec.ClusterScoped { + n++ + } + } + if n != 15 { + t.Fatalf("cluster-scoped specs=%d want 15", n) + } + for _, kind := range []string{ + "ManagedCluster", "Namespace", "StorageClass", "User", "Group", + "ClusterManagementAddOn", "AgentServiceConfig", "Authentication", + "Infrastructure", "CertificateSigningRequest", "ManagedClusterSet", + "ClusterExtension", "ClusterImageSet", "MultiClusterEngine", "ClusterVersion", + } { + if !IsClusterScopedKind(kind) { + t.Fatalf("%s should be cluster-scoped", kind) + } + } + if IsClusterScopedKind("Secret") || IsClusterScopedKind("Placement") || IsClusterScopedKind("ManagedClusterInfo") { + t.Fatal("namespaced kinds must not be cluster-scoped") + } + if IsClusterScopedKind("ClusterRole") { + t.Fatal("ClusterRole is served by /events/rbac, not DefaultWatchSpecs") + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4180b34ca39..d0754194066 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -41,7 +41,7 @@ All REST calls use the token passed from the console frontend. Standalone login (`GET /login`, `/login/callback`, `/logout`) is served by the Go listener in non-production. `GET /configure` returns `{ token_endpoint }` from OAuth/OIDC discovery for frontend logout and the Display Token page. The cookie `acm-access-token-cookie` (HttpOnly, Path=/, Secure in production) holds the OpenShift access token or OIDC id_token. Production plugin mode continues to use OpenShift Console authentication. -The Go listener runs a client-go informer cache (`backend/internal/informers`) from `DefaultWatchSpecs()` in `backend/internal/informers/specs.go`. `GET /events` is served by Go (`backend/internal/events/hub`) with per-user SelfSubjectAccessReview filtering (60s cache). `POST /aggregate/{applications,statuses,appSetData}` is served by Go (`backend/internal/aggregate`) from that cache plus an in-cluster Search GraphQL client (service-account token). `POST /proxy/search` and the Search graphql-ws relay are served by Go (`backend/internal/searchproxy`) with the user token. ROSA wizard, `POST /ansibletower`, `POST /placement-debug`, and `POST /upgrade-risks-prediction` are served by Go (`backend/internal/rosa`, `ansibletower`, `placementdebug`, `upgraderisks`). The Go store holds `unstructured.Unstructured` (managedFields stripped except Policy). Development builds expose `GET /debug/informer-snapshot`. `GET /events/rbac` remains a separate ClusterRole informer. +The Go listener runs a client-go informer cache (`backend/internal/informers`) from `DefaultWatchSpecs()` in `backend/internal/informers/specs.go`. `GET /events` is served by Go (`backend/internal/events/hub`) with per-user RBAC: cluster `list` SSAR, then SelfSubjectRulesReview, then SSAR fallback (60s cache). `POST /aggregate/{applications,statuses,appSetData}` is served by Go (`backend/internal/aggregate`) from that cache plus an in-cluster Search GraphQL client (service-account token). `POST /proxy/search` and the Search graphql-ws relay are served by Go (`backend/internal/searchproxy`) with the user token. ROSA wizard, `POST /ansibletower`, `POST /placement-debug`, and `POST /upgrade-risks-prediction` are served by Go (`backend/internal/rosa`, `ansibletower`, `placementdebug`, `upgraderisks`). The Go store holds `unstructured.Unstructured` (managedFields stripped except Policy). Development builds expose `GET /debug/informer-snapshot`. `GET /events/rbac` remains a separate ClusterRole informer. DELETED resource events are sent to every SSE client without an access check. That is a known quirk to fix later.