RBAC vs ABAC: Choosing a Permissions Model for a Real Product

Role-based access control is the right default for most products, and we reach for it first almost every time. A handful of named roles — admin, manager, member, viewer — mapped to a fixed set of permissions covers the majority of B2B SaaS products we build, and it's easy to reason about, easy to audit, and cheap to implement with a roles and permissions join table.
It breaks down when permissions depend on relationships between the user and the resource rather than the user's fixed role. For a healthcare scheduling platform, a 'manager' role wasn't enough — a clinic manager could only manage staff within their own clinic, and a regional director could manage staff across clinics in their region but not others. Bolting that onto RBAC meant an explosion of clinic-scoped and region-scoped role variants.
We rebuilt the permission check as attribute-based: instead of asking 'does this user have the manage_staff role,' we asked 'does this user have manage_staff permission AND does resource.clinic_id belong to a clinic in user.managed_clinics.' We implemented this with a policy engine (we used Oso, though Casbin would have worked equally well) rather than hand-rolling condition logic scattered across controllers.
The cost of ABAC is real: policies are harder to unit test exhaustively, and a junior engineer can't glance at a roles table and know who can do what — you have to trace the policy logic. We mitigated this by writing a permission-matrix test suite that runs every (user, action, resource) combination we care about against expected outcomes, about 140 cases, and runs on every PR touching the policy files.
Our rule of thumb now: start with RBAC. Move to ABAC only when you catch yourself creating role variants to encode a relationship — 'clinic_manager_east,' 'clinic_manager_west' — rather than a genuinely different permission set. That naming pattern is the tell that you're modeling data, not roles.


