Stage 7 · Master
Phase 4 — Authentication Service
Authorization (RBAC)
Turn a verified JWT's role and org_id claims into a RequireRole middleware that blocks privilege escalation across HOA organizations, and give platform staff a narrow, explicit escape hatch instead of a blanket bypass.
RequireAuth Answers 'Who'; This Lesson Answers 'Allowed to Do What'
The previous lesson's middleware guarantees a request reaching a handler carries a genuinely verified identity. It says nothing about whether that identity is allowed to, say, delete a membership or approve a maintenance request. Authorization in this platform is role-based and, critically, always scoped to the org_id claim already embedded in the token — a resident of one HOA must never be able to act as staff in a different HOA, no matter what role they hold there.
resident < staff < board_member < org_admin, the same lifecycle established for memberships in user-service. RequireRole checks a minimum role rather than an exact match, so a handler declaring RequireRole(RoleStaff) also admits board_member and org_admin — a strict superset relationship, not a list of allowed roles to maintain per handler.
RequireRole Reads Claims Already Verified Upstream
package httpmw
import "net/http"
type Role int
const (
RoleResident Role = iota
RoleStaff
RoleBoardMember
RoleOrgAdmin
)
var roleRank = map[string]Role{
"resident": RoleResident,
"staff": RoleStaff,
"board_member": RoleBoardMember,
"org_admin": RoleOrgAdmin,
}
// RequireRole must run after RequireAuth — it reads claims injected into
// context, it never re-parses a token or trusts a client-supplied header.
func RequireRole(min Role) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := ClaimsFromContext(r.Context())
if !ok {
forbidden(w, "no verified identity in context")
return
}
if claims.IsPlatformAdmin {
next.ServeHTTP(w, r)
return
}
if roleRank[claims.Role] < min {
forbidden(w, "insufficient role for this operation")
return
}
next.ServeHTTP(w, r)
})
}
}
func forbidden(w http.ResponseWriter, reason string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"error":"forbidden","message":"` + reason + `"}`))
}Notice there is no WWW-Authenticate header here — a 403 means the caller successfully authenticated but lacks permission, a fundamentally different signal than the previous lesson's 401, and clients (and the correlation-id-tagged access log) should be able to tell the two apart at a glance.
Why org_id Must Be Cross-Checked, Not Just role
RequireRole alone answers 'does this caller hold at least this role somewhere' — it does not answer 'in the organization this specific request is about.' A handler for DELETE /api/v1/organizations/{orgID}/memberships/{id} must additionally verify claims.OrgID == orgID from the path, or a board_member of HOA A could delete a membership belonging to HOA B simply by changing the path parameter, despite holding no role there at all.
func (h *MembershipHandler) Delete(c *gin.Context) {
claims, _ := httpmw.ClaimsFromContext(c.Request.Context())
pathOrgID := c.Param("orgID")
// This check is what actually prevents cross-tenant privilege escalation.
// RequireRole(RoleBoardMember) alone would let a board_member of ANY
// organization pass — this line confirms it is THIS organization.
if claims.OrgID != pathOrgID && !claims.IsPlatformAdmin {
respond.Error(c, http.StatusForbidden, "org_mismatch", "token is not scoped to this organization")
return
}
// ... proceed with the delete, already known to be same-org
}This is the same tenant-isolation discipline the Relationships lesson applied at the SQL query level (always filtering by organization_id) — here it happens one layer higher, at the HTTP boundary, before a query is ever issued.
The claim exists for a small set of genuinely cross-tenant platform operations (support tooling, billing reconciliation) and is issued only to a hand-provisioned platform-staff account type that never has an ordinary org membership. It is never set based on any per-organization role, however senior — an org_admin of even the platform's largest customer organization is still just an org_admin, scoped to their own org_id.
Proving Escalation Is Actually Blocked
Applied exercise
Design RequireRole for a resource with no path org_id
A future endpoint, GET /api/v1/notifications/unread-count, has no organization in its path at all — it aggregates across every organization the caller belongs to.
- State whether RequireRole and the org-mismatch check from this lesson even apply to this endpoint, and why or why not.
- Describe what claims.OrgID means for a request like this, given a single access token is scoped to exactly one organization at a time.
- Propose how the endpoint should behave for a user who wants counts across multiple memberships in a single call, given each access token only carries one org_id.
Deliverable
A short written design decision addressing the single-org-per-token constraint.
Completion checks
- The answer correctly identifies that a single access token cannot answer a genuinely cross-org query, and proposes either multiple token-scoped calls or an explicit token-switch flow rather than silently expanding the token's authority.
Why must claims.OrgID be checked against the path's orgID even after RequireRole(RoleBoardMember) has already passed?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.