← Writing

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:read or user: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.