Session 05 ยท ๐ŸŽญ Roles

Role-Based Access Control

Give users roles, give roles permissions โ€” and let routes ask only for permissions.

โœ“
5aroles & permission map
โœ“
5benforcement middleware

5a

Three roles, four routes, one map

Each cell is a real status code from go run ./demos/05. Fill the matrix one role at a time.

internal/authz/rbac.go
type Role string
type Perm string

const (
    RoleUser      Role = "user"
    RoleModerator Role = "moderator"
    RoleAdmin     Role = "admin"
)

const (
    ReadProfile Perm = "profile:read"
    DeletePost  Perm = "post:delete"
    ListUsers   Perm = "user:list"
    SetRole     Perm = "user:set_role"
)

var rolePerms = map[Role][]Perm{
    RoleUser:      {ReadProfile},
    RoleModerator: {ReadProfile, DeletePost},
    RoleAdmin:     {ReadProfile, DeletePost, ListUsers, SetRole},
}

func Can(r Role, p Perm) bool {
    for _, have := range rolePerms[r] {
        if have == p {
            return true
        }
    }
    return false // unknown role or perm: deny
}
โ†’Routes ask for permissions, not roles. Adding a "support" role is one new map line โ€” no route changes.
โ†’Typed Role / Perm constants โ€” a typo is a compile error, not a silent 403.
โœ“Default deny: anything not in the map โ€” even a role called superuser โ€” gets nothing.

5b

The middleware that enforces it

It runs after RequireAuth, reads the role from the claims and answers 403 on its own.

internal/authz/middleware.go
func RequirePerm(p Perm, next http.Handler) http.Handler {
    fn := func(w http.ResponseWriter, r *http.Request) {
        c := auth.ClaimsFrom(r.Context())
        if c == nil || !Can(Role(c.Role), p) {
            http.Error(w, "forbidden", 403)
            return
        }
        next.ServeHTTP(w, r)
    }
    return http.HandlerFunc(fn)
}

// wiring: authenticate first, then authorize
protect := func(p Perm) http.Handler {
    return auth.RequireAuth(RequirePerm(p, h))
}
mux.Handle("GET /me", protect(ReadProfile))
mux.Handle("DELETE /posts/{id}", protect(DeletePost))
mux.Handle("GET /admin/users", protect(ListUsers))
mux.Handle("PATCH /admin/users/{id}/role", protect(SetRole))
go run ./demos/05 ยท one real JWT per role
            me   del  list role
(no token)  401  401  401  401
user        200  403  403  403
moderator   200  200  403  403
admin       200  200  200  200
superuser   403  403  403  403
# unknown role โ†’ denied everywhere
โ†’Order matters: RequireAuth outside, RequirePerm inside โ€” so no token is 401, wrong role is 403.
โ†’The role lives in the JWT, so a demotion takes effect at the next refresh (โ‰ค 15 min). Need it instant? Deny the jti (session 04).
โ†’Hiding an admin button in the UI is not access control โ€” the server check is.
Mistake

Letting users send their own role, e.g. {"role":"admin"} in the register body. Roles are only ever set by an admin endpoint guarded by SetRole.

TASK

Add a role without touching a route

  • Add RoleSupport that can ReadProfile and ListUsers.
  • Print the matrix again โ€” only one new row should change.
  • Write a table-driven test that asserts every cell.

Recap

Session 05 in four lines

1Users have roles; roles map to permissions; routes require permissions.
2Can(role, perm) returns false for anything unknown โ€” default deny.
3RequireAuth(RequirePerm(p, h)): 401 first, 403 second.
4Role changes reach the token at refresh โ€” or immediately via the denylist.