Why a second layer
Server-side scope already blocks out-of-policy calls before they reach the API server: allow and deny namespace lists, cluster-scope opt-in, a pinned context. RBAC is the independent layer underneath, enforced by Kubernetes rather than by this process.
If a bug ever let a call slip past ScopeGuard, the credential still cannot read a Secret, patch an unlisted kind, or touch a namespace outside its Role bindings. Two mechanisms, one outcome, no shared failure mode.
What the manifests grant
A ServiceAccount in its own janus-mcp namespace, one narrow ClusterRole, and a per-namespace Role bound to it.
| Scope | Resources | Verbs |
|---|---|---|
| Cluster-wide | namespaces | get list |
| Per namespace, core | pods pods/log events services configmaps persistentvolumeclaims | get list |
| Per namespace, apps | deployments replicasets statefulsets daemonsets | get list |
| Per namespace, batch | jobs cronjobs | get list |
| Per namespace, networking | ingresses | get list |
| Per namespace, autoscaling | horizontalpodautoscalers | get list |
| Per namespace, events.k8s.io | events | get list |
Repeat the Role and RoleBinding pair for every namespace in scope.allowed_namespaces. A namespace with no Role is invisible twice over: server-side scope refuses it, and the credential could not read it anyway.
What is absent, deliberately
The absence is the control. These are not filtered at runtime; they were never granted.
secretsin any namespace, in any verb. Nowhere, ever.serviceaccounts,certificatesigningrequestsandtokenreviews, the adjacent credential-bearing kinds.create,deleteanddeletecollectionon everything.pods/exec,pods/attachandpods/portforward, the shell-shaped subresources.- Node and other cluster-scoped reads beyond
namespaces; those stay behindallow_cluster_scopedas well.
If the credential you point janus-mcp at can read Secrets, the startup access-review probe says so loudly, and refuses outright under --strict.
Apply it
Edit the namespaces in the manifest first: it ships with prod as the example.
kubectl create namespace janus-mcp kubectl apply -f rbac/janus-mcp-rbac.yaml
The file contains, in order: the ServiceAccount, the namespace-read ClusterRole and its ClusterRoleBinding, then the per-namespace read Role and RoleBinding, then the optional write Role and RoleBinding.
The optional write role
Only apply this if write tools are enabled in the janus-mcp config. It grants patch and nothing else, on the workload kinds a rollout restart or a scale actually touches.
rules:
- apiGroups: ["apps"]
resources:
- deployments
- deployments/scale
- statefulsets
- statefulsets/scale
- daemonsets
verbs: ["patch"]
Even with this bound, every write still needs an out-of-band human approval, and the config gates which tools register at all. RBAC is the floor, not the policy.
Verify before you trust it
Check the negative cases, not just the positive ones. The Secret probe must come back no.
SA=system:serviceaccount:janus-mcp:janus-mcp kubectl auth can-i list pods -n prod --as $SA # yes kubectl auth can-i get secrets -n prod --as $SA # no kubectl auth can-i create pods/exec -n prod --as $SA # no kubectl auth can-i patch deployments -n prod --as $SA # yes only with the write role kubectl auth can-i list pods -n kube-system --as $SA # no
The threat model lists the invariants these checks stand in for.
Bind a kubeconfig context
Create a context that uses the ServiceAccount token, then pin its name in the config. A short-lived token is fine; janus-mcp reads the kubeconfig at startup.
# in ~/.config/janus-mcp/config.yaml context: limited-sa@prod-cluster scope: allowed_namespaces: ["prod"] denied_namespaces: ["kube-system", "kube-node-lease"] allow_cluster_scoped: false