kubepath

apps/deployer/rbac.yaml

Write-only RBAC

Secrets are create and patch, never get or list. The deployer can install a credential it cannot read back — and the reason that works at all is a detail about which verb you choose.

This is the clearest write-only RBAC example in the repository and the best thirty minutes in the material, because it rewards being read closely three times. The first reading is "the deployer cannot read Secrets, good." The second is "wait, how does it update one without reading it?" The third is "hold on, it can create Deployments."

Take a class through all three. The third reading is the one that matters, because it is the difference between a security property and the appearance of one — and a class that arrives at it themselves will never again read an RBAC rule without asking what else is in the role.

Along the path: hop 10 · Kustomize apply, with no reconciler · hop 9 · BuildKit, rootless and tokenless

apps/deployer/rbac.yaml 7 annotated lines
1apiVersion: v1
2kind: ServiceAccount
3metadata:
4 name: deployer
5 namespace: platform
6---
7apiVersion: rbac.authorization.k8s.io/v1
8kind: ClusterRole
9metadata:
10 name: deployer
11rules:
12
13 # ---- Secrets: write-only. this is the interesting one. ----
14 - apiGroups: [""]
15 resources: ["secrets"]
18
19 # ---- tenant workloads ----
20 - apiGroups: ["apps"]
21 resources: ["deployments", "statefulsets"]
23 - apiGroups: [""]
24 resources: ["services", "configmaps"]
26 - apiGroups: ["traefik.io"]
27 resources: ["ingressroutes", "middlewares", "serverstransports"]
28 verbs: ["create", "patch", "get", "list", "watch"]
29 - apiGroups: ["cert-manager.io"]
30 resources: ["certificates"]
32
34 # pods/exec, pods/portforward — a shell in a tenant pod reads its env
35 # secrets: get, list, watch — the whole point; see above
36 # serviceaccounts/token — mints credentials for other identities
37 # roles, rolebindings — lets the deployer widen itself
38 # nodes/proxy — reaches the kubelet, and every pod on it
39---
40apiVersion: rbac.authorization.k8s.io/v1
41kind: ClusterRoleBinding
42metadata:
43 name: deployer
44roleRef:
45 apiGroup: rbac.authorization.k8s.io
46 kind: ClusterRole
47 name: deployer
48subjects:
49 - kind: ServiceAccount
50 name: deployer

line 16

Write-only: the blast radius changes category

No get, no list, no watch. The deployer can install a tenant’s database credential, registry pull secret or TLS key, and cannot read any of them back — not its own, not another tenant’s.

State the consequence precisely, because the precision is the lesson. A stolen deployer token is an integrity and availability problem: an attacker can overwrite credentials and break things. It is not a confidentiality problem: it does not hand over every tenant secret in the cluster. Those are different incidents with different disclosure obligations, and the difference is this one line.

Most deploy tooling holds get on Secrets because a read-modify-write needs it. Not needing it is a design achievement, and the next annotation is how.

line 17

Why patch and not update — the load-bearing detail

This is the part people miss, and it is what makes write-only workable rather than merely aspirational.

update is a PUT: it replaces the whole object, so you must send every field — which means you must first have read it. update without get is nearly useless, because any change you make clobbers the keys you could not see.

patch is different. A JSON-merge or strategic-merge patch is applied by the API server, so you send only the keys you are changing and the server merges them into an object you never saw. "Set this one key, leave the rest alone" becomes possible without a read.

So the verb choice is not stylistic. Swap patch for update here and either the deployer breaks or somebody adds get back to fix it — and the property is gone.

line 22

The reading that undoes the one above

Here is the third reading, and the reason this file is worth thirty minutes rather than five.

Anything that can create a Deployment can create a pod. A pod can mount any Secret in its namespace and print it to a log, or ship it anywhere. So a compromised deployer can obtain Secret values — not by asking the API for them, but by asking the kubelet to hand them to a container it controls.

The property this role actually provides is: the API server will not return a Secret to this identity. It is not: this identity cannot obtain a Secret. The first is real and worth having — it defeats the cheap smash-and-grab, it is visible in audit logs as a conspicuous pod creation rather than an ordinary read, and it raises the noise floor of an attack considerably. But it is not the second, and a slide that claims the second is teaching something false.

Closing the gap needs a different mechanism: a validating admission policy constraining which Secrets a workload may mount, or splitting workload creation onto a separate identity from secret installation. Ask a class which they would choose and why before you say.

line 25

Service creation is privileged here, and this is where

Read this next to allowExternalNameServices: true in the Traefik values. Because that setting is on, creating a Service is enough to point the ingress controller at an arbitrary host. This rule grants exactly that.

It is not obviously wrong — the deployer has to create Services for the workloads it deploys — but it does mean the two files have to be read together, and neither one mentions the other. That is the thing to point at: a trust boundary that only exists in the intersection of two manifests is a boundary nobody will notice when they change one of them.

line 31

Certificates are readable, and that is fine

A Certificate is a request, not a key — the private key lands in a Secret that this role cannot read. So full read access to Certificates leaks nothing, and the deployer needs it to know whether issuance succeeded.

Worth a minute in class as a contrast: the split between "the object describing the credential" and "the credential" is what lets one be readable and the other not. Designs that conflate the two cannot make this distinction.

line 33

Write down what is missing, or nobody knows it was on purpose

An RBAC file records what was granted. It cannot record what was considered and refused, which means the next person to hit a permission error cannot tell a deliberate omission from an oversight — and the path of least resistance is to add the verb.

This comment block is the cheapest control in the repository. It converts "the deployer cannot exec into pods" from an accident into a decision, and it makes a pull request that adds pods/exec a conversation instead of a typo.

line 51

Check the grant, do not read it

RBAC is additive and a subject can hold several roles, so reading one file does not tell you what an identity can do. Ask the API server instead:

kubectl auth can-i get secrets \
  --as=system:serviceaccount:platform:deployer
# no

kubectl auth can-i create deployments \
  --as=system:serviceaccount:platform:deployer
# yes   ← and that is the gap in the annotation above

Run both in front of a class. The second answer landing "yes" right after the first lands "no" is the moment the lesson sticks.

Check yourself

2 questions. One attempt each is recorded; the explanation is the point, not the score.

Why does write-only Secrets access need patch rather than update?

The deployer cannot get Secrets but can create Deployments. What is the true security property?