AI EngineeringZero to ProductionHome·About·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

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 on resources get,list,create...
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 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.

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 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).

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

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:

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

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 (cd6) cosign, SBOM Push w/ digest immutable ref Admission verifies sig Kyverno/policy-controller Run or reject trusted only
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

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.)

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-apiyes; ... 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