feat(root): add Kubernetes security curriculum

Four new modules, moving ROOT 09 off PLANNED and building the
22-scenario CKS-style bank into a coherent course instead of
disconnected exam tasks — see docs/ROOT_CURRICULUM_COVERAGE_MATRIX.md
for the CORE/GUIDED/INDEPENDENT/ADVANCED/ASSESSMENT-BANK classification
behind every placement.

root-09-m01-security-model-and-securitycontext — READY, full
gold-standard pattern: defense in depth, least privilege, hardening a
Pod with SecurityContext. Lesson, demonstration (a Pod before/after
hardening), knowledge check, guided exercise (a hardening checklist),
real Labs (CORE: non-root/drop-capabilities/read-only-fs/
pod-security-context; GUIDED: seccomp; ADVANCED: AppArmor; INDEPENDENT:
the generic pod-security-context duplicate), and a reflection.

root-09-m02-serviceaccounts-and-rbac — PARTIAL: workload identity and
least-privilege permissions. Lesson + knowledge check + real Labs.

root-09-m03-network-and-supplychain-security — PARTIAL: NetworkPolicy
as a security control (default-deny), image/registry trust, TLS
ingress. Lesson + knowledge check + real Labs.

root-09-m04-cluster-hardening-and-runtime — PARTIAL, deliberately
survey-depth: Pod Security Admission and a lighter treatment of
audit-policy/runtime-sandboxing topics that don't belong in the core
daily-driver path.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-08-26 09:07:07 -05:00
co-authored by Claude Sonnet 5
parent 73cea1ba1d
commit 960a638ec9
10 changed files with 633 additions and 0 deletions
@@ -0,0 +1,90 @@
# Identity for Workloads: ServiceAccounts & RBAC
## Workloads need an identity too, separate from yours
When YOU run `kubectl`, you're authenticated as yourself. But a Pod running INSIDE the cluster
that needs to call the Kubernetes API (to list other Pods, read a ConfigMap, whatever) needs its
own identity — it can't and shouldn't use your personal credentials. That identity is a
**ServiceAccount**.
Every Pod runs as SOME ServiceAccount — if you don't specify one, it uses the `default`
ServiceAccount in its Namespace, which by default has essentially no permissions. A Pod's
ServiceAccount token is automatically mounted into the container unless you explicitly disable
that (`automountServiceAccountToken: false`) — worth doing for any Pod that never actually needs
to call the Kubernetes API at all, since an unused, mounted credential is still something an
attacker could steal and use.
## RBAC: what that identity is actually allowed to do
Having an identity (a ServiceAccount) doesn't grant any permissions by itself — **RBAC
(Role-Based Access Control)** is the separate system that defines what an identity can DO.
- A **Role** (namespace-scoped) or **ClusterRole** (cluster-wide) defines a set of permissions:
which API resources, which verbs (get/list/watch/create/update/delete).
- A **RoleBinding** (or **ClusterRoleBinding**) connects a Role/ClusterRole to a specific
identity (a ServiceAccount, a user, or a group) — the Role alone grants nothing to anyone
until it's bound.
```yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: pod-reader
namespace: default
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: read-pods
namespace: default
subjects:
- kind: ServiceAccount
name: my-app
roleRef:
kind: Role
name: pod-reader
apiGroup: rbac.authorization.k8s.io
```
This grants the `my-app` ServiceAccount permission to `get`/`list` Pods in the `default`
Namespace — and nothing else. It cannot delete Pods, cannot touch Secrets, cannot do anything
not explicitly listed.
## Role vs. ClusterRole: scope matters
A Role only grants permissions within the ONE Namespace it's defined in. A ClusterRole can grant
permissions across the entire cluster (or, bound via a RoleBinding instead of a
ClusterRoleBinding, can also be reused namespace-by-namespace) — reach for a ClusterRole only
when the workload genuinely needs cluster-wide visibility, not by default.
## Least privilege, applied to permissions specifically
The same principle from the previous module applies directly here: grant the narrowest set of
verbs on the narrowest set of resources that the workload actually needs. A Role granting
`get`/`list` on Pods is very different from one granting `*` on `*` — the latter is
"root-equivalent" API access, and should be treated with exactly that level of caution.
## Instructor Notes
**Approximate timing:** 15 minutes read; 10 minutes live.
**Likely misconceptions to watch for:**
- Students often conflate "ServiceAccount" with "user account for logging in." A ServiceAccount
is specifically an identity for WORKLOADS, not a human login mechanism.
- The Role/RoleBinding two-step (define permissions, then separately grant them to someone) is
sometimes seen as redundant. Emphasize the reuse benefit: the same Role can be bound to
multiple identities without redefining the permission set each time.
**Discussion question:** "Why might you explicitly disable automounting a ServiceAccount token
for a Pod that never calls the Kubernetes API?" (Answer: an unused but present credential is
still something an attacker who compromises the container could steal and use elsewhere —
removing it entirely removes that risk for a Pod that never needed it.)
**Optional extension:** ask what the practical difference is between granting a ClusterRole via
a RoleBinding (scoped to one Namespace) versus a ClusterRoleBinding (cluster-wide) — a good
check that students understand the Role/ClusterRole and Binding-type distinctions are
independent axes, not the same choice.
@@ -0,0 +1,44 @@
# Knowledge Check: ServiceAccounts & RBAC
---
**1. A Pod is created with no ServiceAccount specified. What identity does it actually run as,
and what can that identity do by default?**
*Answer:* The `default` ServiceAccount in its Namespace, which by default has essentially no
permissions — it can authenticate to the API but is granted nothing until an RBAC binding says
otherwise.
---
**2. You create a Role granting `get`/`list` on Pods, but a workload still can't read Pods.
What's the most likely missing piece?**
*Answer:* A RoleBinding (or ClusterRoleBinding) connecting that Role to the workload's actual
ServiceAccount. A Role by itself grants nothing to anyone — it has to be bound to an identity.
---
**3. True or false: a ClusterRole can only ever be applied cluster-wide.**
*Answer:* False. A ClusterRole can be bound via a RoleBinding to apply within just ONE
Namespace (reusing a cluster-wide-DEFINED permission set in a namespace-SCOPED way), or via a
ClusterRoleBinding to apply cluster-wide. The Role-type and Binding-type are independent
choices.
---
**4. Why would a security review flag a Role granting `verbs: ["*"]` on `resources: ["*"]`
even if the workload "seems to work fine"?**
*Answer:* That grant is root-equivalent API access — it violates least privilege regardless of
whether the workload happens to only use a small subset of it today. "Works fine" doesn't mean
"correctly scoped" — an over-broad grant is a real risk the moment that workload (or its
credentials) is compromised, whether or not it's ever exploited in practice.
## Instructor Notes
**Facilitation:** Question 4 is worth discussing even if everyone answers correctly — the "it
works, so what's the problem" instinct is exactly what least-privilege review exists to counter.
**Approximate timing:** 5 minutes.