package auth import ( "context" "log" "slices" "sort" "strings" "sync" "time" authv1 "k8s.io/api/authorization/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" ) // UserPermissions holds cached permission data for an authenticated user. // // AllowedNamespaces describes namespace-list scope for *namespaced* resources: // // - nil: user can list namespaced resources cluster-wide (no per-namespace // filter required at read time) // - []: user has no namespace access — every read returns empty // - non-empty: user can read only the listed namespaces // // AllowedNamespaces does NOT imply anything about cluster-scoped resources. // A user can have cluster-wide pod visibility (AllowedNamespaces == nil) and // still lack permission to list Nodes, PVs, ClusterRoles, or any specific // CRD. Cluster-scoped reads MUST be gated separately via per-kind SAR; use // the canI map below. type UserPermissions struct { AllowedNamespaces []string // nil = all namespaces; see doc above ExpiresAt time.Time // ContextName stamps the K8s context this entry was computed under. // PermissionCache.Get refuses entries whose stamp doesn't match the // cache's current context — this closes the cross-cluster authorization // window between client-swap (PerformContextSwitch step 2) and // post-switch invalidation. Without the stamp, an in-flight request // mid-swap reads new-cluster data through old-cluster AllowedNamespaces. ContextName string // canI caches per-(verb, group, resource, namespace) SubjectAccessReview // results for this user. Used by the per-kind authorization gate that // covers cluster-scoped reads (and any other check beyond the namespace- // list discovery). Empty namespace = cluster-scoped check. canIMu sync.RWMutex canI map[string]bool } // canIKey builds the cache key for SAR results. func canIKey(verb, group, resource, namespace string) string { return verb + "\x00" + group + "\x00" + resource + "\x00" + namespace } // CanI returns the cached SAR result for this (verb, group, resource, // namespace) tuple. The second return is true when the entry exists; false // means the caller should run a fresh SAR and call SetCanI. func (p *UserPermissions) CanI(verb, group, resource, namespace string) (bool, bool) { p.canIMu.RLock() defer p.canIMu.RUnlock() if p.canI == nil { return false, false } v, ok := p.canI[canIKey(verb, group, resource, namespace)] return v, ok } // SetCanI stores a SAR result. Bound by the same TTL as the parent // UserPermissions entry — when PermissionCache evicts the user, the SAR // cache evaporates with it. func (p *UserPermissions) SetCanI(verb, group, resource, namespace string, allowed bool) { p.canIMu.Lock() defer p.canIMu.Unlock() if p.canI == nil { p.canI = make(map[string]bool) } p.canI[canIKey(verb, group, resource, namespace)] = allowed } // PermissionCache caches per-user permission lookups (thread-safe) type PermissionCache struct { mu sync.RWMutex cache map[string]*UserPermissions // keyed by cacheKey(username, groups) ttl time.Duration contextName func() string // current K8s context; entries with a different stamp are invisible } // cacheKeySep separates the username from the groups fingerprint in a cache // key. It is a control byte that cannot appear in a Kubernetes username or // group name, so a username's entries remain enumerable by splitting on it. const cacheKeySep = "\x00" // cacheKey builds the composite cache key for an identity. The verdicts stored // in a UserPermissions entry are computed by SubjectAccessReviews that run with // the username AND its groups, so an entry is only valid for that exact // (username, groups) identity — keying by username alone lets a different group // set inherit another identity's RBAC ceiling for the cache TTL. Groups are // canonicalized (sorted + deduped) so order and duplicates don't fork the key; // empty/nil groups is a valid, distinct identity (not a wildcard). func cacheKey(username string, groups []string) string { return username + cacheKeySep + groupsFingerprint(groups) } // IdentityCacheKey is cacheKey for callers outside this package that cache // per-user SubjectAccessReview results. func IdentityCacheKey(username string, groups []string) string { return cacheKey(username, groups) } // groupsFingerprint returns a deterministic fingerprint of a group set: // sorted, deduped, and joined. Empty-string elements are dropped (an empty // group is not a real principal), so nil, [], [""], and ["", ""] all // fingerprint to "" — one identity. The proxy path already trims empties, but // the OIDC path (internal/auth/oidc.go) does not; dropping them here makes the // no-groups collision explicit rather than accidental. func groupsFingerprint(groups []string) string { if len(groups) == 0 { return "" } uniq := make([]string, 0, len(groups)) seen := make(map[string]struct{}, len(groups)) for _, g := range groups { if g == "" { continue // empty group is not a real principal } if _, ok := seen[g]; ok { continue } seen[g] = struct{}{} uniq = append(uniq, g) } sort.Strings(uniq) return strings.Join(uniq, cacheKeySep) } // NewPermissionCache creates a new permission cache with a 2-minute TTL. // Use WithContextName to wire cluster-identity stamping; without it, every // entry stamps as "" and the stamp check is a no-op (test-friendly default). func NewPermissionCache() *PermissionCache { return &PermissionCache{ cache: make(map[string]*UserPermissions), ttl: 2 * time.Minute, contextName: func() string { return "" }, } } // WithContextName wires the cache to a context-name provider so entries // can be stamped at write time and verified at read time. Production code // passes k8s.GetContextName so a request straddling a context switch // can't be authorized by the previous cluster's RBAC. func (pc *PermissionCache) WithContextName(provider func() string) *PermissionCache { if provider == nil { provider = func() string { return "" } } pc.mu.Lock() defer pc.mu.Unlock() pc.contextName = provider return pc } // Get returns cached permissions for a user, or nil if not cached / expired // / stamped with a different context than the current one. func (pc *PermissionCache) Get(username string, groups []string) *UserPermissions { pc.mu.RLock() defer pc.mu.RUnlock() perms, ok := pc.cache[cacheKey(username, groups)] if !ok || time.Now().After(perms.ExpiresAt) { return nil } if pc.contextName != nil && perms.ContextName != pc.contextName() { // Entry was computed against a different cluster — refuse it // rather than authorizing the current cluster's reads with // the previous cluster's RBAC. return nil } return perms } // Set stores permissions for a user. Stamps the entry with the cache's // current context so cross-cluster requests can't see it. Opportunistically // evicts expired entries so a long-running deploy with churning users // doesn't accumulate. func (pc *PermissionCache) Set(username string, groups []string, perms *UserPermissions) { pc.mu.Lock() defer pc.mu.Unlock() now := time.Now() perms.ExpiresAt = now.Add(pc.ttl) if pc.contextName != nil { perms.ContextName = pc.contextName() } key := cacheKey(username, groups) pc.cache[key] = perms for k, p := range pc.cache { if k == key { continue } if now.After(p.ExpiresAt) { delete(pc.cache, k) } } } // Invalidate removes all cached permissions (e.g., on context switch) func (pc *PermissionCache) Invalidate() { pc.mu.Lock() defer pc.mu.Unlock() pc.cache = make(map[string]*UserPermissions) } // DiscoverNamespaces checks which namespaces a user can access via SubjectAccessReview. // Returns nil for "all namespaces" (cluster admin), empty slice for "no access". func DiscoverNamespaces(ctx context.Context, client kubernetes.Interface, username string, groups []string, allNamespaces []string) ([]string, error) { // Step 1: Check cluster-wide "list pods" — if allowed, user is effectively cluster-admin allowed, err := SubjectCanI(ctx, client, username, groups, "", "", "pods", "list") if err != nil { log.Printf("[auth] DiscoverNamespaces: cluster-scope SAR failed for %s: %v", username, err) return nil, err } log.Printf("[auth] DiscoverNamespaces: user=%s cluster-scope list pods = %v", username, allowed) if allowed { return nil, nil // nil = all namespaces } // Step 2: Parallel check each namespace for "list pods" OR "list deployments". // // Per-namespace SAR errors are propagated. The caller must NOT cache an // empty/partial result on error — a transient apiserver hiccup would // otherwise lock the user out for the cache TTL window. type nsResult struct { namespace string allowed bool err error } results := make(chan nsResult, len(allNamespaces)) sem := make(chan struct{}, 10) // limit to 10 concurrent checks var wg sync.WaitGroup for _, ns := range allNamespaces { wg.Add(1) go func(namespace string) { defer wg.Done() sem <- struct{}{} defer func() { <-sem }() ok, err := SubjectCanI(ctx, client, username, groups, namespace, "", "pods", "list") if err != nil { results <- nsResult{namespace: namespace, err: err} return } if ok { results <- nsResult{namespace: namespace, allowed: true} return } ok, err = SubjectCanI(ctx, client, username, groups, namespace, "apps", "deployments", "list") if err != nil { results <- nsResult{namespace: namespace, err: err} return } results <- nsResult{namespace: namespace, allowed: ok} }(ns) } go func() { wg.Wait() close(results) }() allowedNS := make([]string, 0) // empty (not nil) = no access; nil = all namespaces var firstErr error for r := range results { if r.err != nil { if firstErr == nil { firstErr = r.err } continue } if r.allowed { allowedNS = append(allowedNS, r.namespace) } } if firstErr != nil { log.Printf("[auth] DiscoverNamespaces: per-namespace SAR errored for %s: %v (returning err to skip cache)", username, firstErr) return nil, firstErr } return allowedNS, nil } // ReviewSubjectAccess asks the apiserver authorizer whether a subject can // perform the action described by attrs. func ReviewSubjectAccess(ctx context.Context, client kubernetes.Interface, username string, groups []string, attrs authv1.ResourceAttributes) (authv1.SubjectAccessReviewStatus, error) { review := &authv1.SubjectAccessReview{ Spec: authv1.SubjectAccessReviewSpec{ User: username, Groups: groups, ResourceAttributes: &attrs, }, } result, err := client.AuthorizationV1().SubjectAccessReviews().Create(ctx, review, metav1.CreateOptions{}) if err != nil { return authv1.SubjectAccessReviewStatus{}, err } return result.Status, nil } // SubjectCanI performs a SubjectAccessReview (not SelfSubject) to check if a specific // user can perform an action. This uses the ServiceAccount's permissions to check // on behalf of the user. func SubjectCanI(ctx context.Context, client kubernetes.Interface, username string, groups []string, namespace, group, resource, verb string) (bool, error) { return SubjectCanISubresource(ctx, client, username, groups, namespace, group, resource, "", verb) } // SubjectCanISubresource performs the same impersonated authorization check // for an exact resource/subresource tuple such as nodes/proxy. func SubjectCanISubresource(ctx context.Context, client kubernetes.Interface, username string, groups []string, namespace, group, resource, subresource, verb string) (bool, error) { status, err := ReviewSubjectAccess(ctx, client, username, groups, authv1.ResourceAttributes{ Namespace: namespace, Group: group, Resource: resource, Subresource: subresource, Verb: verb, }) if err != nil { log.Printf("[auth] SubjectAccessReview failed for user=%s %s %s/%s: %v", username, verb, group, resource, err) return false, err } return status.Allowed, nil } // FilterNamespacesForUser intersects requested namespaces with user's allowed namespaces. // // - user == nil: caller is unauthenticated/system; returns requested unchanged. // - user != nil, perms == nil: caller forgot to resolve permissions. Fail // closed (return empty) rather than passing through unfiltered — a missing // perms lookup must never widen access. // - perms.AllowedNamespaces == nil: user has cluster-wide access; returns // requested unchanged. // - requested empty: returns a copy of the user's allowed namespaces, so // callers can't mutate the cached slice. func FilterNamespacesForUser(requested []string, user *User, perms *UserPermissions) []string { if user == nil { return requested } if perms == nil { return []string{} } if perms.AllowedNamespaces == nil { return requested } allowedSet := make(map[string]bool, len(perms.AllowedNamespaces)) for _, ns := range perms.AllowedNamespaces { allowedSet[ns] = true } if len(requested) == 0 { return slices.Clone(perms.AllowedNamespaces) } result := make([]string, 0) for _, ns := range requested { if allowedSet[ns] { result = append(result, ns) } } return result }