Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions internal/embed/embed_frontend_rbac_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
40 changes: 38 additions & 2 deletions internal/embed/infrastructure/base/templates/obol-frontend.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Loading