AI EngineeringZero to ProductionHome·About·What’s new·Contact
Kubernetes Orchestration · Part 9

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.

⏱️ ~120 min🔒 Security🎯 Advanced→Production
🌱 Honesty up front & the cd6 linkAll YAML/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.

Subject SA / user / group Binding RoleBinding Role Role / ClusterRole Verbs get, list, create…
🗺️ How to read this diagram

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.

ObjectScopeUse it for
RoleOne namespacePermissions inside a single namespace (most app grants)
ClusterRoleCluster-wide OR reusableCluster resources (nodes), or a reusable rule set bound per-namespace
RoleBindingOne namespaceGrant a Role (or a ClusterRole, scoped to this ns) to a subject
ClusterRoleBindingCluster-wideGrant a ClusterRole everywhere — powerful, use sparingly
Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
yaml · a least-privilege Role + binding to a ServiceAccount (needs a cluster; verify vs docs)
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
bash · audit what a ServiceAccount can actually do (needs a cluster)
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
The three RBAC sins(1) Binding cluster-admin (or 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.

three standards · strictness increases → privileged — anything goes baseline — blocks risky restricted non-root · drop caps · seccomp · read-only rootfs safe rollout of a standard → audit warn enforce watch who would fail, fix, then reject runs at admission — bad pod rejected on kubectl apply Pick a level, then roll it out gently. The rings on the left go from wide-open (privileged) to hardened (restricted); the right shows why you set audit+warn before enforce — so a security label doesn't cause the outage.
🗺️ How to read this diagram

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.

yaml · label a namespace to enforce the 'restricted' standard (needs a cluster; verify vs docs)
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:

yaml · a Pod securityContext that passes 'restricted' (needs a 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"] }
Roll out with audit/warn before enforceFlipping a busy namespace straight to 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).

kubectl / CI create / update API server admission webhook Policy engine Gatekeeper / Kyverno admit → scheduled reject at the door your rules, enforced on humans and pipelines alike The policy engine is customs control. Every create/update clears the API server's admission webhook first; the engine matches your rules and either admits the object or rejects it before it ever schedules.
🗺️ How to read this diagram

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 kubectl or 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 GatekeeperKyverno
Policy languageRego (a real language — powerful, a learning curve)YAML (Kubernetes-native, easy to start)
MutationLimited / assign mutatorsFirst-class mutate + generate
ReuseConstraintTemplate reused across ConstraintsPer-policy rules
Good whenComplex logic, sharing Rego with non-k8s systemsMost k8s teams wanting fast, readable policy
yaml · Kyverno: require images from an approved registry (needs a cluster; verify vs docs)
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/*"
rego · the same rule as a Gatekeeper ConstraintTemplate (illustrative; verify vs docs)
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])
}
Start in audit, and mind fail-open vs fail-closedRoll new policies out in audit/observe mode first — an 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:

Git repo (public-ish) the cluster ExternalSecret (reference) operator pulls from AWS SM / Vault External Secrets SealedSecret (ciphertext) controller decrypts w/ private key Sealed Secrets raw Secret = base64 only Vault injects at runtime, leased never commit → Keep plaintext out of Git. External Secrets commits only a reference, Sealed Secrets commits ciphertext, Vault injects at runtime — but a raw base64 Secret in the repo (bottom) is readable by anyone and must never be committed.
🗺️ How to read this diagram

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 ExternalSecret reference; 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.

ApproachHow it worksBest for
External Secrets OperatorSyncs secrets from AWS Secrets Manager / Vault into k8s SecretsCloud-hosted secret store as source of truth
Sealed SecretsEncrypt a Secret to a cluster public key; the ciphertext is safe to commit to GitGitOps — secrets in the repo, decryptable only in-cluster
Vault (agent/CSI)Inject secrets into pods at runtime; short-lived, dynamic, leasedDynamic/rotating credentials, strong audit
yaml · External Secrets Operator pulling from AWS Secrets Manager (needs a cluster; verify vs docs)
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 }
GitOps + secrets = Sealed Secrets or External SecretsGitOps (K6) wants everything in Git, but you can't commit a plaintext Secret. Two clean answers: Sealed Secrets (commit the encrypted blob; only the in-cluster controller can decrypt it) or External Secrets (commit only a reference; the operator fetches the value from a real secret store). Never commit a raw Secret; never bake keys into an image. Verify the CRD apiVersion — External Secrets has changed it.

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.

Build + sign cosign, SBOM (cd6) Push w/ digest immutable ref Verify signature Kyverno / policy Run or reject trusted only
🗺️ How to read this diagram

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.

yaml · Kyverno verifyImages: only admit cosign-signed images (needs a cluster; verify vs docs)
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----- }
Pin by digest, verify by signature, scan on a cadenceThree layers: pin by digest (@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.)

default: flat network frontend compromised db anyone reaches the db — lateral movement default-deny, then allow one flow api compromised db :5432 ✓ only api→db:5432 allowed; rest denied Default-deny contains the breach. On the flat network (left) any compromised pod reaches the database; with a default-deny plus one allow-rule (right), only app: api may reach app: db on 5432 — the compromised pod is walled off.
🗺️ How to read this diagram

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 → db on 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.

yaml · default-deny ingress, then allow only the API → DB flow (needs a cluster + enforcing CNI)
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 } ]
NetworkPolicy only works if your CNI enforces itApplying a NetworkPolicy on a cluster whose CNI doesn't enforce policy is a silent no-op — the YAML is accepted, traffic flows anyway, and you have a false sense of security. Confirm your CNI (Calico/Cilium/EKS VPC CNI with policy enabled) actually enforces it, and test with a denied 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-i and 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.
✓ Knowledge check

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
That grants the app's ServiceAccount full control of the entire cluster. Any RCE or prompt-injection in that pod now owns every namespace, every secret, every node — the blast radius is the whole cluster. The fix is least privilege: find the specific verbs/resources the app actually needs (usually just reading a ConfigMap or nothing at all), write a namespaced Role granting exactly those, and bind it with a RoleBinding to that SA. Then verify with kubectl auth can-i. cluster-admin on a workload is almost never correct.
✓ Knowledge check

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
Two GitOps-compatible patterns. Sealed Secrets: encrypt the Secret against the cluster's public key with 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.

Exercise 1 · Grant a workload least-privilege API accessBeginner

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:: proves the grant.

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.

Exercise 2 · Harden a namespace with Pod SecurityIntermediate

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.

Exercise 3 · Write an org policy in KyvernoAdvanced

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.

Exercise 4 · GitOps-safe secretsExpert

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.

Exercise 5 · Only run signed, trusted imagesProfessional

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.

Exercise 6 · Defense-in-depth security design for a shared platformIndustry scenario

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.

© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in