Skip to content

Trim operator RBAC to least privilege (workstream E) - #84

Merged
nickmarden merged 2 commits into
mainfrom
security/workstream-e-least-privilege
Jul 28, 2026
Merged

Trim operator RBAC to least privilege (workstream E)#84
nickmarden merged 2 commits into
mainfrom
security/workstream-e-least-privilege

Conversation

@nickmarden

@nickmarden nickmarden commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Workstream E — runtime blast-radius reduction. Grants the operator only the Kubernetes permissions its code actually exercises, and removes an orphaned RBAC artifact that had drifted from the real policy. Every removed permission was verified as unused against the source (see below); no functional change.

The main win: scope configmaps to the operator's own namespace

The operator reads exactly one ConfigMap: the emergency kill switch ballast-kill-switch, in its own namespace (internal/killswitch/killswitch.go). But the ClusterRole granted configmaps get,list,watch cluster-wide, because the manager cache only namespace-scoped Pods, so the ConfigMap informer watched every namespace.

This PR:

  • Scopes the ConfigMap informer in code (cmd/ballastd/main.go) to operatorNamespace + a metadata.name field selector, so the process only ever lists/watches the single kill-switch object.
  • Moves the configmaps grant off the cluster-wide ClusterRole onto a namespaced Role in the release namespace. get is pinned with resourceNames: [ballast-kill-switch]; list/watch stay namespace-scoped (RBAC can't name-restrict those, but the code-side cache scoping means they only ever touch the one object).

Net effect: the operator can no longer read ConfigMaps anywhere outside its own namespace, closing a common read-surface (CA bundles, app config, occasionally-misplaced secrets).

Verb trims on the ClusterRole

All confirmed unused by grepping the codebase for the corresponding client calls:

Resource Before After Why
pods get,list,watch,update,patch get,list,watch,patch Pods are only Patched; in-place resize uses the pods/resize subresource. No client.Update on pods anywhere.
nodes get,list list The kubelet MetricsSource only Lists nodes; per-node data comes via nodes/proxy.
events create,patch create Events are uniquely named and only ever Created (no aggregation/patch path).
workloadprofiles …,update,… create,patch,delete (+ get,list,watch) Writes are Create/Patch/Delete only.
workloadprofiles/status get,update,patch patch Status is written via Status().Patch and read through the parent object, never as a /status GET.

Removed the orphaned manager-role RBAC stub

config/rbac/role.yaml (the controller-gen manager-role) and its binding config/rbac/role_binding.yaml granted only pods get,list,watch — wildly out of sync with the real operator policy. There are no +kubebuilder:rbac markers anywhere in the code, so controller-gen never regenerated it (verified: a real delete followed by make manifests leaves it gone). It was a hand-frozen artifact that nothing generates and nothing deploys — Ballast installs exclusively via the Helm chart.

This PR deletes both files, drops their entries from config/rbac/kustomization.yaml, and documents the Helm ClusterRole (charts/ballast/templates/clusterrole.yaml) as the sole source of truth for operator permissions. Nothing runs kustomize build, and no other manifest references manager-role/manager-rolebinding, so no dangling references remain.

securityContext — already hardened, unchanged

The deployment is already Pod Security Standards restricted-compliant: pod-level runAsNonRoot + seccompProfile: RuntimeDefault; container-level readOnlyRootFilesystem, allowPrivilegeEscalation: false, capabilities.drop: [ALL]. No change needed.

Validation

  • make lint — clean (0 issues)
  • make test-coverage-check — passed
  • helm lint charts/ballast — passed; RBAC renders correctly with leaderElect both true and false
  • make manifests — clean tree (does not regenerate the removed stub)
  • go build ./cmd/ballastd — clean

Reduce the operator's runtime blast radius by granting only the
permissions the code actually exercises.

configmaps: the operator reads exactly one ConfigMap, the emergency
kill switch (ballast-kill-switch) in its own namespace. Move the grant
off the cluster-wide ClusterRole onto a namespaced Role in the release
namespace, so the process can no longer read every ConfigMap in the
cluster. The kill-switch informer is scoped in code to that single
object via the manager cache (namespace + metadata.name field
selector), and the get verb is pinned with resourceNames (list/watch
can't be name-restricted via RBAC).

Drop unused verbs from the ClusterRole, all verified against the code:
- pods: no update (pods are only patched; in-place resize uses the
  pods/resize subresource)
- nodes: no get (the kubelet source only lists nodes)
- events: no patch (events are only created, uniquely named)
- workloadprofiles: no update (create/patch/delete only)
- workloadprofiles/status: patch only (status is written via
  Status().Patch and read through the parent object)

No functional change; the operator exercises none of the removed
permissions. The pod securityContext is already PSS-restricted
compliant (runAsNonRoot, seccompProfile RuntimeDefault,
readOnlyRootFilesystem, allowPrivilegeEscalation false, drop ALL caps),
so it is unchanged.
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown

Docker Image Built

docker pull ghcr.io/tight-line/ballast:pr-84-9d1aa6c

Helm (values override)

image:
  repository: ghcr.io/tight-line/ballast
  tag: "pr-84-9d1aa6c"

Image expires ~15 days after PR closes.

config/rbac/role.yaml (manager-role) and config/rbac/role_binding.yaml
(manager-rolebinding) were a stale controller-gen stub granting only
pods get,list,watch, wildly out of sync with the real operator policy.

There are no +kubebuilder:rbac markers anywhere in the code, so
controller-gen never regenerated the file (a real delete + `make
manifests` leaves it gone); it was a hand-frozen artifact that nothing
generated and nothing deployed. Ballast installs exclusively via the
Helm chart, whose hand-maintained ClusterRole
(charts/ballast/templates/clusterrole.yaml) is the true, deployed
policy and now the sole source of truth.

Delete both files, drop their entries from config/rbac/kustomization.yaml,
and document the Helm ClusterRole as authoritative there. Nothing runs
`kustomize build`, and no other manifest references manager-role /
manager-rolebinding, so no dangling references remain.
@nickmarden
nickmarden merged commit acd6892 into main Jul 28, 2026
10 checks passed
@nickmarden
nickmarden deleted the security/workstream-e-least-privilege branch July 28, 2026 15:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants