Building RBAC from scratch
Why I built a role-based access control module by hand for an enterprise platform, the model I landed on, and the gotchas that only show up in production.
A few years ago I built the access-control layer for an internal enterprise platform from the ground up — no off-the-shelf authorization library, just Go, DynamoDB, and a model we could actually reason about. This is what I learned.
The platform served at least 500 users, and access control had to stay flexible: roles assigned to users on the fly, with the permissions on each role adjustable per module.
Why build it instead of reaching for a library
There are good authorization libraries out there, and for most projects I’d use one. We didn’t, for two reasons.
First, our permissions were tied to systems we were integrating — Azure AD for identity, plus JIRA and Bitbucket for the actual work. A generic library would have meant bending our domain to fit its model anyway, so the “free” part wasn’t really free.
Second, authorization is the one place I want zero magic. When someone asks “why can this user do that?”, I need to answer it by reading code, not by tracing through a framework’s policy engine. Building it ourselves kept the whole decision path in plain sight.
The model
I kept it deliberately boring. Three nouns:
- Permissions are the atoms — a verb on a resource, like
repo:readoruser:invite. Nothing checks a role directly; everything checks a permission. - Roles are named bundles of permissions (
admin,maintainer,viewer). - Assignments bind a user to a role, optionally scoped to a team or project.
The single most important decision was that code only ever checks
permissions, never roles. Roles are a convenience for humans assigning access;
the enforcement layer doesn’t know they exist. That one rule is what kept the
system from rotting: when product asked for “let maintainers do X too,” it was a
one-line change to a role’s permission set, not a hunt through the codebase for
every if role == "maintainer".
Storing it in DynamoDB
DynamoDB rewards you for knowing your access patterns up front, so I started there. The hot path is always the same question: given this user, what can they do? So role assignments are keyed by user, and resolving a user’s effective permissions is a single query plus a couple of cheap role lookups that are easy to cache.
The trap I almost fell into was modelling permissions as something you compute with a scan. You never want authorization to depend on a table scan — it’s the one query that runs on every request, and it has to stay fast and predictable. Keep the “what can this user do” lookup down to a key-based read and you’re fine.
Enforcing it in Go
Enforcement lived in one place: middleware. The handler declares the permission it needs, and the middleware resolves the caller’s effective permissions and either continues or returns a 403.
func RequirePermission(p Permission) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := UserFromContext(r.Context())
if !user.Permissions.Has(p) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
The win here isn’t the code — it’s that there’s exactly one code path that grants or denies. No handler does its own ad-hoc check. If authorization has a bug, I know the one file to open.
The gotchas nobody warns you about
Caching effective permissions is a footgun. You’ll want to cache the resolved permission set because it’s read constantly — but the moment you do, revoking access stops being instant. I cached with a short TTL and an explicit invalidation when a user’s assignments change. Decide your staleness budget on purpose; don’t let it be an accident.
The frontend hides, the backend enforces. It’s tempting to treat a hidden button as security. It isn’t. The UI consumes the same permission set purely to decide what to show — every real decision still happens on the server. Getting this boundary right is most of the battle.
Roles multiply if you let them. The first time a request doesn’t fit an existing role, the lazy fix is a new role. Do that enough and you’ve got forty near-identical roles nobody understands. Because we checked permissions and not roles, we could usually adjust an existing role instead of minting a new one.
Deny-by-default, always. The absence of a permission is a “no.” New resources and new endpoints start locked, and access is something you add deliberately. The inverse — open by default, lock things down later — is how you end up with a breach.
Would I do it again?
For that platform, yes. The model was small enough to hold in my head, the enforcement path was a single file, and “why can this user do that?” was always answerable. If your authorization rules are genuinely complex — hierarchies, attribute-based conditions, relationship graphs — reach for something purpose- built instead. But for plain role-based access, hand-rolling it bought us clarity that was worth far more than the few days it took to write.
The whole thing comes down to one habit: check permissions, not roles, and deny by default. Get that right and the rest is just plumbing.