Back to all posts

Backend

RBAC is not enough: roles, permissions, and scopes

A practical model for authorization that separates what someone can do from exactly where they are allowed to do it.

Favour Sunday8 min read
Abstract layered access boundaries showing roles passing through scoped permission gates

A role is only the starting point

Role-based access control is attractive because it gives the system a small vocabulary. A user is an admin, provider, support agent, or patient, and each role receives a known set of permissions. That is a good foundation, but production authorization rarely stops there.

The difficult questions are contextual. Can this provider view every patient or only patients assigned to them? Can a support agent issue a refund in every organization? Can an administrator edit their own permissions? A role can tell us what kind of work a person does, but not always the boundary around that work.

Separate roles, permissions, and scopes

I find authorization easier to reason about when three ideas stay separate. Roles are convenient bundles. Permissions describe actions. Scopes describe the resources or boundaries within which those actions are valid.

  • Role: provider, organization admin, support agent, patient
  • Permission: appointment.read, prescription.create, billing.refund
  • Scope: own record, assigned patients, one organization, all organizations

Make every decision answer the same question

A useful authorization check can be expressed as: may this actor perform this action on this resource in this context? The actor supplies identity and memberships. The action maps to a permission. The resource supplies ownership and tenancy. The context can include assignment, organization, workflow state, or another policy input.

This shape is more durable than scattering checks like isAdmin throughout the application. It also produces better tests because each policy can be exercised with explicit actors, actions, resources, and boundaries.

Enforce the boundary where the data lives

Hiding a button is useful interface feedback, not security. The server must make the final decision, and the data query should be constrained by the same scope whenever possible. If a user is restricted to one organization, the organization boundary belongs in the query rather than in a filter applied after data has already been loaded.

This matters even more for list endpoints. Checking access to the route without scoping the returned rows can expose an entire dataset through a perfectly legitimate request.

Design for change and auditability

Permissions should be stable capabilities, while roles can evolve as product responsibilities change. Keeping that distinction makes it possible to create a new role by composing existing permissions instead of rewriting authorization logic.

Sensitive decisions should also leave an audit trail: who acted, which permission allowed it, what resource was affected, and when it happened. Authorization tells the system whether an action may occur. Auditing helps the team understand what actually occurred afterward.

The practical rule

Use roles to make permissions manageable, permissions to name allowed actions, and scopes to keep those actions inside the correct boundary. Apply the same policy on the server, constrain the underlying data access, and treat the client as an explanation of the policy rather than its enforcement point.