Problem
The main api-server ClusterRole (charts/topograph/templates/rbac.yaml) grants its nodes, pods, and daemonsets verbs unconditionally — gated on nothing but .Values.rbac.create. Only the pods/exec and configmaps rules are engine/provider-aware. As a result, a deployment whose selected engine and provider never call the Kubernetes API (any slurm/graph engine paired with a non-Kubernetes provider, e.g. the default test provider) still receives a cluster-wide nodes:update (a write) plus pods and daemonsets reads it never exercises. This is the largest least-privilege gap on the primary API surface (audit finding F2).
Evidence (re-verified against the repo this session, on branch fix/rbac-drop-unused-verbs)
Post-PR1 template renders these four rules with no engine/provider gate (charts/topograph/templates/rbac.yaml:32-51):
rules:
- apiGroups: [""]
resources: [pods]
verbs: [list]
- apiGroups: [""]
resources: [nodes]
verbs: [get,list,update]
- apiGroups: [apps]
resources: [daemonsets]
verbs: [get]
{{- if eq .Values.global.engine.name "slinky" }}
- apiGroups: [""]
resources: [configmaps]
verbs: [create,get,update]
{{- end }}
Every Kubernetes call the main api-server binary (cmd/topograph) can make is confined to four loaders — verified by enumerating clientset construction (grep 'kubernetes.NewForConfig\|InClusterConfig' over pkg/ cmd/ internal/, non-test): the k8s engine (pkg/engines/k8s/engine.go:60), the slinky engine (pkg/engines/slinky/engine.go:124), the dra provider (pkg/providers/dra/provider.go:55), and the infiniband-k8s provider (pkg/providers/infiniband/provider_k8s.go:57). Neither cmd/topograph nor pkg/server builds a client. Per-verb call sites (all confirmed by reading the files):
| Verb (post-PR1) |
Actually needed when |
Verified call site |
nodes [list] |
engine ∈ {k8s, slinky} or provider ∈ {dra, infiniband-k8s} |
k8s/kubernetes.go:35; slinky/engine.go:216; dra/provider.go:93; infiniband/provider_k8s.go:94 (all via internal/k8s/utils.go:28) |
nodes [get, update] |
engine=k8s or (engine=slinky and useDynamicNodes) |
k8s/kubernetes.go:73,80; slinky/engine.go:683,706 — reachable only through performReconciliation, which runs only under if p.UseDynamicNodes (slinky/engine.go:463,468) |
pods [list] |
engine=slinky or provider=infiniband-k8s |
slinky/engine.go:221,568,612; infiniband/k8s.go:48 → internal/k8s/utils.go:63 |
daemonsets [get] |
provider=infiniband-k8s |
infiniband/k8s.go:48 → internal/k8s/utils.go:51 |
Concrete over-grant: the slurm engine writes topology.conf via internal/files.Create and the test provider is offline (neither imports client-go), so a test/slurm api-server makes zero clientset calls yet holds cluster-wide nodes:update. (lambdai's Nodes().Get at pkg/providers/lambdai/k8s.go:38 runs in the node-data-broker binary, not the api-server, so it does not bear on this role.)
Proposed approach
Gate each rule by engine/provider, mirroring the existing $needsPodExec / configmaps pattern already used well in this template. Because nodes:list has a broader condition than nodes:get+update — and nodes:get+update always implies nodes:list (both k8s-engine and dynamic-slinky need the list) — the nodes rule becomes a single rule with a computed verb set ([get,list,update] when write is needed, else [list]), not two rules.
Design decision to confirm (the one genuine fork): when a combo needs no rule at all (e.g. test/slurm), should the chart render an empty-rules ClusterRole, or omit the ClusterRole + ClusterRoleBinding entirely?
- Recommended — omit both. A zero-rule
ClusterRole grants nothing; rendering it is a useless object, and the empty rules: list is a fragile YAML artifact. Every rule condition implies nodes:list is needed, so "any rule needed" collapses cleanly to a single $needsClusterRole guard around both documents. This is the true least-privilege outcome: a non-Kubernetes deployment gets no cluster role at all. The ServiceAccount still renders (unchanged), so nothing dangles.
- Rejected alternative: keep an always-rendered
ClusterRole with rules: [] for zero-k8s combos — retains a no-op object and needs extra templating to emit a valid empty list.
The pods/exec and configmaps gates are already correct and are left untouched.
Breaking-change / compatibility note
This is a defaults reduction on the primary API surface — issue-first, and visible to operators:
- Non-Kubernetes combos (
slurm/graph engine with a non-dra/non-infiniband-k8s provider) lose the main ClusterRole + ClusterRoleBinding entirely. They made no api-server K8s calls, so this is safe.
k8s-engine combos with a non-infiniband-k8s provider (incl. the default test/k8s) lose the unused pods and daemonsets rules; nodes [get,list,update] is retained.
- Non-dynamic
slinky combos lose nodes:get+update (retain nodes:list, pods:list, configmaps) and lose daemonsets.
No supported runtime path loses a grant it exercises (reverse-direction check: every one of the 27 call sites still maps to a rendered grant in the combos where it fires). Operators who layer their own ClusterRole onto the api-server SA are unaffected.
Acceptance criteria
charts/topograph/templates/rbac.yaml gates pods, nodes, daemonsets by engine/provider; pods/exec and configmaps gates unchanged; the whole ClusterRole+ClusterRoleBinding is omitted when no rule applies.
make chart-test passes; new helm-unittest cases in charts/topograph/tests/rbac_test.yaml assert the per-combo shape for test/k8s, test/slurm, dra/slurm, infiniband-k8s, slinky (non-dynamic), and slinky+useDynamicNodes, each with notContains discriminators that fail if the gate regresses.
charts/topograph/tests/__snapshot__/render_snapshot_test.yaml.snap regenerated; the diff is confined to the intended rule removals (and the ib-example snapshot is unchanged).
CHANGELOG.md [Unreleased] → ### Security records the change.
- Stacks on PR1 (
fix/rbac-drop-unused-verbs); DCO sign-off on every commit.
A staged implementation plan (helm-unittest cases, template edits, docs, CHANGELOG) exists and can follow this issue.
Problem
The main api-server
ClusterRole(charts/topograph/templates/rbac.yaml) grants itsnodes,pods, anddaemonsetsverbs unconditionally — gated on nothing but.Values.rbac.create. Only thepods/execandconfigmapsrules are engine/provider-aware. As a result, a deployment whose selected engine and provider never call the Kubernetes API (anyslurm/graphengine paired with a non-Kubernetes provider, e.g. the defaulttestprovider) still receives a cluster-widenodes:update(a write) pluspodsanddaemonsetsreads it never exercises. This is the largest least-privilege gap on the primary API surface (audit finding F2).Evidence (re-verified against the repo this session, on branch
fix/rbac-drop-unused-verbs)Post-PR1 template renders these four rules with no engine/provider gate (
charts/topograph/templates/rbac.yaml:32-51):Every Kubernetes call the main api-server binary (
cmd/topograph) can make is confined to four loaders — verified by enumerating clientset construction (grep 'kubernetes.NewForConfig\|InClusterConfig'overpkg/ cmd/ internal/, non-test): the k8s engine (pkg/engines/k8s/engine.go:60), the slinky engine (pkg/engines/slinky/engine.go:124), the dra provider (pkg/providers/dra/provider.go:55), and the infiniband-k8s provider (pkg/providers/infiniband/provider_k8s.go:57). Neithercmd/topographnorpkg/serverbuilds a client. Per-verb call sites (all confirmed by reading the files):nodes [list]k8s/kubernetes.go:35;slinky/engine.go:216;dra/provider.go:93;infiniband/provider_k8s.go:94(all viainternal/k8s/utils.go:28)nodes [get, update]useDynamicNodes)k8s/kubernetes.go:73,80;slinky/engine.go:683,706— reachable only throughperformReconciliation, which runs only underif p.UseDynamicNodes(slinky/engine.go:463,468)pods [list]slinky/engine.go:221,568,612;infiniband/k8s.go:48→internal/k8s/utils.go:63daemonsets [get]infiniband/k8s.go:48→internal/k8s/utils.go:51Concrete over-grant: the
slurmengine writestopology.confviainternal/files.Createand thetestprovider is offline (neither imports client-go), so atest/slurmapi-server makes zero clientset calls yet holds cluster-widenodes:update. (lambdai'sNodes().Getatpkg/providers/lambdai/k8s.go:38runs in the node-data-broker binary, not the api-server, so it does not bear on this role.)Proposed approach
Gate each rule by engine/provider, mirroring the existing
$needsPodExec/configmapspattern already used well in this template. Becausenodes:listhas a broader condition thannodes:get+update— andnodes:get+updatealways impliesnodes:list(both k8s-engine and dynamic-slinky need the list) — thenodesrule becomes a single rule with a computed verb set ([get,list,update]when write is needed, else[list]), not two rules.Design decision to confirm (the one genuine fork): when a combo needs no rule at all (e.g.
test/slurm), should the chart render an empty-rulesClusterRole, or omit theClusterRole+ClusterRoleBindingentirely?ClusterRolegrants nothing; rendering it is a useless object, and the emptyrules:list is a fragile YAML artifact. Every rule condition impliesnodes:listis needed, so "any rule needed" collapses cleanly to a single$needsClusterRoleguard around both documents. This is the true least-privilege outcome: a non-Kubernetes deployment gets no cluster role at all. TheServiceAccountstill renders (unchanged), so nothing dangles.ClusterRolewithrules: []for zero-k8s combos — retains a no-op object and needs extra templating to emit a valid empty list.The
pods/execandconfigmapsgates are already correct and are left untouched.Breaking-change / compatibility note
This is a defaults reduction on the primary API surface — issue-first, and visible to operators:
slurm/graphengine with a non-dra/non-infiniband-k8sprovider) lose the mainClusterRole+ClusterRoleBindingentirely. They made no api-server K8s calls, so this is safe.k8s-engine combos with a non-infiniband-k8sprovider (incl. the defaulttest/k8s) lose the unusedpodsanddaemonsetsrules;nodes [get,list,update]is retained.slinkycombos losenodes:get+update(retainnodes:list,pods:list,configmaps) and losedaemonsets.No supported runtime path loses a grant it exercises (reverse-direction check: every one of the 27 call sites still maps to a rendered grant in the combos where it fires). Operators who layer their own
ClusterRoleonto the api-server SA are unaffected.Acceptance criteria
charts/topograph/templates/rbac.yamlgatespods,nodes,daemonsetsby engine/provider;pods/execandconfigmapsgates unchanged; the wholeClusterRole+ClusterRoleBindingis omitted when no rule applies.make chart-testpasses; new helm-unittest cases incharts/topograph/tests/rbac_test.yamlassert the per-combo shape fortest/k8s,test/slurm,dra/slurm,infiniband-k8s,slinky(non-dynamic), andslinky+useDynamicNodes, each withnotContainsdiscriminators that fail if the gate regresses.charts/topograph/tests/__snapshot__/render_snapshot_test.yaml.snapregenerated; the diff is confined to the intended rule removals (and theib-examplesnapshot is unchanged).CHANGELOG.md[Unreleased]→### Securityrecords the change.fix/rbac-drop-unused-verbs); DCO sign-off on every commit.A staged implementation plan (helm-unittest cases, template edits, docs, CHANGELOG) exists and can follow this issue.