Lesson 5 · Authorization — what may you do?

RBAC — the role gate

The first authorization tier: a per-method allow-list of roles — and a widely-cited rule in the docs that turns out to be wrong.

Your win: read a rbacDecider, state exactly what a role list, nil, and a missing entry each mean — and correct the common misconception about what happens when an entry is missing.

Gate ② — the role check

Back in Lesson 4, step ③ of the interceptor was groupDecider.Check(...). That's the role gate, and it runs on every authenticated request, right after the token is verified. It asks one question: "is this gRPC method allowed for any of the caller's roles?" The answer comes from a per-service map:

cmd/server/spike/auth.go:33 (a real rbacDecider)
var rbacDecider = map[string][]string{
    "/spike.v1.EmailModifierService/ExternalSendEmail":
        {constant.RoleSchoolAdmin, constant.RoleHQStaff, constant.RoleCentreManager,
         constant.RoleTeacher, constant.RoleTeacherLead, /* …staff roles… */},

    "/grpc.health.v1.Health/Watch": nil,        // ← nil means something specific
}

It's wired into the interceptor as the decider's allow-list (GroupDecider{AllowedGroups: rbacDecider, GroupFetcher: RetrieveUserRoles}, auth.go:49-55). The GroupFetcher reads the caller's roles from the database; Check compares them to the map.

The three cases — and they're all different

internal/usermgmt/pkg/interceptors/auth.go:201 (GroupDecider.Check, condensed)
allowedGroups, ok := g.AllowedGroups[fullMethod]
if !ok { return nil, sttNoDeciderProvided }     // ← method absent from map
groups := g.GroupFetcher(ctx, userID)               // the caller's roles (from DB)
if len(groups) == 0 { return nil, sttDeniedAll }   // no roles at all
if allowedGroups == nil { return groups, nil }    // ← nil = allow any authenticated user
if len(allowedGroups) == 0 { return nil, sttDeniedAll }
for _, group := range groups {                      // membership test
    if InArrayString(group, allowedGroups) { return groups, nil }
}
return nil, sttNotAllowed                            // has roles, none match
rbacDecider[method]MeaningResult if not satisfied
a role listcaller must hold ≥1 of these rolessttNotAllowed (PermissionDenied)
nilany authenticated user (with ≥1 role)— (only fails if user has no roles)
absent keyno decision was configuredsttNoDeciderProvided — PermissionDenied, at runtime
The distinction that trips people up nil and absent are not the same. nil is a deliberate "any logged-in user may call this." An absent key is "nobody configured this method" — and it's denied. So every RPC needs an explicit entry; you can't accidentally leave one open. (Fail-closed by default — a good property.)

⚠️ The docs are wrong about "panic at startup"

The repo's own .claude/rules/security.md states: "Every gRPC method MUST appear in rbacDecider… Missing entries panic at startup." That is not what the code does. Look again at Check: a missing key returns sttNoDeciderProvided = PermissionDeniedat call time, on the first request to that method. There is no startup validation anywhere in bootstrap or the service wiring that cross-checks registered methods against the map.1

Why this matters (and why it's a great interview answer) The practical difference is real: a "panic at startup" would fail the deploy immediately — you'd never ship a service missing an entry. The actual behavior means the service starts fine and the missing method returns PermissionDenied only when someone calls it — a bug you find in runtime, not at boot. Knowing the true behavior (and that a checked-in doc overstates it) is exactly the kind of precision that separates "I read the README" from "I read the code."

The roles themselves

Roles are the constant.Role* values (internal/usermgmt/pkg/constant) — about 27 of them: School Admin, HQ Staff, Centre Manager/Lead/Staff, Teacher, Teacher Lead, Parent, Student, Chat Support Agent, and more. A user holds roles indirectly (through user-groups — Lesson 6); the GroupFetcher resolves them and filters to the registerable set (AllowListRoles). This is textbook RBAC — access by role, the NIST model.2 But roles are coarse; the next lessons add the fine-grained tiers.

Read this next

Role-based access control (the NIST model)

What RBAC is and its variants — the standard the role gate implements.

NIST — Role-Based Access Control
OneLogin — RBAC vs ABAC (why roles alone aren't enough → Lessons 6–8) · in-repo .claude/rules/security.md (with the panic caveat above)

Check yourself (from memory)

Q1. What does a nil value in rbacDecider mean?

nil = deliberately open to any logged-in user. Distinct from absent, which is denied.

Q2. A method is missing from rbacDecider entirely. What happens?

Check returns sttNoDeciderProvided (PermissionDenied) on the call. Despite the docs, there's no startup panic.

Q3. Where does the role gate get the caller's actual roles?

GroupFetcher (e.g. RetrieveUserRoles) reads the user's roles from the DB, then Check compares them to the allow-list.
Recall: rbacDecider semantics + the panic myth.
three cases + the correction, then reveal
rbacDecider = per-service map[method][]roles wired into GroupDecider.AllowedGroups; the gate runs in the auth interceptor after verify. Three cases (Check, auth.go:201): role list → caller must hold ≥1 (else sttNotAllowed); nil → any authenticated user with ≥1 role; absent keysttNoDeciderProvided = PermissionDenied at runtime (fail-closed). No-roles → sttDeniedAll; deactivated → sttDeactivatedUser. Roles come from the DB via GroupFetcher (RetrieveUserRoles), from constant.Role* (~27). MYTH: security.md says missing entries "panic at startup" — WRONG; it's a runtime PermissionDenied, no startup cross-check exists. This is coarse RBAC; fine tiers next.
🎯 Interview one-liner "How does role-based access work here?" → "A per-method allow-list of roles, checked in the auth interceptor against the caller's DB-resolved roles. nil means any authenticated user; a missing entry is denied — fail-closed. One subtlety: the docs claim a missing entry panics at startup, but the code actually returns PermissionDenied at call time — there's no startup validation."
Next: roles are coarse — the granular permission model is how a role becomes a specific capability like user.user.read, granted through user-groups. Ask me about the nil-vs-absent distinction if it's slippery.

1. In-repo: internal/usermgmt/pkg/interceptors/auth.go:71-76,201-234, cmd/server/spike/auth.go:33-55; the overstated rule in .claude/rules/security.md.

2. NIST RBAC. Roles: internal/usermgmt/pkg/constant/common_def.go:17-43.