Lesson 11 · Row-Level Security & the whole chain

Defense in depth — the full trace

One real request, followed through every gate — and, at each one, what it would catch if all the others failed.

Your win: trace a real endpoint from token to rows through all six gates, and for each gate say precisely what it enforces and what independent protection it provides — the concrete meaning of "defense in depth."

The request: a parent reads their children

We'll follow RetrieveStudentAssociatedToParentAccount — a Parent-only endpoint that returns the caller's own children. It touches every gate cleanly.

① TOKEN Manabie JWT in gRPC md "token" (manabie claim: user_id=parentID, resource_path=org, user_group=Parent) │ ② VERIFY Auth.UnaryServerInterceptor → verify vs JWKS → ctx: UserID, JWTClaims [authN] │ ③ ROLE GATE rbacDecider[".../RetrieveStudentAssociatedToParentAccount"] = {RoleParent} [authZ tier 1] │ GroupDecider.Check requires Parent, else PermissionDenied │ ④ OWNERSHIP handler: userID := UserIDFromContext(ctx); parentID = userID [authZ service] │ → queries by the CALLER'S OWN id — never from the request │ ⑤ REPO GetStudentsByParentID: WHERE parent_id = $1 AND deleted_at IS NULL [scoping] │ ⑥ RLS setPostgres already stamped resource_path + app.user_id from the JWT → [authZ tier 3] ▼ users/students rows filtered by tenant + location-permission + self rows — survive only if the repo WHERE AND every RLS policy allow them

Gate ④ is the quiet hero

Look closely at the handler. It does not take the parent id from the request — it reads the caller's own id from the verified context:

internal/usermgmt/modules/user/core/service/user_reader_retrieve_student_associated_to_parent.go:15-22
userID := interceptors.UserIDFromContext(ctx)     // ← the CALLER, from the JWT — not req
parentID.Set(userID)
users, _ := urs.StudentRepo.GetStudentsByParentID(ctx, urs.DB, parentID)
Why this is a beautiful ownership check There is no parent id in the request to forge. The handler ignores client input for "whose children" entirely and uses the authenticated caller. So even if a malicious parent somehow slipped past the role gate, they still could only ever query their own children — the very shape of the code makes "read another parent's children" unexpressible. The strongest access checks aren't checks at all; they're designs where the unsafe thing can't be said.

What each gate catches — the whole point of layering

Defense in depth means: assume any one gate has a bug, and ask what still protects the data.

GateEnforcesIf every other gate failed, it still stops…
② Verifyauthentic identity…an anonymous or forged-token caller — no identity, no entry
③ Role gatethe caller's role may call this method…a Student or Teacher calling a Parent-only endpoint
④ Ownershipyou act as yourself…a parent asking for another parent's children (it's not expressible)
⑤ Repothe query's own filters…soft-deleted rows, wrong-parent rows
⑥ RLStenant + permission + self, in the DB…cross-tenant rows even from a raw query or a forgotten check

No single failure breaches the data. Forget the repo filter? RLS still scopes the tenant. Bug in RLS? The handler still only asked for the caller's own children. Skip the role gate? An anonymous caller never got past verification. Each gate is independent, and each is a full stop on its own axis. That redundancy is the design — not accidental belt-and-suspenders, but the deliberate assumption that some layer will eventually have a bug.1

The through-line: resource_path Follow one value across the whole trace. It's minted into the JWT by Shamir (L3), verified and put on the context by the interceptor (L4), read by setPostgres into a session variable (L9), and checked by permission_check in every RLS policy (L9–10). One claim field, threaded through five layers, is what makes a multi-tenant platform safe. That's the spine of this whole course.
Read this next

Defense in depth & least privilege

The security principle this trace embodies — layered, independent controls so no single failure is catastrophic.

NIST — defense in depth
→ in-repo trace: …/user_reader_retrieve_student_associated_to_parent.go:15 · .claude/rules/security.md (the layered rules)

Check yourself (from memory)

Q1. In the parent endpoint, where does the "whose children?" parent id come from?

UserIDFromContext(ctx). There's no parent id to forge — the unsafe request simply can't be expressed.

Q2. If a bug let a Student past the role gate, what would still stop them reading a parent's children?

The handler queries by the caller's own id, and RLS filters by tenant/permission — independent gates that don't rely on the role gate.

Q3. What is the single value threaded through every gate of the trace?

The tenant: Shamir mints it → interceptor verifies it → setPostgres sets it → permission_check enforces it. One field, five layers.
Recall: the six gates + what each independently catches.
walk the trace + the redundancy, then reveal
Trace (RetrieveStudentAssociatedToParentAccount): ① token (JWT in md) → ② verify (interceptor, vs JWKS → ctx claims) → ③ role gate (rbacDecider={RoleParent}) → ④ ownership (handler: UserIDFromContext as parentID — never from req) → ⑤ repo (WHERE parent_id=$1 AND deleted_at IS NULL) → ⑥ RLS (setPostgres session vars → tenant + location-permission + self policies). Defense in depth: ② stops anonymous/forged; ③ stops wrong-role; ④ makes "another parent's children" inexpressible; ⑤ filters soft-deleted/wrong-parent; ⑥ stops cross-tenant even from a raw query. No single failure breaches data. Through-line: resource_path — minted by Shamir → verified by interceptor → set by setPostgres → checked by permission_check.
🎯 Interview one-liner "Walk me through an authorized request end to end." → "Token verified at the interceptor → role gate for the method → the handler uses the caller's own id, so ownership is structural, not a check → the repo filters → and RLS independently scopes tenant, permission, and self in the database. Every gate is a full stop on its own axis, so no single bug leaks data — and the tenant id threads through all of them from the signed token."
Final lesson: the guardrails and the edges — the rls_scan that catches a missing policy, service-to-service auth, the repo's surprises, and the whole-course recap. Ask me to re-run this trace on a different endpoint if you'd like the reps.

1. NIST — defense in depth. In-repo: internal/usermgmt/modules/user/core/service/user_reader_retrieve_student_associated_to_parent.go:15-22, internal/usermgmt/modules/user/adapter/postgres/repository/legacy_student.go:185.