package auth import ( "context" "errors" "reflect" "testing" "time" authv1 "k8s.io/api/authorization/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes/fake" k8stesting "k8s.io/client-go/testing" ) // --- FilterNamespacesForUser tests (pure logic, no K8s client) --- func TestFilterNamespacesForUser_NilUser(t *testing.T) { requested := []string{"default", "kube-system"} got := FilterNamespacesForUser(requested, nil, nil) if len(got) != 2 { t.Errorf("nil user should pass through: got %v", got) } } func TestFilterNamespacesForUser_NilPerms_FailsClosed(t *testing.T) { requested := []string{"default"} user := &User{Username: "alice"} got := FilterNamespacesForUser(requested, user, nil) if len(got) != 0 { t.Errorf("nil perms with non-nil user must fail closed: got %v, want empty", got) } } func TestFilterNamespacesForUser_NilUser_PassesThrough(t *testing.T) { requested := []string{"default", "prod"} got := FilterNamespacesForUser(requested, nil, nil) if len(got) != 2 { t.Errorf("nil user is the unauthenticated/system path: got %v, want passthrough", got) } } func TestFilterNamespacesForUser_AllNamespaces_ReturnsClone(t *testing.T) { user := &User{Username: "alice"} perms := &UserPermissions{AllowedNamespaces: []string{"a", "b"}} got := FilterNamespacesForUser(nil, user, perms) if len(got) != 2 { t.Fatalf("expected 2 namespaces, got %v", got) } got[0] = "tampered" if perms.AllowedNamespaces[0] != "a" { t.Errorf("FilterNamespacesForUser returned aliased slice: cached AllowedNamespaces[0] = %q, want %q", perms.AllowedNamespaces[0], "a") } } func TestFilterNamespacesForUser_ClusterAdmin(t *testing.T) { requested := []string{"default", "prod"} user := &User{Username: "admin"} perms := &UserPermissions{AllowedNamespaces: nil} // nil = cluster admin got := FilterNamespacesForUser(requested, user, perms) if len(got) != 2 { t.Errorf("cluster admin should see all requested: got %v", got) } } func TestFilterNamespacesForUser_Intersection(t *testing.T) { requested := []string{"default", "prod", "staging"} user := &User{Username: "alice"} perms := &UserPermissions{AllowedNamespaces: []string{"default", "staging"}} got := FilterNamespacesForUser(requested, user, perms) if len(got) != 2 { t.Errorf("expected 2 namespaces, got %v", got) } allowed := map[string]bool{} for _, ns := range got { allowed[ns] = true } if !allowed["default"] || !allowed["staging"] { t.Errorf("expected default and staging, got %v", got) } if allowed["prod"] { t.Error("prod should not be in result") } } func TestFilterNamespacesForUser_NoOverlap(t *testing.T) { requested := []string{"prod"} user := &User{Username: "alice"} perms := &UserPermissions{AllowedNamespaces: []string{"default", "staging"}} got := FilterNamespacesForUser(requested, user, perms) if got == nil { t.Error("result should be empty slice (not nil) for no access") } if len(got) != 0 { t.Errorf("expected empty result, got %v", got) } } func TestFilterNamespacesForUser_NoRequestedReturnsAllAllowed(t *testing.T) { user := &User{Username: "alice"} perms := &UserPermissions{AllowedNamespaces: []string{"dev", "staging"}} got := FilterNamespacesForUser(nil, user, perms) if len(got) != 2 { t.Errorf("empty request should return all allowed: got %v", got) } } func TestFilterNamespacesForUser_EmptyAllowed(t *testing.T) { requested := []string{"default"} user := &User{Username: "alice"} perms := &UserPermissions{AllowedNamespaces: []string{}} // empty = no access got := FilterNamespacesForUser(requested, user, perms) if len(got) != 0 { t.Errorf("empty allowed should give no access: got %v", got) } } // --- PermissionCache tests --- func TestPermissionCache_SetAndGet(t *testing.T) { pc := NewPermissionCache() perms := &UserPermissions{AllowedNamespaces: []string{"default"}} pc.Set("alice", nil, perms) got := pc.Get("alice", nil) if got == nil { t.Fatal("expected cached perms, got nil") } if len(got.AllowedNamespaces) != 1 || got.AllowedNamespaces[0] != "default" { t.Errorf("cached perms = %v, want [default]", got.AllowedNamespaces) } } func TestPermissionCache_Miss(t *testing.T) { pc := NewPermissionCache() got := pc.Get("nonexistent", nil) if got != nil { t.Error("expected nil for cache miss") } } // TestPermissionCache_GroupScopedKey pins the group-identity fix: the cache // key must include the caller's groups, not just the username. The SARs that // produce a UserPermissions entry run with username AND groups, so the same // username arriving with a different group set (proxy header change, OIDC // role change mid-TTL) must NOT inherit the first request's RBAC verdicts. func TestPermissionCache_GroupScopedKey(t *testing.T) { pc := NewPermissionCache() // Entry produced by SARs run for alice WITH groups [platform-admins]: // cluster-wide namespace access (AllowedNamespaces == nil). pc.Set("alice", []string{"platform-admins"}, &UserPermissions{AllowedNamespaces: nil}) // A request arrives as alice but WITH groups [viewers]. It must be a // MISS — the weaker identity must not read the admin entry. if got := pc.Get("alice", []string{"viewers"}); got != nil { t.Fatalf("cross-identity leak: alice/[viewers] inherited alice/[platform-admins] entry: %+v", got) } // Same username + SAME groups must still HIT. if got := pc.Get("alice", []string{"platform-admins"}); got == nil { t.Fatal("same username + same groups should hit, got nil") } // Group order and duplicates must not matter (canonical fingerprint). pc.Set("bob", []string{"a", "b"}, &UserPermissions{AllowedNamespaces: []string{"default"}}) if got := pc.Get("bob", []string{"b", "a", "a"}); got == nil { t.Fatal("reordered/duplicated groups should map to the same key, got nil") } // Empty/nil groups is a valid distinct identity, not a wildcard. pc.Set("carol", nil, &UserPermissions{AllowedNamespaces: []string{"kube-system"}}) if got := pc.Get("carol", []string{"admins"}); got != nil { t.Fatalf("no-groups entry must not be read by a grouped identity: %+v", got) } if got := pc.Get("carol", nil); got == nil { t.Fatal("no-groups entry must hit for the no-groups identity, got nil") } // Empty-string group elements are dropped: [""] canonicalizes to [] and // ["a", ""] to ["a"]. An empty group is not a real principal, and the OIDC // path doesn't trim empties like the proxy path does — pin the collision so // it's explicit, not accidental. if cacheKey("u", nil) != cacheKey("u", []string{""}) { t.Error(`cacheKey("u", nil) must equal cacheKey("u", [""]) — empty group is dropped`) } if cacheKey("u", []string{"a"}) != cacheKey("u", []string{"a", ""}) { t.Error(`cacheKey("u", ["a"]) must equal cacheKey("u", ["a", ""]) — empty group is dropped`) } // Dropping empties must not collapse distinct real group sets. if cacheKey("u", []string{"a", ""}) == cacheKey("u", []string{"a", "b"}) { t.Error(`cacheKey("u", ["a", ""]) must differ from cacheKey("u", ["a", "b"]) — "b" is a real principal`) } } func TestPermissionCache_Expiry(t *testing.T) { pc := &PermissionCache{ cache: make(map[string]*UserPermissions), ttl: 1 * time.Millisecond, // very short TTL } perms := &UserPermissions{AllowedNamespaces: []string{"default"}} pc.Set("alice", nil, perms) time.Sleep(5 * time.Millisecond) got := pc.Get("alice", nil) if got != nil { t.Error("expected nil for expired cache entry") } } func TestPermissionCache_Invalidate(t *testing.T) { pc := NewPermissionCache() pc.Set("alice", nil, &UserPermissions{AllowedNamespaces: []string{"default"}}) pc.Set("bob", nil, &UserPermissions{AllowedNamespaces: []string{"staging"}}) pc.Invalidate() if pc.Get("alice", nil) != nil || pc.Get("bob", nil) != nil { t.Error("Invalidate should clear all entries") } } // --- DiscoverNamespaces tests (with fake K8s client) --- // fakeClientWithSAR creates a fake K8s client that responds to SubjectAccessReview // based on the allowFunc. allowFunc receives (username, namespace, resource, verb) // and returns whether the action is allowed. func fakeClientWithSAR(allowFunc func(username, namespace, resource, verb string) bool) *fake.Clientset { client := fake.NewClientset() client.PrependReactor("create", "subjectaccessreviews", func(action k8stesting.Action) (bool, runtime.Object, error) { sar := action.(k8stesting.CreateAction).GetObject().(*authv1.SubjectAccessReview) spec := sar.Spec ns := "" resource := "" verb := "" if spec.ResourceAttributes != nil { ns = spec.ResourceAttributes.Namespace resource = spec.ResourceAttributes.Resource verb = spec.ResourceAttributes.Verb } allowed := allowFunc(spec.User, ns, resource, verb) return true, &authv1.SubjectAccessReview{ ObjectMeta: metav1.ObjectMeta{Name: "test"}, Status: authv1.SubjectAccessReviewStatus{Allowed: allowed}, }, nil }) return client } func TestDiscoverNamespaces_ClusterAdmin(t *testing.T) { client := fakeClientWithSAR(func(username, namespace, resource, verb string) bool { return true // allow everything }) ns, err := DiscoverNamespaces(context.Background(), client, "admin", nil, []string{"default", "kube-system"}) if err != nil { t.Fatalf("unexpected error: %v", err) } if ns != nil { t.Errorf("cluster admin should get nil (all namespaces), got %v", ns) } } func TestDiscoverNamespaces_NamespaceScoped(t *testing.T) { client := fakeClientWithSAR(func(username, namespace, resource, verb string) bool { // Deny cluster-wide, allow only in "dev" namespace if namespace == "" { return false } return namespace == "dev" }) ns, err := DiscoverNamespaces(context.Background(), client, "alice", nil, []string{"dev", "prod", "staging"}) if err != nil { t.Fatalf("unexpected error: %v", err) } if ns == nil { t.Fatal("expected namespace list, got nil (cluster admin)") } if len(ns) != 1 || ns[0] != "dev" { t.Errorf("expected [dev], got %v", ns) } } func TestDiscoverNamespaces_MultipleNamespaces(t *testing.T) { client := fakeClientWithSAR(func(username, namespace, resource, verb string) bool { if namespace == "" { return false } return namespace == "dev" || namespace == "staging" }) ns, err := DiscoverNamespaces(context.Background(), client, "alice", nil, []string{"dev", "prod", "staging"}) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(ns) != 2 { t.Errorf("expected 2 namespaces, got %v", ns) } allowed := map[string]bool{} for _, n := range ns { allowed[n] = true } if !allowed["dev"] || !allowed["staging"] { t.Errorf("expected dev and staging, got %v", ns) } } func TestDiscoverNamespaces_NoAccess(t *testing.T) { client := fakeClientWithSAR(func(username, namespace, resource, verb string) bool { return false // deny everything }) ns, err := DiscoverNamespaces(context.Background(), client, "nobody", nil, []string{"default", "kube-system"}) if err != nil { t.Fatalf("unexpected error: %v", err) } if ns == nil { t.Fatal("expected empty slice (not nil) for no access") } if len(ns) != 0 { t.Errorf("expected empty slice, got %v", ns) } } func TestDiscoverNamespaces_DeploymentFallback(t *testing.T) { // User can't list pods but CAN list deployments in "prod" client := fakeClientWithSAR(func(username, namespace, resource, verb string) bool { if namespace == "" { return false } return namespace == "prod" && resource == "deployments" }) ns, err := DiscoverNamespaces(context.Background(), client, "alice", nil, []string{"dev", "prod"}) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(ns) != 1 || ns[0] != "prod" { t.Errorf("expected [prod] via deployment fallback, got %v", ns) } } func TestDiscoverNamespaces_EmptyNamespaceList(t *testing.T) { client := fakeClientWithSAR(func(username, namespace, resource, verb string) bool { return false }) ns, err := DiscoverNamespaces(context.Background(), client, "alice", nil, []string{}) if err != nil { t.Fatalf("unexpected error: %v", err) } // With no namespaces to check, cluster-wide check fails → empty result if ns == nil { t.Fatal("expected empty slice, got nil") } if len(ns) != 0 { t.Errorf("expected empty slice, got %v", ns) } } func TestDiscoverNamespaces_PropagatesPerNamespaceError(t *testing.T) { // When a per-namespace SAR fails (apiserver hiccup), DiscoverNamespaces // must surface the error so the caller can avoid caching the partial // result. Otherwise a brief outage would lock the user out for the // whole cache TTL window. client := fake.NewClientset() client.PrependReactor("create", "subjectaccessreviews", func(action k8stesting.Action) (bool, runtime.Object, error) { sar := action.(k8stesting.CreateAction).GetObject().(*authv1.SubjectAccessReview) spec := sar.Spec // Step 1 (cluster-wide list pods, namespace=="") succeeds with deny. if spec.ResourceAttributes.Namespace == "" { return true, &authv1.SubjectAccessReview{Status: authv1.SubjectAccessReviewStatus{Allowed: false}}, nil } // Step 2 (per-namespace) returns a transport error. return true, nil, errors.New("apiserver unreachable") }) _, err := DiscoverNamespaces(context.Background(), client, "alice", nil, []string{"dev", "prod"}) if err == nil { t.Fatal("expected error from per-namespace SAR failure, got nil") } } // --- SubjectCanI tests --- func TestReviewSubjectAccess_PreservesAttributesAndStatus(t *testing.T) { var captured authv1.SubjectAccessReviewSpec wantStatus := authv1.SubjectAccessReviewStatus{ Allowed: false, Denied: true, Reason: "resource name is not granted", EvaluationError: "one authorizer returned partial data", } client := fake.NewClientset() client.PrependReactor("create", "subjectaccessreviews", func(action k8stesting.Action) (bool, runtime.Object, error) { sar := action.(k8stesting.CreateAction).GetObject().(*authv1.SubjectAccessReview) captured = *sar.Spec.DeepCopy() return true, &authv1.SubjectAccessReview{Status: wantStatus}, nil }) attrs := authv1.ResourceAttributes{ Namespace: "prod", Verb: "get", Group: "apps.example.io", Resource: "widgets", Subresource: "status", Name: "frontend", } groups := []string{"system:authenticated", "system:serviceaccounts"} gotStatus, err := ReviewSubjectAccess(context.Background(), client, "system:serviceaccount:ops:controller", groups, attrs) if err != nil { t.Fatalf("ReviewSubjectAccess: %v", err) } if !reflect.DeepEqual(gotStatus, wantStatus) { t.Fatalf("status = %#v, want %#v", gotStatus, wantStatus) } if captured.User != "system:serviceaccount:ops:controller" || !reflect.DeepEqual(captured.Groups, groups) { t.Fatalf("subject = user %q groups %v", captured.User, captured.Groups) } if captured.ResourceAttributes == nil || !reflect.DeepEqual(*captured.ResourceAttributes, attrs) { t.Fatalf("resource attributes = %#v, want %#v", captured.ResourceAttributes, attrs) } } func TestSubjectCanI_Allowed(t *testing.T) { client := fakeClientWithSAR(func(username, namespace, resource, verb string) bool { return username == "alice" && resource == "pods" && verb == "list" }) allowed, err := SubjectCanI(context.Background(), client, "alice", nil, "default", "", "pods", "list") if err != nil { t.Fatalf("unexpected error: %v", err) } if !allowed { t.Error("expected allowed=true") } } func TestSubjectCanI_Denied(t *testing.T) { client := fakeClientWithSAR(func(username, namespace, resource, verb string) bool { return false }) allowed, err := SubjectCanI(context.Background(), client, "alice", nil, "default", "", "pods", "list") if err != nil { t.Fatalf("unexpected error: %v", err) } if allowed { t.Error("expected allowed=false") } } func TestSubjectCanI_WithGroups(t *testing.T) { var capturedGroups []string client := fake.NewClientset() client.PrependReactor("create", "subjectaccessreviews", func(action k8stesting.Action) (bool, runtime.Object, error) { sar := action.(k8stesting.CreateAction).GetObject().(*authv1.SubjectAccessReview) capturedGroups = sar.Spec.Groups return true, &authv1.SubjectAccessReview{ Status: authv1.SubjectAccessReviewStatus{Allowed: true}, }, nil }) groups := []string{"devs", "admins"} SubjectCanI(context.Background(), client, "alice", groups, "default", "", "pods", "list") if len(capturedGroups) != 2 || capturedGroups[0] != "devs" || capturedGroups[1] != "admins" { t.Errorf("groups not passed to SAR: got %v", capturedGroups) } }