Ctrl K
ComunidadGuías…

pkg/auth/permissions.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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
}