Part VI — Security
34. Securing the cluster and the workloads
Defense in depth, in the order an attacker meets it: supply chain (§8, §34.6) → admission (what is allowed to run) → runtime privileges (what a compromised pod can do) → network (what it can reach) → identity (what it can access) → detection (Falco, audit logs).
34.1 ServiceAccounts and RBAC
Every workload gets its own ServiceAccount. Mount a token only if it calls the Kubernetes API.
apiVersion: v1
kind: ServiceAccount
metadata: { name: orders, namespace: orbit }
automountServiceAccountToken: false
RBAC: Role/ClusterRole (verbs on resources) + RoleBinding/ClusterRoleBinding
(to users, groups, ServiceAccounts). Example: a read-only on-call role for
the orbit namespace.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: { name: oncall-readonly, namespace: orbit }
rules:
- apiGroups: ["", "apps", "batch", "autoscaling", "keda.sh", "argoproj.io"]
resources: ["pods", "pods/log", "services", "endpoints", "configmaps", "events",
"deployments", "replicasets", "statefulsets", "jobs", "cronjobs",
"horizontalpodautoscalers", "scaledobjects", "rollouts"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["pods/portforward", "pods/ephemeralcontainers"] # debugging, audited
verbs: ["create", "patch"]
# deliberately NO secrets
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: { name: oncall-readonly, namespace: orbit }
subjects: [{ kind: Group, name: "orbit-oncall", apiGroup: rbac.authorization.k8s.io }] # from your SSO/OIDC
roleRef: { kind: Role, name: oncall-readonly, apiGroup: rbac.authorization.k8s.io }
kubectl auth can-i list secrets -n orbit --as-group=orbit-oncall --as=jane
kubectl auth can-i --list -n orbit --as=system:serviceaccount:orbit:orders
RBAC escalation traps: create pods in a namespace means access to every
Secret and ServiceAccount there (mount it into a new pod). escalate,
bind and impersonate verbs, wildcards (*), and nodes/proxy are
effectively cluster-admin. Audit them with tools like rbac-tool or
kubectl-who-can.
34.2 Pod Security Admission
Built in (replaced PodSecurityPolicy). Label namespaces with a level:
privileged, baseline, or restricted.
kubectl label namespace orbit \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/enforce-version=latest \
pod-security.kubernetes.io/warn=restricted \
pod-security.kubernetes.io/audit=restricted
restricted requires exactly the securityContext in our golden manifest:
non-root, no privilege escalation, drop: ALL, a seccomp profile. Roll it
out with warn/audit first, read the warnings, then enforce.
34.3 NetworkPolicy: default deny, then allow what's needed
By default every pod can talk to every pod in every namespace. A
compromised catalog could reach Postgres, the Kubernetes API, and the
cloud metadata endpoint. NetworkPolicies need a CNI that enforces them
(Cilium, Calico; recent kind's kindnet does).
# 1. default deny everything in the namespace (ingress AND egress)
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: default-deny, namespace: orbit }
spec:
podSelector: {}
policyTypes: [Ingress, Egress]
---
# 2. everyone may resolve DNS
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: allow-dns, namespace: orbit }
spec:
podSelector: {}
policyTypes: [Egress]
egress:
- to:
- namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: kube-system } }
podSelector: { matchLabels: { k8s-app: kube-dns } }
ports: [{ protocol: UDP, port: 53 }, { protocol: TCP, port: 53 }]
---
# 3. the gateway may reach catalog and orders
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: api-from-gateway, namespace: orbit }
spec:
podSelector:
matchExpressions: [{ key: app.kubernetes.io/name, operator: In, values: [catalog, orders] }]
policyTypes: [Ingress]
ingress:
- from: [{ namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: envoy-gateway-system } } }]
ports: [{ port: 8080 }]
---
# 4. Postgres accepts only orders, catalog, worker (+ CNPG operator and its own replicas)
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: postgres-ingress, namespace: orbit }
spec:
podSelector: { matchLabels: { cnpg.io/cluster: orbit-db } }
policyTypes: [Ingress]
ingress:
- from:
- podSelector:
matchExpressions: [{ key: app.kubernetes.io/name, operator: In, values: [catalog, orders, worker] }]
- podSelector: { matchLabels: { cnpg.io/cluster: orbit-db } } # replication
- namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: cnpg-system } } # operator
- namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: monitoring } } # metrics :9187
ports: [{ port: 5432 }, { port: 8000 }, { port: 9187 }]
---
# 5. app egress: Postgres, Redis, and the node-local OTel agent
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: app-egress, namespace: orbit }
spec:
podSelector:
matchExpressions: [{ key: app.kubernetes.io/name, operator: In, values: [catalog, orders, worker] }]
policyTypes: [Egress]
egress:
- to: [{ podSelector: { matchLabels: { cnpg.io/cluster: orbit-db } } }]
ports: [{ port: 5432 }]
- to: [{ podSelector: { matchLabels: { app.kubernetes.io/name: redis } } }]
ports: [{ port: 6379 }]
- to: [{ ipBlock: { cidr: 172.18.0.0/16 } }] # node IPs (kind's docker network): OTel agent hostPort
ports: [{ port: 4317 }]
Write the policies (and the remaining Redis ingress / monitoring policies) while you build the service, not afterwards. Then test them:
kubectl run -n orbit -it --rm probe --image=nicolaka/netshoot --restart=Never -- \
nc -zv -w2 orbit-db-rw 5432 # expect: timeout (probe pod isn't allowed)
Cilium adds L7 (HTTP method/path) and FQDN egress policies
(toFQDNs: api.stripe.com). It also adds Hubble, which shows every
dropped flow and why, and turns NetworkPolicy debugging from guesswork
into a query.
War story: pods on cloud VMs can reach the instance metadata endpoint (
169.254.169.254) by default and fetch the node's IAM credentials. Several cloud breaches started with an SSRF in a web app that fetched that URL. Block it with an egress policy (allow0.0.0.0/0except169.254.169.254/32), enforce IMDSv2 with hop limit 1 on AWS, and use workload identity (§42.3) so pods never need node credentials.
34.4 Secrets management: External Secrets Operator
The source of truth lives in a real secret manager (AWS Secrets Manager, GCP Secret Manager, Vault, Azure Key Vault). ESO syncs it into Kubernetes Secrets and refreshes on rotation. Git holds only references.
helm repo add external-secrets https://charts.external-secrets.io
helm upgrade --install external-secrets external-secrets/external-secrets -n external-secrets --create-namespace
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata: { name: aws-secrets-manager }
spec:
provider:
aws:
service: SecretsManager
region: us-east-1
auth: # the ESO pod's own IAM identity (Pod Identity / IRSA): no static keys
jwt: { serviceAccountRef: { name: external-secrets, namespace: external-secrets } }
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata: { name: payment-provider, namespace: orbit }
spec:
refreshInterval: 1h
secretStoreRef: { kind: ClusterSecretStore, name: aws-secrets-manager }
target: { name: payment-provider, creationPolicy: Owner }
data:
- secretKey: API_KEY
remoteRef: { key: prod/orbit/payment-provider, property: api_key }
Alternatives: Sealed Secrets (encrypt with the cluster's public key, commit the ciphertext; simple, cluster-bound), and SOPS + age/KMS (encrypted YAML in Git, decrypted by Argo CD/Flux plugins). For database credentials, CNPG already generates and stores them.
⚠️ Caveat (verify): (1) NetworkPolicy enforcement on kind depends on the kind/kindnet version; recent releases enforce it, older ones silently ignore policies. Test with the
ncprobe before trusting the policies, or install Cilium. (2)172.18.0.0/16is kind's usual Docker network; confirm withdocker network inspect kind | jq '.[0].IPAM'. (3) Check CNPG's instance ports (5432, status 8000, metrics 9187) in its docs. (4) External Secrets uses theexternal-secrets.io/v1API on recent releases (older ones usev1beta1), and the AWSauth.jwtshape differs from Pod Identity setups; check the provider docs.
34.5 Admission policy: guardrails as code
ValidatingAdmissionPolicy (built in, CEL expressions, GA since 1.30) needs no extra components:
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata: { name: orbit-workload-standards }
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- { apiGroups: ["apps"], apiVersions: ["v1"], operations: ["CREATE", "UPDATE"], resources: ["deployments"] }
validations:
- expression: "object.spec.template.spec.containers.all(c, !c.image.endsWith(':latest') && c.image.contains(':') || c.image.contains('@sha256:'))"
message: "images must be pinned to a tag or digest, never :latest"
- expression: "object.spec.template.spec.containers.all(c, has(c.resources.requests) && has(c.resources.limits) && has(c.resources.limits.memory))"
message: "every container needs requests and a memory limit"
- expression: "object.spec.template.spec.containers.all(c, has(c.readinessProbe))"
message: "every container needs a readinessProbe"
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata: { name: orbit-workload-standards }
spec:
policyName: orbit-workload-standards
validationActions: [Deny] # start with [Warn, Audit]
matchResources:
namespaceSelector: { matchLabels: { orbit.acme.example/enforce: "true" } }
Kyverno (YAML policies; validate, mutate, generate, verify images) or OPA Gatekeeper (Rego) cover what CEL can't, plus a policy library and reporting.
War story: an admission webhook with
failurePolicy: Failwhose pods were themselves down blocked every pod creation in the cluster, including its own replacement pods and CoreDNS. Excludekube-systemand the policy engine's own namespace, run ≥ 3 replicas with a PDB, and set webhook timeouts low.
34.6 Only run signed images (Kyverno verifyImages)
CI signs images keylessly (§35). This policy rejects any orbit image not
signed by our workflow on our repo:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata: { name: verify-orbit-images }
spec:
validationFailureAction: Enforce
webhookTimeoutSeconds: 15
rules:
- name: verify-signature
match: { any: [{ resources: { kinds: [Pod], namespaces: ["orbit*"] } }] }
verifyImages:
- imageReferences: ["ghcr.io/acme/orbit-*"]
mutateDigest: true # rewrite tag -> digest: what was verified is what runs
attestors:
- entries:
- keyless:
issuer: https://token.actions.githubusercontent.com
subject: "https://github.com/acme/orbit/.github/workflows/ci.yaml@refs/heads/main"
rekor: { url: https://rekor.sigstore.dev }
⚠️ Caveat (verify): Kyverno is moving image verification to newer policy types (
ImageValidatingPolicy), and spec-levelvalidationFailureActionis deprecated in favor of per-rulefailureAction. Check this ClusterPolicy against your Kyverno version withkyverno apply/kubectl apply --dry-run=server. The keylesssubjectmust match your workflow path and ref exactly; check it withcosign verify --certificate-identity ... --certificate-oidc-issuer ...first.
34.7 Runtime detection and continuous audit
- Falco (eBPF) alerts on suspicious runtime behavior: a shell spawned
in a container, writes to
/etc, unexpected outbound connections, reading/proc/*/environ. - Kubernetes audit logs: who did what via the API. Enable them on
managed clusters, ship them to your SIEM, and alert on
execinto prod pods, Secret reads by humans, and RBAC changes. - trivy-operator / Kubescape: continuously scan running workloads for CVEs, misconfigurations, and exposed secrets.
- kube-bench: CIS Kubernetes Benchmark checks for nodes and the control plane.