feat(curriculum): Phase 6C module upgrades, security assessment bank, ROOT 11 orientation

Upgrades six PARTIAL modules to the gold-module standard (lesson/demonstration/
knowledge-check/guided-exercise/lab(s)/reflection), preserving every existing lab
activity_id and scenario_id: root-06-m03 (jobs/cronjobs/daemonsets), root-07-m04
(ingress/networkpolicy), root-07-m02 (configuration/secrets, converted in place
from a bare 5-lab bank), root-09-m02 (serviceaccounts/rbac), root-09-m03
(network/supply-chain security), root-10-m03 (troubleshooting framework).

root-05-m01 (20-task kubectl-fluency bank) gets a minimal bookending lesson +
reflection rather than a full redesign, since each task already carries its own
explanation. root-12-m01, root-capstone-m01, and root-09-m04 are promoted to
READY via documented exceptions (no new activities) where adding one would
misrepresent their intentional design.

Wires a new rootnode-security-assessment (exam, 60min, hint_policy disabled)
using the existing Phase 3B assessment engine: 3 required hands-on CORE
scenarios + 4 random-draw pools covering SecurityContext, RBAC, network
policy, and the 7 previously-unwired CKS MCQs. Wires the previously-unwired
readiness-probe-task scenario as an OPTIONAL practice lab in root-10-m01,
using the existing is_required model.

Adds root-11-m01-devops-platform-thinking — an honest, lesson-only ROOT 11
orientation module naming DevOps/platform engineering practices already
exercised in ROOT 05-10/12, explicit that GitOps/CI-CD/IaC/observability need
lab infrastructure this platform doesn't yet provide. No fake labs.

Adds infra/scripts/validate-course-content.js: cross-checks the DB against
scenario/content files for duplicate ids, orphans, invalid references, cycles,
sequence collisions, instructor-notes leakage, and (new) self-paced content
deferring explanation to a live instructor. 0 errors against the resulting
22 courses/modules, 163 activities, 98 scenario files.

No application code changed — every gap was satisfiable with existing tables
(course_modules, module_activities, assessment_definitions) and the existing
content-delivery pipeline.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-08-26 11:20:17 -05:00
co-authored by Claude Sonnet 5
parent 2db136875f
commit 7f6e1857c0
26 changed files with 1716 additions and 0 deletions
@@ -0,0 +1,62 @@
# Demonstration: Watching RBAC Actually Deny Something
## The setup
A ServiceAccount `readonly-bot` has a Role granting only `get`/`list` on `pods` — nothing else.
## What it CAN do
```
$ kubectl auth can-i list pods --as=system:serviceaccount:default:readonly-bot
yes
$ kubectl get pods --as=system:serviceaccount:default:readonly-bot
NAME READY STATUS RESTARTS AGE
web-abc123 1/1 Running 0 2h
```
## What it CANNOT do
```
$ kubectl auth can-i delete pods --as=system:serviceaccount:default:readonly-bot
no
$ kubectl delete pod web-abc123 --as=system:serviceaccount:default:readonly-bot
Error from server (Forbidden): pods "web-abc123" is forbidden:
User "system:serviceaccount:default:readonly-bot" cannot delete resource "pods"
in API group "" in the namespace "default"
```
This is what least privilege actually looks like in practice — not a policy document, a real,
enforced denial with a specific, readable error naming exactly which permission was missing.
`kubectl auth can-i` is your fastest way to CHECK a permission before attempting an action that
might fail — useful both for debugging "why can't my workload do X" and for verifying a Role is
actually as narrow as intended.
## What happens when the ServiceAccount tries something it was never even considered for
```
$ kubectl auth can-i get secrets --as=system:serviceaccount:default:readonly-bot
no
```
Notice this returns `no` the same way `delete pods` did — RBAC has no concept of "not yet
decided," only "explicitly allowed" or "everything else, denied." This is the practical meaning
of default-deny applied to permissions: nothing is grey area.
## Instructor Notes
**Demo script:** Run all three `kubectl auth can-i`/action pairs live — the FORBIDDEN error
message's exact wording (naming the verb, resource, and namespace) is worth reading aloud, since
that's exactly the evidence a student would use to debug a real permissions issue later.
**Approximate timing:** 8 minutes live; 5 minutes self-paced.
**Common misconception to preempt:** students sometimes think `kubectl auth can-i` REQUIRES the
action to actually be attempted to know the answer. It doesn't — it's a dry-run permission
check against the same RBAC rules, safe to run repeatedly without side effects.
**Optional extension:** ask what the error message would look like if `readonly-bot` tried to
act on a DIFFERENT namespace than the one its Role/RoleBinding applies to — a good check that
students understand Role scope is namespace-specific unless a ClusterRole+ClusterRoleBinding is
used instead.
@@ -0,0 +1,45 @@
# Guided Exercise: Writing a Least-Privilege Role
Paper-and-thinking exercise before the Labs.
## The scenario
A monitoring agent needs to run inside your cluster. Its ONLY job is to periodically list Pods
and read their status, across the whole cluster, to report health metrics elsewhere. It never
needs to create, modify, or delete anything, and it never needs to touch Secrets, ConfigMaps, or
any object type other than Pods.
## Your task
1. Should this use a Role or a ClusterRole? Justify your answer based on scope.
2. Which verbs does it actually need? Which common verbs (create, update, delete, patch) should
be deliberately left OUT?
3. Does it need access to Secrets? Why is it tempting to grant broader access "just in case,"
and why should you resist that?
4. What would you check, using `kubectl auth can-i`, to CONFIRM your Role is exactly as narrow
as intended before deploying it?
## A worked answer to check yourself against
1. **ClusterRole** (bound via a ClusterRoleBinding) — it needs to see Pods across the WHOLE
cluster, not just one namespace, so a namespace-scoped Role can't satisfy the requirement.
2. **`get`, `list`, `watch`** on `pods` — read-only verbs only. Deliberately exclude `create`,
`update`, `patch`, `delete` — this workload never legitimately needs to change anything.
3. No — it never touches Secrets, so it should get zero permission on them. The temptation to
"just in case" grant broader access is exactly what least privilege exists to resist: every
unused permission is pure risk with no corresponding benefit if this workload is ever
compromised.
4. Run `kubectl auth can-i delete pods --as=<the ServiceAccount>` and confirm it returns `no` —
and similarly check a Secrets verb returns `no` — proving the Role is bounded, not just
assuming it from reading the YAML.
## Instructor Notes
**Facilitation:** Question 3 is the core judgment call this exercise is testing — push on WHY
"just in case" access is a real anti-pattern, not just point out that it is one.
**Approximate timing:** 10-12 minutes live with discussion; 10 minutes self-paced.
**Optional extension:** ask what would change about the answer if this monitoring agent later
needed to also restart crash-looping Pods automatically — a good check that students can reason
about incrementally expanding a Role's scope only as far as a genuinely new requirement demands.
@@ -0,0 +1,42 @@
# Reflection: Permissions Should Reflect What a Workload Actually Needs
You just configured real ServiceAccounts and RBAC rules. Take a few minutes to connect this to
production security practice.
## Reflection questions
1. Before this module, would you have thought twice about a workload's ServiceAccount having
broad, unused permissions "just in case"? Has that changed?
2. If a workload with cluster-admin-equivalent RBAC access is ever compromised (a vulnerable
dependency, a supply-chain issue), what's the realistic blast radius compared to one scoped
to exactly what it needs?
3. How would you discover, on a real cluster you didn't build, whether any ServiceAccount has
more access than it actually uses?
## Real Ops takeaway
**RBAC over-permissioning is a silent, cumulative risk.** Unlike a misconfigured probe or a
crashing Pod, an over-broad Role doesn't cause a visible symptom on a healthy day — it just sits
there as unused risk until the one day a workload using it is compromised. This makes RBAC
review a discipline that has to be practiced deliberately, not one that gets forced on you by
an obvious failure.
**Least privilege is a default you set once, not a fix you apply after an incident.** Every
example in this module started from "grant nothing, add exactly what's needed" rather than
"grant broadly, restrict later" — the second approach almost never actually happens once
something works, because there's no pressure forcing the cleanup.
This module is complete. Continue to the next module.
## Instructor Notes
**Discussion questions for a live classroom:** question 3 is a good one to leave partially open
— acknowledge that real answers involve tooling beyond plain `kubectl` (RBAC audit tools) that
this course doesn't build hands-on, while still reinforcing that the QUESTION itself is the
right one to ask.
**Approximate timing:** 8 minutes live discussion; 5 minutes self-paced writing.
**Optional extension:** ask students to identify one ServiceAccount permission from earlier in
ROOT_ (any Lab so far) that, in a real production system, they'd want to double-check was
actually necessary.
@@ -0,0 +1,89 @@
# Demonstration: Default-Deny, Before and After
## Before: everything can reach everything
```
$ kubectl run test-pod --image=busybox --rm -it -- wget -qO- --timeout=2 http://backend-svc
<real response from backend>
```
Any Pod in the namespace can reach `backend-svc`, regardless of what it is or who deployed it —
this is the un-configured default the ROOT 07 lesson described, now made concrete from a
security angle.
## Applying a default-deny policy
```
$ kubectl apply -f default-deny.yaml
networkpolicy.networking.k8s.io/default-deny created
```
```yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny
spec:
podSelector: {}
policyTypes: ["Ingress"]
```
## After: the same request, now blocked
```
$ kubectl run test-pod --image=busybox --rm -it -- wget -qO- --timeout=2 http://backend-svc
wget: download timed out
```
No error naming a permission (that's RBAC's job, a different layer) — just a timeout, because
the packets are being dropped at the network layer before they ever reach the Pod. This is a
DIFFERENT failure signature than an RBAC denial, worth being able to tell apart: RBAC denials
are explicit and immediate; NetworkPolicy denials look like the destination is simply
unreachable.
## Explicitly allowing only what's needed
```yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-frontend-to-backend
spec:
podSelector:
matchLabels: { app: backend }
ingress:
- from:
- podSelector:
matchLabels: { app: frontend }
```
```
$ kubectl run frontend-test --labels="app=frontend" --image=busybox --rm -it -- wget -qO- --timeout=2 http://backend-svc
<real response from backend>
$ kubectl run other-test --labels="app=other" --image=busybox --rm -it -- wget -qO- --timeout=2 http://backend-svc
wget: download timed out
```
Same destination, same request — the ONLY difference is the caller's label. This is
least-privilege network access made concrete: reachability now reflects an explicit decision,
not an accident of both Pods existing in the same cluster.
## Instructor Notes
**Demo script:** Run all three states live if a real cluster is available — the timeout (not an
error) for the denied case is worth calling out explicitly, since it looks identical to a
genuinely broken/unreachable service from the caller's perspective.
**Approximate timing:** 8-10 minutes live; 5-7 minutes self-paced.
**Common misconception to preempt:** students sometimes expect a NetworkPolicy denial to
produce a clear "forbidden" error like RBAC does. It doesn't — reinforce that a NetworkPolicy-
caused timeout is genuinely hard to distinguish from an actual network/DNS problem without
already suspecting NetworkPolicy, which is exactly why checking existing policies is a real
diagnostic step worth knowing.
**Optional extension:** ask how a student would distinguish "blocked by NetworkPolicy" from
"the backend Pod is actually down" purely from the caller's side (answer: check the backend
Pod's own status/logs directly — if it's healthy and Ready, but still unreachable from one
caller and reachable from another with a different label, NetworkPolicy is the likely cause).
@@ -0,0 +1,51 @@
# Guided Exercise: Planning Egress Restriction
Paper-and-thinking exercise before the Labs.
## The scenario
A `payment-processor` Pod handles sensitive transactions. It legitimately needs to reach:
- an internal `payments-db` Service, and
- one external payment gateway API.
It should NOT be able to reach anything else — including the public internet in general, other
internal services, or any other namespace.
## Your task
1. Why is restricting this Pod's OUTBOUND (egress) traffic just as important as restricting who
can reach IT (ingress), given what this Pod handles?
2. If this Pod were compromised via a vulnerable dependency, what would UNRESTRICTED egress let
an attacker do that restricted egress would prevent?
3. Sketch (in words) what an egress policy for this Pod needs to allow, and — just as
important — what it must NOT allow by omission.
4. Why might a security review flag a Pod with `policyTypes: ["Egress"]` but an EMPTY `egress`
list as suspicious, rather than assume it's simply "not configured yet"?
## A worked answer to check yourself against
1. A Pod handling sensitive data that's compromised is far more dangerous if it can freely
exfiltrate that data anywhere (unrestricted egress) than if its only reachable destinations
are pre-approved and narrow.
2. Unrestricted egress would let an attacker exfiltrate transaction data to an external server,
or download additional malicious tooling. Restricted egress limits it to only the
pre-approved destinations, even after compromise.
3. Allow: egress to `payments-db` (internal, by label/namespace selector) and the specific
external payment gateway (by IP range or, if supported, a domain-based egress rule). Must NOT
allow: general internet access, other internal Services, or any other namespace by default.
4. An empty `egress` list under `policyTypes: ["Egress"]` means DENY ALL outbound — exactly like
the ingress case from the lesson. A reviewer seeing this should confirm it's intentional
(a Pod that genuinely needs zero outbound network access) rather than assume it's an
oversight, since the two look identical in the YAML.
## Instructor Notes
**Facilitation:** Question 4 reinforces the same "empty rules list means deny-all, not
unconfigured" point from earlier lessons — a good moment to confirm it generalizes correctly to
egress, not just ingress.
**Approximate timing:** 10-12 minutes live with discussion; 10 minutes self-paced.
**Optional extension:** ask what additional control (beyond NetworkPolicy) a team might want for
truly sensitive egress, like restricting DNS resolution itself for this Pod — a forward pointer
to defense-in-depth without requiring a hands-on Lab here.
@@ -0,0 +1,41 @@
# Reflection: Reachability and Trust Are Decisions You Make on Purpose
You just applied default-deny NetworkPolicy, egress restriction, TLS ingress, and image-pull
authentication for real. Take a few minutes to connect these to a single underlying principle.
## Reflection questions
1. Four different mechanisms in this module (NetworkPolicy ingress, NetworkPolicy egress, TLS,
image-pull secrets) all express the same underlying idea in a different layer. What is that
idea, stated in one sentence?
2. If you had to prioritize implementing ONLY ONE of this module's controls on a brand-new,
unsecured cluster, which would you pick first, and why?
3. How would you explain, to someone who thinks "our cluster is internal, so it doesn't need
this," why internal-only still benefits from all four controls?
## Real Ops takeaway
**Trust should be explicit, narrow, and verifiable — never assumed from proximity.** A Pod on
the same cluster, a request from inside the corporate network, an image from "a registry we
usually use" — none of these are actually trustworthy by default. Every control in this module
replaces an assumption ("it's internal, it's probably fine") with an explicit, checkable
decision (this specific Pod can reach this specific destination; this specific registry is
authenticated; this specific connection is encrypted).
**Supply-chain and network security compound.** A compromised image with unrestricted egress on
an open network is a far worse incident than the same compromised image on a hardened one — the
controls in this module don't individually prevent every incident, but together they
dramatically shrink what any single failure can turn into.
This module is complete. Continue to the next module.
## Instructor Notes
**Discussion questions for a live classroom:** question 1 is the module's thesis — make sure
students can actually articulate it in their own words, not just recognize it when stated.
**Approximate timing:** 8-10 minutes live discussion; 5 minutes self-paced writing.
**Optional extension:** ask students to rank this module's four controls by how much
IMPLEMENTATION EFFORT each typically requires on a real team, separately from security value —
a good exercise in real-world prioritization tradeoffs.