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:
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
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] | Meaning | Result if not satisfied |
|---|---|---|
| a role list | caller must hold ≥1 of these roles | sttNotAllowed (PermissionDenied) |
nil | any authenticated user (with ≥1 role) | — (only fails if user has no roles) |
| absent key | no decision was configured | sttNoDeciderProvided — PermissionDenied, at runtime |
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 = PermissionDenied — at 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
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.
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.
rbacDecider semantics + the panic myth.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 key → sttNoDeciderProvided =
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.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."
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.