pkg/auth/permissions_test.go
Rama main · réplica de lectura del repositorio original.
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)
}
}