diff --git a/internal/embed/embed_frontend_rbac_test.go b/internal/embed/embed_frontend_rbac_test.go new file mode 100644 index 00000000..0cb47edd --- /dev/null +++ b/internal/embed/embed_frontend_rbac_test.go @@ -0,0 +1,90 @@ +package embed + +import "testing" + +func TestObolFrontendRBAC_CanReadDefaultHermesTokenSecret(t *testing.T) { + data, err := ReadInfrastructureFile("base/templates/obol-frontend.yaml") + if err != nil { + t.Fatalf("ReadInfrastructureFile: %v", err) + } + docs := multiDoc(data) + + role := findDocByName(docs, "Role", "obol-frontend-hermes-token-reader") + if role == nil { + t.Fatal("no Role 'obol-frontend-hermes-token-reader' found") + } + if ns := nested(role, "metadata", "namespace"); ns != "hermes-obol-agent" { + t.Fatalf("token reader Role namespace = %v, want hermes-obol-agent", ns) + } + + rules, ok := role["rules"].([]any) + if !ok || len(rules) != 1 { + t.Fatalf("token reader Role rules = %#v, want exactly one rule", role["rules"]) + } + rule, ok := rules[0].(map[string]any) + if !ok { + t.Fatalf("token reader Role rule has type %T", rules[0]) + } + if !stringSet(rule["apiGroups"])[""] { + t.Fatal("token reader Role must target the core API group") + } + if !stringSet(rule["resources"])["secrets"] { + t.Fatal("token reader Role must target core/secrets") + } + if !stringSet(rule["resourceNames"])["hermes-api-server"] { + t.Fatal("token reader Role must be scoped to secret/hermes-api-server") + } + verbs := stringSet(rule["verbs"]) + if !verbs["get"] { + t.Fatal("token reader Role missing get verb") + } + for _, forbidden := range []string{"list", "watch", "create", "update", "patch", "delete"} { + if verbs[forbidden] { + t.Fatalf("token reader Role grants forbidden verb %q", forbidden) + } + } + + binding := findDocByName(docs, "RoleBinding", "obol-frontend-hermes-token-reader") + if binding == nil { + t.Fatal("no RoleBinding 'obol-frontend-hermes-token-reader' found") + } + if ns := nested(binding, "metadata", "namespace"); ns != "hermes-obol-agent" { + t.Fatalf("token reader RoleBinding namespace = %v, want hermes-obol-agent", ns) + } + if ref := nested(binding, "roleRef", "kind"); ref != "Role" { + t.Fatalf("token reader RoleBinding roleRef.kind = %v, want Role", ref) + } + if ref := nested(binding, "roleRef", "name"); ref != "obol-frontend-hermes-token-reader" { + t.Fatalf("token reader RoleBinding roleRef.name = %v, want obol-frontend-hermes-token-reader", ref) + } + if !bindingHasSubject(binding, "obol-frontend", "obol-frontend") { + t.Fatal("token reader RoleBinding missing obol-frontend/obol-frontend subject") + } +} + +func TestObolFrontendDiscoveryRBAC_DoesNotGrantBroadSecretAccess(t *testing.T) { + data, err := ReadInfrastructureFile("base/templates/obol-frontend.yaml") + if err != nil { + t.Fatalf("ReadInfrastructureFile: %v", err) + } + docs := multiDoc(data) + + role := findDocByName(docs, "ClusterRole", "obol-frontend-openclaw-discovery") + if role == nil { + t.Fatal("no ClusterRole 'obol-frontend-openclaw-discovery' found") + } + rules, ok := role["rules"].([]any) + if !ok || len(rules) == 0 { + t.Fatal("frontend discovery ClusterRole has no rules") + } + + for _, r := range rules { + rm, ok := r.(map[string]any) + if !ok { + t.Fatalf("frontend discovery ClusterRole has malformed rule %T", r) + } + if stringSet(rm["apiGroups"])[""] && stringSet(rm["resources"])["secrets"] { + t.Fatalf("frontend discovery ClusterRole must not grant broad Secret access: %#v", rm) + } + } +} diff --git a/internal/embed/infrastructure/base/templates/obol-frontend.yaml b/internal/embed/infrastructure/base/templates/obol-frontend.yaml index 77a4c806..b586281f 100644 --- a/internal/embed/infrastructure/base/templates/obol-frontend.yaml +++ b/internal/embed/infrastructure/base/templates/obol-frontend.yaml @@ -62,8 +62,9 @@ spec: # # The frontend is local-only behind the obol.stack hostname restriction # (the operator owns the cluster), so this is a single trust boundary. -# Defense-in-depth note: the `secrets` rule is intentionally omitted — no -# frontend code path reads them and the SA token should not have that reach. +# Defense-in-depth note: the discovery ClusterRole intentionally omits +# `secrets`. The default Hermes chat token read is isolated to the namespaced +# Role below so the frontend cannot enumerate or mutate Secrets. apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: @@ -107,3 +108,38 @@ subjects: - kind: ServiceAccount name: obol-frontend namespace: obol-frontend + +--- +# Server-side chat in the local frontend reads the default Hermes API server +# token from Kubernetes, then calls the private in-cluster Hermes service. +# Keep this namespaced and resourceName-scoped; do not grant Secret access via +# the frontend discovery ClusterRole. +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: obol-frontend-hermes-token-reader + namespace: hermes-obol-agent + labels: + app.kubernetes.io/name: obol-frontend +rules: + - apiGroups: [""] + resources: ["secrets"] + resourceNames: ["hermes-api-server"] + verbs: ["get"] + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: obol-frontend-hermes-token-reader + namespace: hermes-obol-agent + labels: + app.kubernetes.io/name: obol-frontend +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: obol-frontend-hermes-token-reader +subjects: + - kind: ServiceAccount + name: obol-frontend + namespace: obol-frontend