package rbac import ( "slices" "testing" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) func role(name, namespace string, rules ...any) *unstructured.Unstructured { return &unstructured.Unstructured{Object: map[string]any{ "apiVersion": "rbac.authorization.k8s.io/v1", "kind": "Role", "metadata": map[string]any{"name": name, "namespace": namespace}, "rules": rules, }} } func clusterRole(name string, rules ...any) *unstructured.Unstructured { return &unstructured.Unstructured{Object: map[string]any{ "apiVersion": "rbac.authorization.k8s.io/v1", "kind": "ClusterRole", "metadata": map[string]any{"name": name}, "rules": rules, }} } func rule(verbs, groups, resources []string) map[string]any { return map[string]any{ "verbs": asAny(verbs), "apiGroups": asAny(groups), "resources": asAny(resources), } } func asAny(held []string) []any { out := make([]any, 0, len(held)) for _, one := range held { out = append(out, one) } return out } func binding(name, namespace, roleKind, roleName string, subjects ...any) *unstructured.Unstructured { kind := "RoleBinding" meta := map[string]any{"name": name, "namespace": namespace} if namespace == "" { kind = "ClusterRoleBinding" meta = map[string]any{"name": name} } return &unstructured.Unstructured{Object: map[string]any{ "apiVersion": "rbac.authorization.k8s.io/v1", "kind": kind, "metadata": meta, "roleRef": map[string]any{"kind": roleKind, "name": roleName}, "subjects": subjects, }} } func account(name, namespace string) map[string]any { return map[string]any{"kind": "ServiceAccount", "name": name, "namespace": namespace} } func user(name string) map[string]any { return map[string]any{"kind": "User", "name": name} } func holderFor(t *testing.T, index Index, label string) Holder { t.Helper() for _, one := range index.Holders { if one.Subject.Label() == label { return one } } t.Fatalf("no holder called %q in %+v", label, labels(index)) return Holder{} } func labels(index Index) []string { out := make([]string, 0, len(index.Holders)) for _, one := range index.Holders { out = append(out, one.Subject.Label()) } return out } func TestABindingBecomesASubjectThatHoldsIt(t *testing.T) { index := Build(Held{ Roles: []*unstructured.Unstructured{role("reader", "web", rule([]string{"get"}, []string{""}, []string{"pods"}))}, Bindings: []*unstructured.Unstructured{binding("read-web", "web", "Role", "reader", account("api", "web"))}, }) held := holderFor(t, index, "system:serviceaccount:web:api") if len(held.Grants) != 1 { t.Fatalf("grants = %+v", held.Grants) } if held.Grants[0].Role != "reader" || held.Grants[0].Namespace != "web" { t.Fatalf("the grant came back as %+v", held.Grants[0]) } } func TestAServiceAccountWithNoNamespaceBelongsToTheBindings(t *testing.T) { index := Build(Held{ Roles: []*unstructured.Unstructured{role("reader", "web", rule([]string{"get"}, []string{""}, []string{"pods"}))}, Bindings: []*unstructured.Unstructured{binding("read-web", "web", "Role", "reader", map[string]any{"kind": "ServiceAccount", "name": "api"})}, }) holderFor(t, index, "system:serviceaccount:web:api") } func TestAClusterRoleBindingReachesEveryNamespace(t *testing.T) { index := Build(Held{ ClusterRoles: []*unstructured.Unstructured{clusterRole("reader", rule([]string{"get"}, []string{""}, []string{"secrets"}))}, ClusterBinds: []*unstructured.Unstructured{binding("read-all", "", "ClusterRole", "reader", user("ana"))}, }) held := holderFor(t, index, "ana") if !held.Grants[0].Everywhere() { t.Fatalf("the grant was confined to %q", held.Grants[0].Namespace) } if !held.Grants[0].Reaches(Ask{Verb: "get", Resource: "secrets", Namespace: "anywhere"}) { t.Fatal("a cluster-wide grant did not reach a namespace") } } func TestARoleBindingToAClusterRoleStaysInItsNamespace(t *testing.T) { index := Build(Held{ ClusterRoles: []*unstructured.Unstructured{clusterRole("reader", rule([]string{"get"}, []string{""}, []string{"secrets"}))}, Bindings: []*unstructured.Unstructured{binding("read-web", "web", "ClusterRole", "reader", user("ana"))}, }) held := holderFor(t, index, "ana") if held.Grants[0].Everywhere() { t.Fatal("a role binding to a cluster role reached the whole cluster") } if !held.Grants[0].Reaches(Ask{Verb: "get", Resource: "secrets", Namespace: "web"}) { t.Fatal("it did not reach its own namespace") } if held.Grants[0].Reaches(Ask{Verb: "get", Resource: "secrets", Namespace: "other"}) { t.Fatal("it reached a namespace it was never bound in") } } func TestARoleIsLookedUpInItsOwnNamespace(t *testing.T) { index := Build(Held{ Roles: []*unstructured.Unstructured{ role("reader", "web", rule([]string{"get"}, []string{""}, []string{"pods"})), role("reader", "db", rule([]string{"delete"}, []string{""}, []string{"pods"})), }, Bindings: []*unstructured.Unstructured{binding("read-web", "web", "Role", "reader", user("ana"))}, }) held := holderFor(t, index, "ana") if held.Grants[0].Reaches(Ask{Verb: "delete", Resource: "pods", Namespace: "web"}) { t.Fatal("it picked up the other namespace's role of the same name") } } func TestABindingThatNamesARoleNobodyWroteIsReported(t *testing.T) { index := Build(Held{ Bindings: []*unstructured.Unstructured{binding("read-web", "web", "Role", "gone", user("ana"))}, }) if len(index.Absent) != 1 { t.Fatalf("absent = %+v", index.Absent) } held := holderFor(t, index, "ana") if !held.Grants[0].Missing { t.Fatal("the grant did not say its role is missing") } if held.Grants[0].Reaches(Ask{Verb: "get", Resource: "pods", Namespace: "web"}) { t.Fatal("a missing role granted something") } } func TestAWildcardVerbCoversTheOneAsked(t *testing.T) { one := Rule{Verbs: []string{Anything}, Groups: []string{""}, Resources: []string{"pods"}} if !one.Allows(Ask{Verb: "delete", Resource: "pods"}) { t.Fatal("a wildcard verb did not cover delete") } } func TestAWildcardResourceCoversTheOneAsked(t *testing.T) { one := Rule{Verbs: []string{"get"}, Groups: []string{Anything}, Resources: []string{Anything}} if !one.Allows(Ask{Verb: "get", Group: "apps", Resource: "deployments"}) { t.Fatal("a wildcard resource did not cover deployments") } } func TestTheApiGroupHasToMatch(t *testing.T) { one := Rule{Verbs: []string{"get"}, Groups: []string{""}, Resources: []string{"deployments"}} if one.Allows(Ask{Verb: "get", Group: "apps", Resource: "deployments"}) { t.Fatal("a core-group rule answered for an apps-group question") } } func TestPodsDoesNotCoverPodsExec(t *testing.T) { one := Rule{Verbs: []string{"create"}, Groups: []string{""}, Resources: []string{"pods"}} if one.Allows(Ask{Verb: "create", Resource: "pods/exec"}) { t.Fatal("a rule on pods answered for pods/exec") } } func TestASubresourceWildcardCoversIt(t *testing.T) { one := Rule{Verbs: []string{"create"}, Groups: []string{""}, Resources: []string{"pods/*"}} if !one.Allows(Ask{Verb: "create", Resource: "pods/exec"}) { t.Fatal("pods/* did not cover pods/exec") } } func TestARuleWithNoResourcesAllowsNothing(t *testing.T) { one := Rule{Verbs: []string{Anything}, Groups: []string{Anything}, URLs: []string{"/healthz"}} if one.Allows(Ask{Verb: "get", Resource: "pods"}) { t.Fatal("a non-resource rule answered a resource question") } } func TestWhoNamesEverySubjectThatMay(t *testing.T) { index := Build(Held{ ClusterRoles: []*unstructured.Unstructured{ clusterRole("exec", rule([]string{"create"}, []string{""}, []string{"pods/exec"})), clusterRole("look", rule([]string{"get"}, []string{""}, []string{"pods"})), }, ClusterBinds: []*unstructured.Unstructured{ binding("can-exec", "", "ClusterRole", "exec", user("ana")), binding("can-look", "", "ClusterRole", "look", user("bo")), }, }) found := index.Who(Ask{Verb: "create", Resource: "pods/exec"}) if len(found) != 1 || found[0].Subject.Name != "ana" { t.Fatalf("who = %+v", labelsOf(found)) } } func TestWhoKeepsOnlyTheGrantsThatAnswer(t *testing.T) { index := Build(Held{ ClusterRoles: []*unstructured.Unstructured{ clusterRole("exec", rule([]string{"create"}, []string{""}, []string{"pods/exec"})), clusterRole("look", rule([]string{"get"}, []string{""}, []string{"pods"})), }, ClusterBinds: []*unstructured.Unstructured{ binding("can-exec", "", "ClusterRole", "exec", user("ana")), binding("can-look", "", "ClusterRole", "look", user("ana")), }, }) found := index.Who(Ask{Verb: "create", Resource: "pods/exec"}) if len(found[0].Grants) != 1 || found[0].Grants[0].Role != "exec" { t.Fatalf("grants = %+v", found[0].Grants) } } func TestWhoInOneNamespaceLeavesOutAnother(t *testing.T) { index := Build(Held{ Roles: []*unstructured.Unstructured{ role("reader", "web", rule([]string{"get"}, []string{""}, []string{"secrets"})), role("reader", "db", rule([]string{"get"}, []string{""}, []string{"secrets"})), }, Bindings: []*unstructured.Unstructured{ binding("read-web", "web", "Role", "reader", user("ana")), binding("read-db", "db", "Role", "reader", user("bo")), }, }) found := index.Who(Ask{Verb: "get", Resource: "secrets", Namespace: "web"}) if len(found) != 1 || found[0].Subject.Name != "ana" { t.Fatalf("who = %+v", labelsOf(found)) } } func labelsOf(held []Holder) []string { out := make([]string, 0, len(held)) for _, one := range held { out = append(out, one.Subject.Label()) } return out } func TestClusterAdminIsSaidOnItsOwn(t *testing.T) { index := Build(Held{ ClusterRoles: []*unstructured.Unstructured{ clusterRole("cluster-admin", rule([]string{Anything}, []string{Anything}, []string{Anything})), }, ClusterBinds: []*unstructured.Unstructured{binding("admins", "", "ClusterRole", "cluster-admin", user("ana"))}, }) held := holderFor(t, index, "ana") if !slices.Equal(held.Powers, []string{"cluster-admin"}) { t.Fatalf("powers = %+v, want cluster-admin alone", held.Powers) } } func TestThePowersAreNamedForWhatTheyReallyReach(t *testing.T) { index := Build(Held{ ClusterRoles: []*unstructured.Unstructured{ clusterRole("ops", rule([]string{"get", "list"}, []string{""}, []string{"secrets"}), rule([]string{"create"}, []string{""}, []string{"pods/exec"})), }, ClusterBinds: []*unstructured.Unstructured{binding("ops", "", "ClusterRole", "ops", user("ana"))}, }) held := holderFor(t, index, "ana") for _, want := range []string{"reads secrets", "lists secrets", "execs into pods"} { if !slices.Contains(held.Powers, want) { t.Fatalf("powers = %+v, want %q", held.Powers, want) } } } func TestASubjectWithNothingWorthNamingSaysNothing(t *testing.T) { index := Build(Held{ Roles: []*unstructured.Unstructured{role("reader", "web", rule([]string{"get"}, []string{""}, []string{"configmaps"}))}, Bindings: []*unstructured.Unstructured{binding("read-web", "web", "Role", "reader", user("ana"))}, }) held := holderFor(t, index, "ana") if len(held.Powers) != 0 { t.Fatalf("powers = %+v", held.Powers) } } func TestTheFurthestReachingSubjectComesFirst(t *testing.T) { index := Build(Held{ Roles: []*unstructured.Unstructured{role("quiet", "web", rule([]string{"get"}, []string{""}, []string{"configmaps"}))}, ClusterRoles: []*unstructured.Unstructured{clusterRole("admin", rule([]string{Anything}, []string{Anything}, []string{Anything}))}, Bindings: []*unstructured.Unstructured{binding("quiet", "web", "Role", "quiet", user("bo"))}, ClusterBinds: []*unstructured.Unstructured{binding("admin", "", "ClusterRole", "admin", user("ana"))}, }) if index.Holders[0].Subject.Name != "ana" { t.Fatalf("the order was %+v", labels(index)) } } func TestASubjectSaysWhereItsGrantsApply(t *testing.T) { index := Build(Held{ Roles: []*unstructured.Unstructured{ role("reader", "web", rule([]string{"get"}, []string{""}, []string{"pods"})), role("reader", "db", rule([]string{"get"}, []string{""}, []string{"pods"})), }, Bindings: []*unstructured.Unstructured{ binding("web", "web", "Role", "reader", user("ana")), binding("db", "db", "Role", "reader", user("ana")), }, }) held := holderFor(t, index, "ana") if !slices.Equal(held.Namespaces(), []string{"db", "web"}) { t.Fatalf("namespaces = %+v", held.Namespaces()) } } func TestASubjectWithACurrentGrantIsNotConfinedToNamespaces(t *testing.T) { index := Build(Held{ ClusterRoles: []*unstructured.Unstructured{clusterRole("reader", rule([]string{"get"}, []string{""}, []string{"pods"}))}, ClusterBinds: []*unstructured.Unstructured{binding("all", "", "ClusterRole", "reader", user("ana"))}, }) held := holderFor(t, index, "ana") if held.Namespaces() != nil { t.Fatalf("a cluster-wide subject was confined to %+v", held.Namespaces()) } } func TestAnAggregatedRoleWithNoRulesYetSaysSo(t *testing.T) { role := clusterRole("view") role.Object["aggregationRule"] = map[string]any{"clusterRoleSelectors": []any{}} index := Build(Held{ ClusterRoles: []*unstructured.Unstructured{role}, ClusterBinds: []*unstructured.Unstructured{binding("view", "", "ClusterRole", "view", user("ana"))}, }) held := holderFor(t, index, "ana") if !held.Grants[0].Aggregated { t.Fatal("an aggregated role did not say it is filled in by a controller") } if held.Grants[0].RuleCount != 0 { t.Fatalf("rules = %d", held.Grants[0].RuleCount) } } func TestABindingWithNoRoleRefIsSkipped(t *testing.T) { odd := binding("odd", "web", "Role", "reader", user("ana")) delete(odd.Object, "roleRef") index := Build(Held{Bindings: []*unstructured.Unstructured{odd}}) if len(index.Holders) != 0 { t.Fatalf("holders = %+v", labels(index)) } } func TestASubjectWithNoNameIsSkipped(t *testing.T) { index := Build(Held{ Roles: []*unstructured.Unstructured{role("reader", "web", rule([]string{"get"}, []string{""}, []string{"pods"}))}, Bindings: []*unstructured.Unstructured{binding("read", "web", "Role", "reader", map[string]any{"kind": "User"})}, }) if len(index.Holders) != 0 { t.Fatalf("holders = %+v", labels(index)) } } func TestAGroupKeepsItsOwnName(t *testing.T) { index := Build(Held{ ClusterRoles: []*unstructured.Unstructured{clusterRole("reader", rule([]string{"get"}, []string{""}, []string{"pods"}))}, ClusterBinds: []*unstructured.Unstructured{ binding("all", "", "ClusterRole", "reader", map[string]any{"kind": "Group", "name": "system:authenticated"}), }, }) held := holderFor(t, index, "system:authenticated") if held.Subject.Kind != KindGroup { t.Fatalf("the subject came back as %+v", held.Subject) } } func TestOneSubjectBoundTwiceKeepsBothGrants(t *testing.T) { index := Build(Held{ ClusterRoles: []*unstructured.Unstructured{clusterRole("reader", rule([]string{"get"}, []string{""}, []string{"pods"}))}, ClusterBinds: []*unstructured.Unstructured{ binding("one", "", "ClusterRole", "reader", user("ana")), binding("two", "", "ClusterRole", "reader", user("ana")), }, }) held := holderFor(t, index, "ana") if len(held.Grants) != 2 { t.Fatalf("grants = %+v", held.Grants) } } func TestAClusterBindingWithAMissingRoleIsNamedWithoutANamespace(t *testing.T) { index := Build(Held{ ClusterBinds: []*unstructured.Unstructured{binding("gone", "", "ClusterRole", "nowhere", user("ana"))}, }) if len(index.Absent) != 1 || index.Absent[0] != "ClusterRoleBinding gone wants ClusterRole nowhere" { t.Fatalf("absent = %+v", index.Absent) } } func TestARuleThatIsNotAMapIsSkipped(t *testing.T) { odd := clusterRole("odd", "not a rule") index := Build(Held{ ClusterRoles: []*unstructured.Unstructured{odd}, ClusterBinds: []*unstructured.Unstructured{binding("odd", "", "ClusterRole", "odd", user("ana"))}, }) held := holderFor(t, index, "ana") if held.Grants[0].RuleCount != 0 { t.Fatalf("rules = %d", held.Grants[0].RuleCount) } } func TestASubjectThatIsNotAMapIsSkipped(t *testing.T) { odd := binding("odd", "", "ClusterRole", "reader", "not a subject", user("ana")) index := Build(Held{ ClusterRoles: []*unstructured.Unstructured{clusterRole("reader", rule([]string{"get"}, []string{""}, []string{"pods"}))}, ClusterBinds: []*unstructured.Unstructured{odd}, }) if len(index.Holders) != 1 { t.Fatalf("holders = %+v", labels(index)) } } func TestAVerbListThatIsNotStringsIsIgnored(t *testing.T) { odd := clusterRole("odd", map[string]any{ "verbs": []any{true}, "apiGroups": []any{""}, "resources": []any{"pods"}, }) index := Build(Held{ ClusterRoles: []*unstructured.Unstructured{odd}, ClusterBinds: []*unstructured.Unstructured{binding("odd", "", "ClusterRole", "odd", user("ana"))}, }) held := holderFor(t, index, "ana") if held.Grants[0].Reaches(Ask{Verb: "get", Resource: "pods"}) { t.Fatal("a rule with no readable verbs granted something") } } func TestAServiceAccountWithNoNamespaceAnywhereKeepsItsBareName(t *testing.T) { index := Build(Held{ ClusterRoles: []*unstructured.Unstructured{clusterRole("reader", rule([]string{"get"}, []string{""}, []string{"pods"}))}, ClusterBinds: []*unstructured.Unstructured{ binding("all", "", "ClusterRole", "reader", map[string]any{"kind": "ServiceAccount", "name": "orphan"}), }, }) holderFor(t, index, "orphan") } func TestTwoSubjectsWithTheSamePowersAreOrderedByName(t *testing.T) { index := Build(Held{ ClusterRoles: []*unstructured.Unstructured{clusterRole("reader", rule([]string{"get"}, []string{""}, []string{"configmaps"}))}, ClusterBinds: []*unstructured.Unstructured{ binding("one", "", "ClusterRole", "reader", user("zoe")), binding("two", "", "ClusterRole", "reader", user("ana")), }, }) if index.Holders[0].Subject.Name != "ana" { t.Fatalf("the order was %+v", labels(index)) } } func TestSubjectsWithTheSamePowersAreOrderedByGrantCount(t *testing.T) { holders := map[string]*Holder{ "one": { Subject: Subject{Kind: KindUser, Name: "one"}, Grants: []Grant{{Role: "reader"}}, }, "two": { Subject: Subject{Kind: KindUser, Name: "two"}, Grants: []Grant{{Role: "reader"}, {Role: "writer"}}, }, } got := ordered(holders) if got[0].Subject.Name != "two" { t.Fatalf("holders = %+v, want the holder with more grants first", got) } } func namedRule(verbs, groups, resources, names []string) map[string]any { one := rule(verbs, groups, resources) one["resourceNames"] = asAny(names) return one } func TestARuleTiedToNamedObjectsDoesNotAnswerForAllOfThem(t *testing.T) { one := Rule{ Verbs: []string{"get"}, Groups: []string{""}, Resources: []string{"secrets"}, Names: []string{"forgejo-pg-app"}, } if one.Allows(Ask{Verb: "get", Resource: "secrets"}) { t.Fatal("a rule that only names one secret answered for every secret, which the apiserver denies") } } func TestARuleWithNoNamesStillAnswers(t *testing.T) { one := Rule{Verbs: []string{"get"}, Groups: []string{""}, Resources: []string{"secrets"}} if !one.Allows(Ask{Verb: "get", Resource: "secrets"}) { t.Fatal("an unrestricted rule stopped answering") } } func TestASubjectHeldToNamedSecretsIsNotSaidToReadSecrets(t *testing.T) { index := Build(Held{ Roles: []*unstructured.Unstructured{ role("pg", "forgejo", namedRule([]string{"get"}, []string{""}, []string{"secrets"}, []string{"forgejo-pg-app"})), }, Bindings: []*unstructured.Unstructured{ binding("pg", "forgejo", "Role", "pg", account("forgejo-pg", "forgejo")), }, }) held := holderFor(t, index, "system:serviceaccount:forgejo:forgejo-pg") if slices.Contains(held.Powers, "reads secrets") { t.Fatalf("powers = %+v, want no claim the apiserver would deny", held.Powers) } if len(index.Who(Ask{Verb: "get", Resource: "secrets", Namespace: "forgejo"})) != 0 { t.Fatal("a subject held to one named secret came back as able to get secrets") } }