Security, RBAC & policy
A cluster is a shared, multi-tenant control plane — one over-broad grant or one un-scanned image is a breach. This lesson is defense-in-depth for Kubernetes: RBAC done right (Roles/ClusterRoles/Bindings, ServiceAccounts, least privilege), Pod Security Standards at admission, policy engines (OPA Gatekeeper / Kyverno) to enforce org rules, secrets management (External Secrets, Sealed Secrets, Vault), image & supply-chain admission (signatures, provenance), and NetworkPolicy as the in-cluster firewall. It builds directly on cd6 — container security & supply chain.
kubectl here needs a cluster (minikube/kind local, or EKS) and output is illustrative. This lesson extends cd6 — Security & supply chain (image scanning, SBOMs, signing) from the image into the cluster; read cd6 first if signing/SBOM is new. Policy engines and admission move fast — verify Gatekeeper, Kyverno, Pod Security Admission, External Secrets and Vault field names against their current docs before relying on any spec below.Learning objectives
- Model RBAC correctly: Role vs ClusterRole, RoleBinding vs ClusterRoleBinding, and least privilege.
- Bind permissions to a ServiceAccount a workload uses (not a human, not default).
- Apply Pod Security Standards (privileged / baseline / restricted) via Pod Security Admission.
- Enforce org policy at admission with OPA Gatekeeper or Kyverno, and write one policy.
- Choose a secrets strategy: External Secrets Operator, Sealed Secrets, or Vault — and why base64 isn't security.
- Add image/supply-chain admission (signature + provenance) and NetworkPolicy as defense-in-depth.
1 · RBAC: subjects, verbs, resources — least privilege
Start with the incident you're trying not to cause. A pod running your LLM API gets popped — a dependency has a remote-code-execution bug, or a prompt injection tricks a tool into shelling out. The attacker is now inside a container. The only question that matters next is: how much of your cluster can that container reach through the Kubernetes API? If the answer is "everything", you don't have a compromised pod, you have a compromised company. RBAC is the dial that sets that answer, and it's the single most important control in this lesson.
Here's the mental model. Think of RBAC like keycards in an office building. A keycard doesn't know who you are — it just carries a set of doors it opens. You hand a keycard to a person (or, in Kubernetes, to a workload), and from then on that identity can open exactly those doors and no others. The genius of the system is that it's default-deny: a brand-new keycard opens nothing. You never write "block the server room" — the server room is already locked to everyone; you only ever add the specific doors someone needs. That's why there are no "deny" rules in RBAC: permissions are purely additive on top of a zero baseline. To lock something down you simply don't grant it.
The most common mistake is the one that feels like a fix. Something "isn't working", the logs say forbidden, and under deadline pressure someone binds cluster-admin to the workload — handing out the master key that opens every door in the building because one office was locked. It works instantly, the ticket closes, and a landmine is now buried in your cluster: the day that pod is compromised, the blast radius is the entire cluster. The discipline that prevents this is least privilege — start from zero, grant the exact verbs on the exact resources the workload provably needs, and verify with kubectl auth can-i that it can do those things and nothing more. Most application pods, it turns out, need almost no API access at all.
RBAC answers 'who can do what, where'. Four object types combine: a Role (namespaced) or ClusterRole (cluster-wide) lists allowed verbs on resources; a RoleBinding or ClusterRoleBinding grants that Role to a subject (a User, Group, or — for workloads — a ServiceAccount). There are no 'deny' rules: permissions are additive and default-deny — you can only grant.
Read the four boxes left-to-right, following the arrows — that's the chain RBAC evaluates to answer "who can do what, where".
- The first box, Subject, is the identity being granted access — a ServiceAccount, user, or group (for workloads it's almost always the SA).
- The arrows are the binding link: the Subject connects through a Binding (a RoleBinding) to a Role (a Role or ClusterRole). The binding is the glue — the Role by itself grants nothing until something binds it to a subject.
- The payoff is the last box: the Role expands to the actual verbs on resources (
get, list, create…) — the concrete permissions the subject ends up with.
In short: a binding connects a subject to a Role, and the Role is just a list of allowed verbs on resources — follow the chain and you have the subject's exact permissions.
| Object | Scope | Use it for |
|---|---|---|
| Role | One namespace | Permissions inside a single namespace (most app grants) |
| ClusterRole | Cluster-wide OR reusable | Cluster resources (nodes), or a reusable rule set bound per-namespace |
| RoleBinding | One namespace | Grant a Role (or a ClusterRole, scoped to this ns) to a subject |
| ClusterRoleBinding | Cluster-wide | Grant a ClusterRole everywhere — powerful, use sparingly |
rbac.yamlapiVersion: v1
kind: ServiceAccount
metadata: { name: llm-api, namespace: web }
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: { name: read-config, namespace: web }
rules:
- apiGroups: [""]
resources: [configmaps]
verbs: [get, list, watch] # read config only — no secrets, no write
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: { name: llm-api-read-config, namespace: web }
roleRef: { apiGroup: rbac.authorization.k8s.io, kind: Role, name: read-config }
subjects:
- kind: ServiceAccount
name: llm-api
namespace: web
audit.sh# Impersonate the SA and test specific permissions:
kubectl auth can-i list configmaps -n web \
--as=system:serviceaccount:web:llm-api # -> yes
kubectl auth can-i delete secrets -n web \
--as=system:serviceaccount:web:llm-api # -> no (good)
kubectl auth can-i '*' '*' --all-namespaces \
--as=system:serviceaccount:web:llm-api # -> no (never grant this)
yes
no
no
ClusterRole/* verbs:[*]) to a workload 'to make it work' — one compromised pod owns the cluster. (2) Using the default ServiceAccount and auto-mounting its token into every pod that doesn't need the API (set automountServiceAccountToken: false). (3) Granting secrets read broadly — a token that can read secrets can often escalate. Start from zero, add the exact verbs a workload needs, and audit with kubectl auth can-i.2 · Pod Security Standards at admission
RBAC just locked down what a pod can ask the cluster to do — but that leaves a second, sneakier door wide open. A pod can be perfectly polite to the API and still be dangerous because of what it is: running as root, mounting the host's filesystem, sharing the node's network namespace, or granting itself Linux capabilities that let it break out of the container and onto the machine underneath. Those settings live in the Pod spec's securityContext, and if nobody checks them, any manifest can request them. Pod Security is the layer that reads the spec at the door and says "no."
The analogy: RBAC is the keycard that controls which rooms you enter; Pod Security is the metal detector at the entrance that inspects what you're carrying in. It doesn't care what you plan to do inside — it refuses to admit a workload that shows up "armed" (privileged, root, host-mounted) in the first place. And crucially it runs at admission time: the check happens the instant you kubectl apply, before the pod ever schedules, so a non-compliant pod is rejected rather than quietly running in a dangerous state you discover months later.
Kubernetes ships this as three named Pod Security Standards, which are just pre-agreed strictness levels so you don't have to invent your own: privileged (anything goes — for trusted system workloads only), baseline (blocks the obviously reckless, like privileged containers and host namespaces), and restricted (the hardened target — non-root, drop all capabilities, seccomp, read-only root filesystem). The common mistake here isn't picking the wrong level; it's the rollout. Flip a busy namespace straight to enforce: restricted and you'll reject every running workload the moment it redeploys — an outage you caused with a security label. That's why each standard runs in one of three modes — enforce, audit, warn — so you can watch who would be rejected before you actually start rejecting them.
RBAC controls the API; Pod Security controls what a Pod may be. The built-in Pod Security Admission controller enforces three Pod Security Standards per namespace via labels: privileged (no restrictions), baseline (blocks the obviously dangerous — host namespaces, privileged containers), and restricted (hardened — non-root, drop all capabilities, seccomp). Each level runs in one of three modes: enforce, audit, warn.
Read the left half top-to-bottom as tightening restriction, and the right half as the order in which you turn a level on.
- The three stacked bars on the left are the Pod Security Standards, narrowing as they get stricter: privileged (red — no restrictions), baseline (amber — blocks host namespaces and privileged containers), and restricted (green — non-root, dropped capabilities, seccomp, read-only rootfs).
- The right side is the three modes a level can run in. audit and warn only report who would be rejected; the arrow down to the green enforce box is the step that actually starts blocking.
- The payoff is at the bottom right: enforcement happens at admission — a non-compliant Pod is refused the instant you
kubectl apply, never quietly run in a dangerous state.
In short: Choose how strict (privileged → baseline → restricted), then observe with audit/warn before flipping to enforce so you don't reject your own running workloads.
ns-restricted.yamlapiVersion: v1
kind: Namespace
metadata:
name: web
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/warn: restricted # also warn on kubectl apply
# Pods that run as root or add capabilities will be REJECTED at admission.
A Pod that satisfies restricted looks like this — the same hardening cd6 taught for the image, now enforced by the cluster:
hardened-pod.yamlspec:
securityContext:
runAsNonRoot: true
seccompProfile: { type: RuntimeDefault }
containers:
- name: app
image: ghcr.io/acme/llm-api@sha256:... # pin by digest (cd6)
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }
enforce: restricted will reject running workloads on their next deploy. Roll out gradually: set audit + warn first, watch which Pods violate, fix their securityContexts, then switch to enforce. Verify the label keys and the exact restricted requirements against the current Pod Security docs — they are versioned.3 · Policy engines: OPA Gatekeeper & Kyverno
Pod Security is a great metal detector — but it only knows how to look for weapons. It has exactly three profiles, and they answer one narrow question: "is this pod running dangerously?" Your organization has a dozen other rules that have nothing to do with container privilege: every image must come from your own registry, every namespace must carry a cost-center label for billing, nobody is allowed to deploy a :latest tag, no hostPath mounts, resource limits are mandatory. Pod Security can't express any of that. You need something programmable — a place to write "at admission, here is our definition of an acceptable object."
That programmable checkpoint is a policy engine, and the mechanism it plugs into is one of Kubernetes' most powerful extension points: the admission webhook. Picture the API server as a customs desk that every object must clear before it's allowed into the country. A validating webhook is the inspector who stamps "approved" or "rejected"; a mutating webhook is the clerk who can quietly stamp a missing label onto your form before it proceeds. A policy engine registers itself as that inspector, so on every create-or-update the API server phones it and asks "does this pass our rules?" — and your rules, written once, are enforced on everyone, humans and CI pipelines alike, with no way to sneak around them.
Two engines dominate, and the choice is mostly about the language you want to write rules in. OPA Gatekeeper uses Rego, a real purpose-built policy language — powerful and reusable across non-Kubernetes systems, but a genuine learning curve. Kyverno writes policies in plain YAML that looks like the Kubernetes resources you already know, so most teams get productive in an afternoon. The mistake that bites everyone regardless of engine is going straight to enforce: an Enforce policy with a subtle bug can block every deployment cluster-wide — including the deploy that fixes the policy. Always roll out in audit mode first, watch the violation reports, and only flip to enforce once it's clean; and always exempt kube-system so a bad rule can't brick the control plane itself.
Pod Security is fixed to three profiles. For your org rules — 'every image must come from our registry', 'every namespace must have a cost-center label', 'no :latest tags' — you need a policy engine running as a validating (and mutating) admission webhook. Two dominate: OPA Gatekeeper (policies in Rego, via ConstraintTemplates + Constraints) and Kyverno (policies in YAML, no new language).
Follow the blue arrows left-to-right: a request travels to the API server, out to the policy engine, and then splits into one of two outcomes.
- The left box is any request — a human running
kubectlor a CI pipeline — asking to create or update an object. - The API server pauses the request at its admission webhook and phones the policy engine (green) — Gatekeeper (Rego) or Kyverno (YAML) — asking "does this pass our rules?"
- The two arrows out of the engine are the verdict: the green path admits the object so it gets scheduled; the red path rejects it at the door, before it runs. Because it sits in the request path, the rule applies to everyone with no way around it.
In short: The engine is a checkpoint in the create/update path — write a rule once and every object, from any source, is validated (or rejected) before admission.
| OPA Gatekeeper | Kyverno | |
|---|---|---|
| Policy language | Rego (a real language — powerful, a learning curve) | YAML (Kubernetes-native, easy to start) |
| Mutation | Limited / assign mutators | First-class mutate + generate |
| Reuse | ConstraintTemplate reused across Constraints | Per-policy rules |
| Good when | Complex logic, sharing Rego with non-k8s systems | Most k8s teams wanting fast, readable policy |
kyverno-registry.yamlapiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata: { name: allowed-registries }
spec:
validationFailureAction: Enforce # block on violation (Audit to observe first)
rules:
- name: only-acme-registry
match:
any: [ { resources: { kinds: [Pod] } } ]
validate:
message: "images must come from ghcr.io/acme/"
pattern:
spec:
containers:
- image: "ghcr.io/acme/*"
registry.regopackage k8sallowedregistry
violation[{"msg": msg}] {
container := input.review.object.spec.containers[_]
not startswith(container.image, "ghcr.io/acme/")
msg := sprintf("image %v not from an approved registry", [container.image])
}
Enforce policy with a bug can block all deploys cluster-wide, including the fix. Also decide the webhook failurePolicy: Fail (fail-closed — safer, but if the policy pod is down, admissions are blocked) vs Ignore (fail-open — availability over enforcement). Exempt kube-system. Verify current CRD/field names against the Kyverno/Gatekeeper docs.4 · Secrets: base64 is not encryption
Here's the sentence that has ended careers: "it's fine, it's in a Kubernetes Secret." The word "Secret" does a lot of unearned reassurance. New engineers see the value come back as an unreadable string of characters and assume it's encrypted and safe. It is not. A Kubernetes Secret is merely base64-encoded — and base64 is not a lock, it's an envelope. Anyone who can read the Secret object, or read the etcd database behind the API, or grab a backup of it, can decode the value back to plaintext in one command. Your API keys, your database passwords, your model provider tokens sit there in a costume, not a vault.
The analogy worth internalizing: base64 is Pig Latin, not a cipher. It scrambles the letters into something you can't read at a glance, but it uses no key and hides nothing from anyone who bothers to unscramble it. So the phrase "base64 is not encryption" isn't pedantry — it's the whole point of this section. Treating a raw Secret as secure is the classic mistake, and it shows up in two flavors: assuming the encoded value protects the data, and — worse — committing that "Secret" YAML straight into a Git repo, where it's now permanently in the history for every person and CI system with repo access.
So the real work of secrets management is two separate questions. First, defense at rest: turn on encryption-at-rest for etcd (a provider setting, or KMS-backed on EKS) so a stolen etcd snapshot is ciphertext, and lock down who can get secrets via RBAC — because a token that can read secrets can usually escalate. Second, and more architecturally interesting, where does the source of truth actually live and how does the value get into the cluster without ever touching Git in plaintext? That question has three well-worn answers — External Secrets, Sealed Secrets, and Vault — each making a different trade about where the real secret lives and who is allowed to decrypt it.
A Kubernetes Secret is only base64-encoded, not encrypted — anyone who can read the object (or etcd) reads the value. Two baseline hardening steps: enable encryption at rest for etcd (a provider config, or KMS on EKS) and lock down secrets RBAC. But the real question is where the source of truth lives and how it gets in — three common patterns:
The dashed line splits the picture: everything on the left is what lives in your Git repo, everything on the right is what happens inside the cluster. Read each row as one pattern.
- External Secrets (top): you commit only an
ExternalSecretreference; the operator fetches the real value from AWS Secrets Manager or Vault and syncs a k8s Secret. - Sealed Secrets (middle): you commit ciphertext encrypted to the cluster's public key; only the in-cluster controller, holding the private key, can decrypt it.
- The red bottom row is the trap: a raw Kubernetes Secret is only base64 — readable by anyone — so the "never commit" arrow is the whole lesson. Vault takes the opposite approach and injects short-lived, leased secrets at runtime.
In short: All three keep plaintext out of Git — commit a reference, commit ciphertext, or inject at runtime — never the raw base64 Secret.
| Approach | How it works | Best for |
|---|---|---|
| External Secrets Operator | Syncs secrets from AWS Secrets Manager / Vault into k8s Secrets | Cloud-hosted secret store as source of truth |
| Sealed Secrets | Encrypt a Secret to a cluster public key; the ciphertext is safe to commit to Git | GitOps — secrets in the repo, decryptable only in-cluster |
| Vault (agent/CSI) | Inject secrets into pods at runtime; short-lived, dynamic, leased | Dynamic/rotating credentials, strong audit |
externalsecret.yamlapiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata: { name: llm-api-keys, namespace: web }
spec:
refreshInterval: 1h
secretStoreRef: { name: aws-sm, kind: ClusterSecretStore }
target: { name: llm-api-keys } # the k8s Secret it creates/keeps in sync
data:
- secretKey: ANTHROPIC_API_KEY
remoteRef: { key: prod/llm-api, property: anthropic_api_key }
5 · Supply-chain admission: only run trusted images
Everything so far protects the cluster from what runs inside it — this section protects it from what you never meant to run at all. Your CI pipeline builds a clean, scanned, signed image and pushes it. Good. But the cluster will happily run any image it's told to: a public image someone typo'd into a manifest, an image from an attacker who phished a registry credential, a stale tag that got repointed at malicious content overnight. The image name in a Pod spec is a claim, and until now nobody has been checking whether that claim is trustworthy. Supply-chain admission is the check.
Think of it as passport control for containers. In cd6 you gave each image a tamper-evident passport at build time: you scanned it, generated an SBOM (its ingredient list), signed it with cosign (a cryptographic seal only your pipeline's key can produce), and attached provenance (a SLSA attestation of how and where it was built). All of that is worthless, though, if the border guard waves everyone through. The admission policy is the border guard: at the cluster door it verifies the cosign signature against your public key and rejects anything that isn't sealed by you — so a leaked registry cred or a wrong image reference simply cannot board.
The mistake to avoid is thinking any one of these controls is "the" answer, because they defend at different moments against different threats — and this is the key insight of the section: digest ≠ signature ≠ scan. Pinning by digest (@sha256:…) freezes exactly which bytes run so a tag can't be swapped under you. Signature verification at admission proves those bytes came from your pipeline. Ongoing scanning catches CVEs that are discovered after you deployed, against images that were perfectly clean the day they shipped. Skip any one and you have a gap: a signed image can still be vulnerable, a scanned image can still be swapped, a pinned image can still be malicious if it was never yours. You want all three, running at build, at admission, and on a cadence.
cd6 built trust into the image — scanning, an SBOM, a signature (cosign), and provenance (SLSA attestation). This lesson enforces that trust at the cluster door: an admission policy that rejects any image that isn't signed by your key and pinned by digest. Now a leaked registry credential or a typo'd public image can't run in prod.
Follow the four boxes left-to-right along the arrows — it's the life of an image from your pipeline to the cluster door.
- The first box, Build + sign (cd6), is where trust is created: the pipeline signs the image with cosign and generates an SBOM.
- The middle arrows carry the image on: it's pushed with a digest (an immutable reference the tag can't be swapped under), then reaches Admission verifies sig — Kyverno or a policy-controller checking the cosign signature.
- The payoff is the last box: Run or reject — only an image signed by your key and pinned by digest is admitted, so a leaked credential or typo'd public image can't board.
In short: sign at build, pin by digest on push, verify the signature at admission — only trusted, pipeline-built images run.
verify-images.yamlapiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata: { name: verify-signatures }
spec:
validationFailureAction: Enforce
rules:
- name: check-cosign
match: { any: [ { resources: { kinds: [Pod] } } ] }
verifyImages:
- imageReferences: [ "ghcr.io/acme/*" ]
attestors:
- entries:
- keys: { publicKeys: |
-----BEGIN PUBLIC KEY-----
...your cosign public key...
-----END PUBLIC KEY----- }
@sha256:…) so the tag can't be moved under you; verify a signature at admission so only images your pipeline built can run; and keep scanning (cd6) on a cadence because new CVEs land against images you already deployed. Digest ≠ signature ≠ scan — you want all three. Verify cosign/Kyverno verifyImages fields against current docs.6 · NetworkPolicy: the in-cluster firewall
Here's the fact that surprises almost everyone the first time they hear it: by default, every pod in your cluster can open a network connection to every other pod. Your public-facing frontend can talk straight to your database. A logging sidecar can reach the payments service. A compromised, low-value pod in one namespace can port-scan and connect to the crown-jewel workload three namespaces away. Kubernetes ships with a completely flat, open network — great for getting started, terrifying once you assume (as security demands you do) that something will eventually be breached.
The fix is a NetworkPolicy, and the mental model is exactly the office building again — this time the internal locked doors between departments, not just the front entrance. An open-plan office where anyone can walk into any room is convenient until one visitor turns out to be a thief; then you wish accounting had been behind a badge reader. NetworkPolicy lets you say, by label selector, "only pods labelled app: api may connect to pods labelled app: db, and only on port 5432." The defense-in-depth pattern is to start with a default-deny in each namespace — lock every internal door — and then reopen only the specific flows your application actually needs. This is what contains lateral movement: a compromised frontend can no longer wander to the database just because they share a network.
But there's a trap here nastier than anything else in this lesson, because it fails silently. A NetworkPolicy is only a request — it does nothing on its own. The actual enforcement is done by your cluster's CNI (the networking plugin: Calico, Cilium, or EKS's VPC CNI with policy enabled). If your CNI doesn't enforce policy, the API server still accepts your NetworkPolicy YAML without complaint, it shows up in kubectl get, and traffic flows exactly as before. You get a beautiful audit-ready policy and zero protection — a false sense of security, which is arguably worse than none. So the non-negotiable step is to confirm your CNI enforces policy and then test it: curl from a pod that should be denied and watch it actually fail before you trust the lock.
By default, every Pod can talk to every other Pod — a flat network. A compromised frontend can reach your database directly. A NetworkPolicy restricts pod-to-pod traffic by label selector; the defense-in-depth move is a default-deny in each namespace, then allow only the flows you need. (Note: NetworkPolicy needs a CNI that enforces it — Calico, Cilium; some setups ignore it silently.)
Compare the two halves side by side: the same set of pods, once with no policy (left) and once with a default-deny plus a single allow-rule (right).
- Left — the default flat network: every red arrow lands on the
db. Both the frontend and a compromised pod can open a connection to the database because Kubernetes allows all pod-to-pod traffic out of the box. - Right — default-deny plus one allow: the single green arrow is the only permitted flow —
api→dbon port 5432. The dashed red arrow from the compromised pod is now blocked. - The payoff is the blocked dashed arrow: a compromised pod can no longer wander to the database, which is exactly how a NetworkPolicy contains lateral movement.
In short: Turn the flat "everyone can reach the db" network into "only api on 5432 can" — so a breached pod is boxed in instead of free to roam.
networkpolicy.yaml# 1) Deny all ingress in the namespace:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: default-deny-ingress, namespace: web }
spec:
podSelector: {} # all pods
policyTypes: [Ingress]
---
# 2) Allow only the api pods to reach the db pods on 5432:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: allow-api-to-db, namespace: web }
spec:
podSelector: { matchLabels: { app: db } }
policyTypes: [Ingress]
ingress:
- from: [ { podSelector: { matchLabels: { app: api } } } ]
ports: [ { protocol: TCP, port: 5432 } ]
curl from a non-allowed pod. Verify CNI policy support against your platform's current docs.✓ Checkpoint — you can move on when you can…
- Explain Role vs ClusterRole and RoleBinding vs ClusterRoleBinding, and bind a Role to a ServiceAccount.
- Audit a ServiceAccount's real permissions with
kubectl auth can-iand name the three RBAC sins. - Apply a Pod Security Standard and roll it out via audit/warn before enforce.
- Write a Gatekeeper or Kyverno policy and explain audit-first + fail-open vs fail-closed.
- Choose between External Secrets, Sealed Secrets and Vault, and say why base64 Secrets aren't secure.
- Enforce signed-image admission and a default-deny NetworkPolicy — and the CNI caveat.
A workload 'wasn't working', so a teammate ran kubectl create clusterrolebinding fix --clusterrole=cluster-admin --serviceaccount=web:llm-api and it started working. What's the risk, and how should it have been fixed?
Show answer
kubectl auth can-i. cluster-admin on a workload is almost never correct.Your team commits Kubernetes manifests to Git for GitOps, but security says 'no plaintext secrets in the repo'. You also can't manually kubectl create secret (that breaks GitOps). What are your options?
Show answer
kubeseal; the resulting ciphertext is safe to commit, and only the in-cluster controller (holding the private key) can decrypt it into a real Secret. External Secrets Operator: commit only a reference (an ExternalSecret pointing at AWS Secrets Manager / Vault); the operator fetches the value at runtime and keeps a k8s Secret in sync. Both keep the plaintext out of Git while staying declarative. Also enable etcd encryption-at-rest, since a k8s Secret is only base64.🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Most 'permission denied' fixes are over-corrected into cluster-admin; least privilege is the habit.
Your task: Give a pod read-only access to ConfigMaps in its namespace — and nothing else — via a ServiceAccount.
Requirements:
- Create a ServiceAccount for the workload
- Write a namespaced Role for get/list/watch on configmaps
- Bind it with a RoleBinding
- Show how you'd verify it can't read secrets
💡 Hint: kubectl auth can-i --as=system:serviceaccount:
Show solution
kind: ServiceAccount
metadata: { name: llm-api, namespace: web }
---
kind: Role
metadata: { name: read-config, namespace: web }
rules: [ { apiGroups: [""], resources: [configmaps], verbs: [get,list,watch] } ]
---
kind: RoleBinding
metadata: { name: llm-api-rb, namespace: web }
roleRef: { kind: Role, name: read-config, apiGroup: rbac.authorization.k8s.io }
subjects: [ { kind: ServiceAccount, name: llm-api, namespace: web } ]Verify: kubectl auth can-i get configmaps -n web --as=system:serviceaccount:web:llm-api → yes; ... can-i get secrets ... → no. The workload references the SA via spec.serviceAccountName: llm-api. Needs a cluster; verify vs docs.
Context: Enforcing that pods run non-root and drop capabilities stops a whole class of container escapes.
Your task: Enforce the 'restricted' Pod Security Standard on a namespace and make a Pod comply, rolling it out safely.
Requirements:
- Label the namespace to enforce restricted
- Show the securityContext a compliant Pod needs
- Describe the safe rollout order
- Note it's admission-time, per-namespace
💡 Hint: audit + warn first, then enforce, so you don't break running deploys.
Show solution
# Namespace
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/warn: restricted
# Compliant Pod
securityContext: { runAsNonRoot: true, seccompProfile: { type: RuntimeDefault } }
containers:
- securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: [ALL] }Rollout: set audit+warn first, watch violations on next deploys, fix securityContexts, then flip to enforce. Pod Security Admission is built in and evaluated at admission per namespace. Needs a cluster; verify the versioned restricted requirements vs docs.
Context: Pod Security can't express 'images only from our registry' — that's a policy-engine job.
Your task: Write a Kyverno ClusterPolicy that blocks images not from your registry, and explain the safe rollout.
Requirements:
- Match Pods and validate the image prefix
- Give a clear failure message
- Explain Audit-then-Enforce
- Mention failurePolicy fail-open vs fail-closed and exempting kube-system
💡 Hint: validationFailureAction: Audit first; then Enforce once it's clean.
Show solution
kind: ClusterPolicy
metadata: { name: allowed-registries }
spec:
validationFailureAction: Enforce # start as Audit
rules:
- name: only-acme
match: { any: [ { resources: { kinds: [Pod] } } ] }
validate:
message: "images must come from ghcr.io/acme/"
pattern: { spec: { containers: [ { image: "ghcr.io/acme/*" } ] } }Rollout: deploy with Audit, review policy reports for violators, fix them, then flip to Enforce. Decide the webhook failurePolicy: Fail (safer, but blocks admission if the policy pod is down) vs Ignore; always exempt kube-system so a bad policy can't brick the control plane. Needs a cluster; verify vs Kyverno docs.
Context: Your GitOps repo must not contain plaintext secrets, but manual kubectl breaks the GitOps model.
Your task: Design a secrets flow that keeps plaintext out of Git while staying fully declarative, comparing two approaches.
Requirements:
- Explain why a k8s Secret alone isn't secure
- Present Sealed Secrets and External Secrets
- State when each is the better fit
- Add etcd encryption-at-rest as a baseline
💡 Hint: Commit ciphertext (Sealed) or a reference (External) — never the value.
Show solution
Why base64 isn't enough: a k8s Secret is base64-encoded, so anyone reading the object or etcd reads the value — enable encryption-at-rest (KMS on EKS) as a baseline and lock down secrets RBAC.
Sealed Secrets: kubeseal encrypts to the cluster's public key; commit the ciphertext, and only the in-cluster controller can decrypt it. Best when you want the secret (encrypted) to live in the repo.
External Secrets Operator: commit an ExternalSecret that references AWS Secrets Manager / Vault; the operator syncs the real value into a k8s Secret. Best when a cloud secret store is your source of truth and you want rotation. Both are declarative and GitOps-safe. Verify CRD apiVersions vs docs.
Context: A leaked registry cred or a typo'd public image running in prod is a supply-chain incident waiting to happen.
Your task: Design cluster admission that only admits images built and signed by your pipeline, tying it to cd6.
Requirements:
- Reference the cd6 build-time trust (scan, SBOM, cosign sign, provenance)
- Enforce signature verification at admission
- Require digest-pinning
- Keep ongoing scanning and state the three distinct layers
💡 Hint: Digest ≠ signature ≠ scan — you want all three, at different times.
Show solution
Build time (cd6): scan the image, generate an SBOM, sign with cosign, and attach SLSA provenance in the pipeline.
Admission time: a Kyverno verifyImages (or the sigstore policy-controller) rule rejects any ghcr.io/acme/* image not signed by your public key — so only pipeline-built images run.
verifyImages:
- imageReferences: [ "ghcr.io/acme/*" ]
attestors: [ { entries: [ { keys: { publicKeys: "" } } ] } ] Three distinct layers: (1) digest pinning so the tag can't be moved; (2) signature verification so only your builds run; (3) ongoing scanning because new CVEs hit already-deployed images. Needs a cluster; verify cosign/Kyverno fields vs current docs.
Context: Representative scenario: you run a multi-tenant LLM platform on EKS for several product teams and must pass a security review — 'assume a pod will be compromised; limit the blast radius'.
Your task: Produce a layered security design spanning identity, workload hardening, policy, secrets, supply chain and network, and name the failure mode each layer contains.
Requirements:
- Least-privilege RBAC per workload/tenant with no shared cluster-admin
- Pod Security restricted + policy engine for org rules
- GitOps-safe secrets with encryption-at-rest
- Signed-image admission tied to cd6
- Default-deny NetworkPolicy with an enforcing CNI
- Name the failure mode each layer defends against
💡 Hint: Each layer assumes the one outside it failed — that's defense-in-depth.
Show solution
Identity (RBAC): per-workload ServiceAccounts with minimal Roles, per-tenant namespaces, no cluster-admin on workloads, automountServiceAccountToken: false where unused. → contains a compromised pod to its namespace/verbs.
Workload hardening (Pod Security restricted): non-root, drop ALL caps, read-only rootfs, seccomp. → contains container escape / privilege escalation.
Policy engine (Kyverno): enforce org rules — approved registries, required labels, no :latest, no hostPath — audit-first, fail-closed with kube-system exempt. → contains misconfiguration drift.
Secrets: External/Sealed Secrets (no plaintext in Git), etcd encryption-at-rest, tight secrets RBAC. → contains secret leakage via repo/etcd.
Supply chain (cd6 + admission): scan + SBOM + cosign sign at build; verify signature + digest at admission; scan on a cadence. → contains malicious/vulnerable images.
Network: default-deny NetworkPolicy per namespace, allow only required flows, enforcing CNI (Calico/Cilium). → contains lateral movement.
The principle: every layer assumes the one outside it already failed, so no single flaw is catastrophic — the blast radius of a compromised pod is its namespace, its narrow RBAC, and its allowed network flows, nothing more. Verify every CRD/label/field against current docs; this area drifts fast.