Role-Based Access Control Is a Data-Modelling Problem.
Authorization isn't just about who you are. It’s about how your data is shaped. Here is why secure systems start at the schema level.
Role-Based Access Control Is a Data-Modelling Problem
Every software developer has written some variation of this line of code:
if (user.role === 'admin') {
allowAccess();
}
It is a quick, satisfying fix. But as systems grow, authorization logic naturally drifts toward high entropy. What starts as a single conditional check quickly morphs into a tangled web of nested statements, hardcoded strings, and security vulnerabilities.
The root cause? We often treat Role-Based Access Control (RBAC) as an application-level logic problem when it is, at its core, a data-modeling problem.
If you design your database schema correctly, your authorization logic becomes a clean, declarative query layer. If you design it poorly, you will fight your database—and your codebase—forever. Let's break down how to model RBAC from the ground up.
1. The Classic Relational Model (The Foundation)
At its simplest, RBAC consists of three distinct concepts:
- Users: The actors in your system.
- Roles: Semantic buckets (e.g.,
Admin,Editor,Viewer). - Permissions: Fine-grained operations (e.g.,
write:articles,read:billing).
A common architectural anti-pattern is assigning permissions directly to users, or assigning roles to users while hardcoding what those roles can do in the application code. To make your authorization dynamic, you must decouple them using many-to-many relationships:
[Users] <--- many-to-many ---> [Roles] <--- many-to-many ---> [Permissions]
The Schema
In a relational database like PostgreSQL, this requires five tables:
-- Core entitiesCREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email VARCHAR(255) UNIQUE NOT NULL);
CREATE TABLE roles ( id VARCHAR(50) PRIMARY KEY, -- e.g., 'editor', 'billing_manager' description TEXT);
CREATE TABLE permissions ( id VARCHAR(100) PRIMARY KEY, -- e.g., 'article:publish', 'invoice:pay' description TEXT);
-- Join tables for many-to-many relationshipsCREATE TABLE user_roles ( user_id UUID REFERENCES users(id) ON DELETE CASCADE, role_id VARCHAR(50) REFERENCES roles(id) ON DELETE CASCADE, PRIMARY KEY (user_id, role_id));
CREATE TABLE role_permissions ( role_id VARCHAR(50) REFERENCES roles(id) ON DELETE CASCADE, permission_id VARCHAR(100) REFERENCES permissions(id) ON DELETE CASCADE, PRIMARY KEY (role_id, permission_id));Why this works:
To check if a user can perform an action, the application no longer asks: "Is this user an admin?" Instead, it queries the database: "Does this user have a role that contains the permission article:publish?"
This abstraction allows product managers to create new roles or modify existing ones directly in the database without developers having to deploy a single line of code.
2. The Multi-Tenancy Twist
In a modern B2B SaaS or operations dashboard, users are rarely global. Instead, they belong to an organization, tenant, or workspace. A user might be an Admin in Workspace A, but only a Viewer in Workspace B.
If you use the basic model above, you will inevitably leak permissions across tenant boundaries. To fix this, we must scope the user-to-role assignment to a specific Tenant.
[Tenants]
|
v
[Users] <--- scoped many-to-many ---> [Roles] <---> [Permissions]
The Schema Update
We introduce a tenants table and modify the user_roles join table to include the tenant context:
CREATE TABLE tenants ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(255) NOT NULL);
-- The scoped join tableCREATE TABLE user_tenant_roles ( user_id UUID REFERENCES users(id) ON DELETE CASCADE, tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, role_id VARCHAR(50) REFERENCES roles(id) ON DELETE CASCADE, PRIMARY KEY (user_id, tenant_id, role_id));Now, your authorization query must always look up permissions with a composite key of (user_id, tenant_id). This guarantees strict logical isolation between clients.
3. Handling Hierarchical Roles
As systems scale, flat roles become a management nightmare. If an Owner should inherit all permissions of an Admin, which inherits all permissions of a Manager, you have two choices:
- Manually assign every single sub-permission to the
Ownerrole in the database. - Model role inheritance directly in your schema.
To model hierarchical roles mathematically, we treat roles as a Directed Acyclic Graph (DAG). In SQL, we can achieve this with a self-referencing table:
CREATE TABLE role_hierarchy ( parent_role_id VARCHAR(50) REFERENCES roles(id) ON DELETE CASCADE, child_role_id VARCHAR(50) REFERENCES roles(id) ON DELETE CASCADE, PRIMARY KEY (parent_role_id, child_role_id), CONSTRAINT no_self_reference CHECK (parent_role_id <> child_role_id));
To resolve permissions for a user with hierarchical roles, you must run a recursive query (using Common Table Expressions, or CTEs) to traverse down the role tree and collect all inherited permissions.
4. The Performance Dilemma: DB Query vs. Token Bloat
Checking permissions on every single HTTP request or RPC call introduces a classic system design trade-off: Latency vs. Data Consistency.
When Basic RBAC Breaks (Moving to ReBAC or ABAC)
Data modeling is about choosing the right tool for the job. RBAC is fantastic for broad, static rules. However, RBAC falls apart when permissions depend on relationships or runtime attributes.
Consider these requirements:
- "An Editor can edit an article, but only if they are the author of that specific article."
- "A Manager can approve an invoice, but only if the invoice value is under $10,000."
Trying to solve these with pure RBAC results in "role explosion"—you would have to dynamically create roles like Editor_Who_Is_Author_Of_Article_123.
For resource-level ownership, you need to transition to Relationship-Based Access Control (ReBAC) (where access is modeled as tuples like (user, owner_of, article_id)). For dynamic context, you need Attribute-Based Access Control (ABAC), which evaluates policies at runtime using input payloads.