Skip to content

RBAC: engine/provider-gate the main Topograph ClusterRole (node/pod/daemonset verbs) #386

Description

@ArangoGutierrez

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:48internal/k8s/utils.go:63
daemonsets [get] provider=infiniband-k8s infiniband/k8s.go:48internal/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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions