Role-based access control in multi-tenant SaaS is more complex than it appears on the first day of implementation. A simple admin/member model works at launch but fails when your first enterprise buyer in Lebanon or the Gulf asks for department-level permissions, approval workflows, and audit logs. Designing RBAC correctly from the start avoids a full rewrite later.
Role-based access control in multi-tenant SaaS is more complex than it appears on the first day of implementation. A simple admin/member model works at launch but fails when your first enterprise buyer in Lebanon or the Gulf asks for department-level permissions, approval workflows, and audit logs. Designing RBAC correctly from the start avoids a full rewrite at the worst possible moment - when you are closing your first significant enterprise contract.
Why simple admin/member models fail at enterprise scale
Most SaaS products start with two roles: admin and member. The admin can do everything. The member can do most things. This is sufficient for the first dozen customers and takes an afternoon to implement.
The problems begin when:
A business has departments with different access needs. A restaurant group's head office staff should see all branch data. A branch manager should only see their own branch. A cashier should only process orders, not view reports or modify menus.
An enterprise buyer requires SOC2 or ISO27001 compliance. These certifications require demonstrable access controls that go beyond admin/member. Auditors want to see that users have minimum necessary permissions, that permission changes are logged, and that there is a documented process for permission changes.
A product grows into workflows. Approval workflows require a distinction between users who can initiate an action and users who can approve it. The accounts payable user can submit invoices. The finance manager can approve them. These are distinct roles that do not map to admin/member.
A reseller or white-label buyer needs tenant management capabilities. Some enterprise buyers want to manage their own sub-tenants. A platform with only admin/member cannot represent this hierarchy.
The failure mode is that the first enterprise deal requires a permission model that the product cannot express, and the engineering team is asked to add it in two weeks while also shipping the quarter's other features.
The data model that scales
A RBAC model that scales across these use cases has four components: users, roles, permissions, and assignments.
Permissions are the atomic capabilities in the system. order:create, order:read, order:void, menu:update, report:view, user:invite, user:manage. Permissions are defined by the product team and change infrequently.
Roles are named collections of permissions. cashier, branch_manager, head_office_admin, viewer. Roles are defined at the tenant level, meaning each tenant can create their own named roles and choose which permissions each role includes. The product provides sensible default roles, but tenants can modify them.
Role assignments link users to roles within a scope. A user can be assigned branch_manager for Branch A but only cashier for Branch B. The scope is what makes the model flexible.
Scope is the unit to which role assignments are attached. In a restaurant group, scope might be organization (all branches) or branch:uuid. In a B2B SaaS product, scope might be organization, team:uuid, or project:uuid.
The PostgreSQL schema for this model:
CREATE TABLE permissions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text UNIQUE NOT NULL -- e.g. 'order:create'
);
CREATE TABLE roles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id),
name text NOT NULL,
UNIQUE (tenant_id, name)
);
CREATE TABLE role_permissions (
role_id uuid REFERENCES roles(id) ON DELETE CASCADE,
permission_id uuid REFERENCES permissions(id),
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE user_roles (
user_id uuid REFERENCES users(id) ON DELETE CASCADE,
role_id uuid REFERENCES roles(id) ON DELETE CASCADE,
scope_type text NOT NULL, -- 'organization', 'branch', 'project'
scope_id uuid, -- null for organization-level
PRIMARY KEY (user_id, role_id, scope_type, COALESCE(scope_id, '00000000-0000-0000-0000-000000000000'::uuid))
);
Implementing permission checks in Go
Permission checks need to be fast. A web request that checks three permissions and makes three database queries per check adds 50ms to 100ms of latency from the permission system alone.
The standard approach: load a user's complete permission set when a session is established and cache it. In Go, the cache can be in-memory (an sync.Map or a simple map protected by a sync.RWMutex) or in Redis for distributed deployments.
A user's effective permissions are the union of all permissions across all their role assignments. A user with branch_manager role on Branch A and cashier role on Branch B has the union of both role's permission sets.
For scope-aware permission checks, the check function needs both the permission name and the scope:
func (s *AuthService) HasPermission(ctx context.Context, userID uuid.UUID, permission string, scopeType string, scopeID *uuid.UUID) bool {
perms := s.cache.GetUserPermissions(userID)
return perms.CheckPermission(permission, scopeType, scopeID)
}
The CheckPermission function returns true if the user has the permission at the specified scope or at a parent scope. A user with order:create at the organization scope can create orders at any branch.
Audit logging for permissions
Enterprise buyers and compliance frameworks require that permission changes are logged and that logs are immutable. The specific requirements that appear most often in enterprise SaaS sales in the MENA region:
- Who granted the permission
- When it was granted
- When it was revoked
- Who revoked it
- What the user did while they had the permission
Audit logs for permission changes are straightforward: a trigger or application-level logging on the user_roles table records every insert and delete with the acting user ID and timestamp.
Audit logs for actions taken with permissions are more involved. Every state-changing operation should log to an audit trail: the user ID, the action, the resource affected, the timestamp, and the outcome. This is not the same as application logging - audit logs need to be queryable by tenant and must be retained for a defined period.
In PostgreSQL, audit logs can live in a separate schema with UNLOGGED turned off and with table-level SECURITY LABEL settings that prevent row deletion. For more serious compliance requirements, a separate write-only audit database provides stronger immutability guarantees.
Handling permission inheritance and delegation
Two patterns appear in enterprise RBAC requirements that the basic model above does not cover directly:
Permission inheritance means a user with a role at the parent scope inherits access to child scopes. An organization-level admin can manage all teams. A team lead can manage all projects in their team. This is handled in the CheckPermission function by walking up the scope hierarchy.
Permission delegation allows users to grant permissions to others, subject to the constraint that they can only grant permissions they themselves hold. A branch manager can make another user a cashier at their branch, but cannot grant head office access. This requires checking the grantor's permissions before recording the assignment.
Token design for stateless permission checks
For APIs that must check permissions on every request without a database query, JWTs with embedded permission claims can carry a subset of the user's permissions. The claim includes the timestamp at which permissions were loaded, and permission changes invalidate cached tokens by making the timestamp stale.
The tradeoff with embedded permission JWTs is that permission revocations do not take effect immediately - they take effect when the token expires or when the user re-authenticates. For most SaaS products, this is acceptable. For products with strict revocation requirements, short token lifetimes (5 to 15 minutes) with refresh token rotation provide near-immediate revocation.
Key lessons from production
Design for scope-aware permissions from the start. Adding scope awareness to a scope-ignorant permission model is a database schema change that affects every permission check in the application.
Cache permissions at session establishment, not on every request. A permission cache that holds for the duration of a web session is sufficient for most SaaS products and eliminates per-request database queries.
Audit logging is a selling point, not just a compliance requirement. Enterprise buyers in Lebanon and the Gulf regularly ask about audit capabilities in sales calls. A well-designed audit log is a feature, not an afterthought.
Permissions should be defined as strings in the application code, not as integers or enums. order:create is readable in logs and audit records. permission_id: 17 is not.
Enjoying this article?
Enter your email and get a clean, formatted PDF of this article - free, no spam.
Not sure where to start?
Voxire designs and implements production-grade authentication and authorization systems for SaaS platforms serving Lebanon and the MENA region. If your permission model is blocking enterprise deals or requires a rewrite to handle your next growth stage, we can help design the right architecture.
https://voxire.com/get-a-quote/



