diff --git a/public/diagrams/workload-least-privilege.svg b/public/diagrams/workload-least-privilege.svg new file mode 100644 index 0000000..cf71c4c --- /dev/null +++ b/public/diagrams/workload-least-privilege.svg @@ -0,0 +1,51 @@ + + + + + Service accounts all the way + least privilege = identity → non-root → an enforced floor + + + + Kubernetes API + control plane + + + no API token mounted + + + + + + namespace + + PodSecurity: baseline → restricted + + + + workload pod + + + + + ServiceAccount: its own — not the default + + + + automountServiceAccountToken: false + + + + runAsNonRoot · uid 1000 · drop ALL caps + + + + seccompProfile: RuntimeDefault + + + + the documented exception + GitOps · LoadBalancer · secrets operator + → scoped Role + token, nothing more + + diff --git a/src/content/blog/workload-least-privilege.md b/src/content/blog/workload-least-privilege.md new file mode 100644 index 0000000..17791b5 --- /dev/null +++ b/src/content/blog/workload-least-privilege.md @@ -0,0 +1,60 @@ +--- +title: "The most secure credential is the one you never mount" +date: 2026-06-23 +summary: "Least privilege for Kubernetes workloads doesn't start with an RBAC role — it starts with revoking the API token every pod silently carries, then layering identity, non-root, and Pod Security on top." +tags: ["kubernetes", "security", "least-privilege", "rbac", "service-accounts"] +draft: false +--- + +Open a shell in almost any Kubernetes pod and look in `/var/run/secrets/kubernetes.io`. There's a token +there — a live credential for the cluster API, mounted automatically, signed and ready. Now ask the harder +question: does this workload ever actually *call* the Kubernetes API? For a media server, a credential vault, +a DNS sinkhole, a search proxy — the answer is no. Never. Not once. + +So every one of those pods is holding a key to a door it never opens. That's not least privilege. That's a +standing credential waiting to be stolen. + +![Workload least privilege — each pod gets its own ServiceAccount with no API token, runs non-root with capabilities dropped, inside a namespace whose Pod Security level is pinned; the cluster API it never calls stays out of reach](/diagrams/workload-least-privilege.svg) + +## Identity first, RBAC second + +The reflex when someone says "least-privilege RBAC" is to start writing Roles. But a Role narrows what a +credential *can do* — it does nothing about a credential that shouldn't exist. The first move is cheaper and +safer: give every workload its **own** ServiceAccount instead of the shared namespace default, and set +`automountServiceAccountToken: false`. + +That single line changes no runtime behaviour — no capabilities, no user, no restart semantics — because +the app wasn't using the token anyway. You've simply stopped handing out a key. The handful of components +that genuinely talk to the API (the GitOps controller, the load-balancer, the secrets operator) get a +scoped Role and keep their token. Everything else gets an identity and nothing else. It's the highest +security-per-effort change in the whole estate, and it's effectively free. + +## Then take away what root gave you + +Identity is one axis; what the process can do on the node is another. A container that runs as root with the +full Linux capability set is one escape away from the host, regardless of how few API rights its token has. +So the next layer is the container itself: run as a non-root UID, drop **all** capabilities and add back only +the few a workload truly needs, forbid privilege escalation, and pin a `RuntimeDefault` seccomp profile. + +This is where honesty matters. Some images — anything running several processes under an init system, or the +classic "start as root, drop to a user" pattern — break the moment you drop capabilities, because something +inside still expects to `chown` a directory or bind a port. The lesson isn't "give up"; it's that non-root is +a *per-app, verified* change, while own-identity-and-no-token is a *universal, safe* one. Do the free thing +everywhere; do the careful thing where you've tested it. + +## Make the floor non-negotiable + +Authoring all of this per workload is good, but authoring isn't enforcing — a future manifest can forget. The +enforcement floor is **Pod Security Admission**, set at the namespace. Pin each namespace's level explicitly +rather than inheriting a cluster default you can't see: `baseline` where root init-containers still live, +`restricted` for the apps that have earned it, and a documented `privileged` exception for the genuine cases +(a VPN sidecar that needs `NET_ADMIN`, a time daemon that needs `SYS_TIME`). Run the rest at `warn` for the +restricted profile, and every apply tells you exactly which workload is one refactor away from the top tier. + +## The principle + +Least privilege isn't a pile of Roles. It's a direction: start by removing the access that nothing is using — +the auto-mounted token first — then narrow what genuinely remains, then make the boundary enforce itself. Give +every workload its own identity, hand it no credential it doesn't need, strip it to the smallest process it can +be, and let the namespace refuse anything bigger. The credential you never mount can't leak, can't be replayed, +and can't be the thing you forgot to revoke.