There's a common pattern in access control tutorials: they explain roles, permissions, a pivot table, and maybe a caching layer — then call it enterprise-ready.
It isn't.
Real enterprise authorization fails in predictable ways:
- A terminated employee retains access for 45 minutes because of stale cache
- A SaaS tenant leaks data through a missing scope
- An audit system can't prove who changed a permission because it only logs the action, not the before/after state
- A super admin impersonates a user but the logs show the user did the action
This guide closes those gaps. We'll cover:
- Object-level scoping
- Tenant isolation that actually works
- A complete permission resolution pipeline
- Cache invalidation strategy
- Audit log design for compliance
- A production database schema
Stack: Examples use Laravel + PostgreSQL + Redis. The architecture patterns apply to any stack — Go, Node, .NET developers: the concepts translate directly.
01 — RBAC Is a Starting Point, Not a Destination
Role-Based Access Control (RBAC) is the correct foundation. The classic model is simple:
User → Role → Permissions
Admin → [users.create, users.delete, reports.read]
Staff → [posts.create, posts.update]
This breaks down the moment you need any of the following:
- A user with two roles
- A permission that only applies to the user's own records
- A permission scoped to a specific tenant
- The ability to revoke a single permission from a user who would otherwise inherit it through a role
Common mistake: Shipping basic RBAC and planning to "add scoping later" almost always means a rewrite. Scoping needs to be in the data model from day one — retrofitting it into 80 existing permission checks is painful.
Hybrid RBAC — The Correct Starting Model
Roles are a convenience shortcut for grouping permissions. Users should be able to inherit permissions from roles and have individual overrides — including explicit denies.
User
├── Roles[] → Permissions (inherited, ALLOW)
└── Direct → Permissions (override, ALLOW or DENY)
// Resolution: union of role permissions,
// minus any direct DENY overrides
02 — Global Permissions Are an Anti-Pattern
Most tutorials treat permissions as system-wide boolean flags: you either have users.update or you don't. In practice, the question is almost always: which users can you update?
A users.update flag with no scope means a junior manager can edit the CEO's account. Scope is not a feature you add later — it is the entire point of fine-grained access control.
Three Levels of Permission Scope
Design permissions with an optional scope suffix from the start:
// Level 1 — Global (no scope)
users.update // can edit any user — dangerous
// Level 2 — Ownership scope
users.update:own // can only edit own profile
// Level 3 — Contextual scope
users.update:department // only edit users in same dept
users.update:tenant // only users in same tenant
invoices.void:threshold // only if amount < policy limit
Enforcing Scope in Laravel Policies
The scope resolver lives in your Policy, not your Permission check. This keeps the permission name stable while business rules evolve:
class UserPolicy
{
public function update(User $actor, User $target): bool
{
// Global permission — full access
if ($actor->hasPermission('users.update')) {
return true;
}
// Ownership scope
if ($actor->hasPermission('users.update:own')) {
return $actor->id === $target->id;
}
// Department scope
if ($actor->hasPermission('users.update:department')) {
return $actor->department_id === $target->department_id;
}
return false;
}
}
Pattern: Always check the most-permissive scope first, then narrow down. This makes authorization logic readable and easy to audit.
03 — Tenant Isolation That Actually Works
Most RBAC guides mention multi-tenancy in one sentence: "add a tenant_id column." This is not an architecture — it's a column. Without enforcement, it's a liability.
The Three Failure Modes
| Failure Mode | Impact |
|---|---|
Developer forgets where tenant_id = ? in one query |
Full cross-tenant data leak |
| System roles shared across tenants | Tenant A's admin can escalate using Tenant B's role definitions |
Permission cache uses only user_id as key |
Cached permissions from Tenant A bleed into Tenant B session |
Solution 1: ORM-Level Global Scope
Never rely on developers remembering the tenant filter. Enforce it at the model level:
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where(
$model->getTable() . '.tenant_id',
tenant()->id()
);
}
}
// Applied to model — can never be forgotten
class Role extends Model
{
protected static function booted(): void
{
static::addGlobalScope(new TenantScope);
}
}
Solution 2: Tenant-Scoped Role Definitions
Roles must be either system-wide (tenant_id NULL) or tenant-local. They must never be shared implicitly:
CREATE TABLE roles (
id BIGINT PRIMARY KEY,
tenant_id BIGINT REFERENCES tenants(id) ON DELETE CASCADE,
-- NULL = system/global role (e.g., "Super Admin")
-- SET = tenant-local role (e.g., "Acme Corp Billing Manager")
name VARCHAR(100) NOT NULL,
slug VARCHAR(100) NOT NULL,
UNIQUE(tenant_id, slug)
);
Solution 3: Compound Cache Keys
Cache permission sets per user per tenant. Never use user ID alone as the cache key:
// WRONG — leaks across tenants
$key = "permissions:{$userId}";
// CORRECT — scoped per tenant session
$key = "permissions:{$tenantId}:{$userId}";
// With tag-based invalidation
Cache::tags(["tenant:{$tenantId}", "user:{$userId}"])
->remember($key, now()->addMinutes(10), fn() => $this->resolvePermissions());
04 — The Complete Permission Resolution Pipeline
Every permission check should travel through the same deterministic pipeline. No shortcuts, no if ($user->role === 'admin') checks scattered through controllers.
┌─────────────────────────────────────────────────────────────────┐
│ Step 1: Authenticate & Load Tenant Context │
│ - Resolve user identity, active tenant, tenant feature flags │
│ - Fail fast if tenant suspended or session revoked │
└─────────────────────────────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ Step 2: Load Role Permissions (tenant-scoped) │
│ - Fetch all roles assigned to user within this tenant │
│ - Union all permission slugs from those roles │
└─────────────────────────────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ Step 3: Merge Direct ALLOW Overrides │
│ - Add permissions granted directly to user (bypasses roles) │
│ - Useful for one-off grants without role creation │
└─────────────────────────────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ Step 4: Apply Explicit DENY Overrides (most missed step) │
│ - Remove any permissions explicitly denied at user level │
│ - DENY always beats ALLOW │
│ - How you revoke a single permission from inherited role │
└─────────────────────────────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ Step 5: Check Feature Flags │
│ - Even if permission exists, is feature enabled for plan? │
│ - Separate concern but evaluated in same pipeline │
└─────────────────────────────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ Step 6: Run Policy (Object-Level) │
│ - Execute Policy class: ownership, department match, state │
│ - This is where scoping is enforced │
└─────────────────────────────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ Step 7: Grant or Deny + Log │
│ - Emit decision │
│ - On DENY: log reason │
│ - On sensitive ALLOW: also log │
│ - Never silently fail │
└─────────────────────────────────────────────────────────────────┘
Implementation Example
class PermissionResolver
{
public function resolve(User $user, int $tenantId): PermissionSet
{
return Cache::tags(["tenant:{$tenantId}", "user:{$user->id}"])
->remember(
"permissions:{$tenantId}:{$user->id}",
now()->addMinutes(10),
function () use ($user, $tenantId) {
// Step 1: Role permissions
$rolePerms = $user->rolesInTenant($tenantId)
->flatMap(fn($r) => $r->permissions)
->pluck('slug')
->unique();
// Step 2: Merge direct ALLOWs
$allows = $user->directPermissions($tenantId, 'allow')
->pluck('slug');
// Step 3: Collect explicit DENYs
$denies = $user->directPermissions($tenantId, 'deny')
->pluck('slug');
// Step 4: Final set = (role + allow) - deny
return new PermissionSet(
$rolePerms->merge($allows)->diff($denies)
);
}
);
}
}
05 — Cache Invalidation: The Part That Gets You Fired
Caching permission sets is necessary at scale. But stale permission caches are a security vulnerability, not a performance trade-off. A suspended user with cached users.delete is an active threat for however long your TTL runs.
What Must Invalidate the Cache
| Event | Action |
|---|---|
| User assigned to or removed from a role | Flush user's permission cache |
| Direct permission override added/removed | Flush user's permission cache |
| Role's permission set changes | Flush ALL users with that role |
| User account suspended or deactivated | Immediate flush + revoke tokens |
| User switches active tenant | Flush previous tenant cache |
Event Handlers
// When role permissions change — flush ALL users with this role
public function onRolePermissionsUpdated(Role $role): void
{
Cache::tags(["role:{$role->id}", 'permissions'])->flush();
}
// When user is suspended — immediate flush
public function onUserSuspended(User $user): void
{
Cache::tags(["user:{$user->id}", 'permissions'])->flush();
// Also revoke active API tokens immediately
$user->tokens()->delete();
}
Recommended TTLs by Risk Level
| System Type | Max TTL | Rationale |
|---|---|---|
| Healthcare / Financial | 2 min | Compliance requires near-real-time revocation |
| Enterprise SaaS | 10 min | Balance between performance and access lag |
| Internal tools | 30 min | Lower risk, slower user lifecycle changes |
06 — Audit Logs That Pass a Compliance Audit
Logging user_id + action + timestamp is not an audit log. It's a weak activity feed.
A real audit log must answer:
- What was the exact state before and after this change?
- Who approved it?
- Was this a super admin acting as another user?
The Full Audit Log Schema
CREATE TABLE audit_logs (
id BIGSERIAL PRIMARY KEY,
-- Identity
user_id BIGINT NOT NULL, -- who triggered the action
acting_on_behalf_of BIGINT, -- impersonator (if any)
tenant_id BIGINT NOT NULL,
-- Action
action VARCHAR(100) NOT NULL, -- e.g. "permission.grant"
status VARCHAR(20) NOT NULL, -- "success" | "denied" | "error"
-- Resource
model_type VARCHAR(100),
model_id BIGINT,
-- State snapshots (critical for compliance)
old_value JSONB, -- before state
new_value JSONB, -- after state
-- Request context
ip_address INET,
user_agent TEXT,
request_id UUID, -- for distributed tracing
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
The Impersonation Problem
When a super admin impersonates another user to debug an issue, every action they take should log both identities. If the log only shows the impersonated user's ID, you cannot reconstruct what actually happened during an incident review.
AuditLog::record([
'user_id' => auth()->id(),
// If impersonating, this differs from user_id
'acting_on_behalf_of' => session('impersonating_as'),
'tenant_id' => tenant()->id(),
'action' => 'role.assign',
'status' => 'success',
'model_type' => 'User',
'model_id' => $target->id,
'old_value' => ['roles' => $before],
'new_value' => ['roles' => $after],
'ip_address' => request()->ip(),
'request_id' => request()->header('X-Request-ID'),
]);
07 — Production Database Schema
Here's the complete schema with annotations. Key additions over basic RBAC:
Entity Relationship Diagram
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ users │ │ tenants │ │ modules │
├─────────────────┤ ├─────────────────┤ ├─────────────────┤
│ id (PK) │ │ id (PK) │ │ id (PK) │
│ name │ │ name │ │ name │
│ email │ │ slug │ │ slug │
│ ... │ │ ... │ │ ... │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ user_roles │ │ roles │ │ permission_groups│
├─────────────────┤ ├─────────────────┤ ├─────────────────┤
│ user_id (FK) │────▶│ id (PK) │ │ id (PK) │
│ role_id (FK) │ │ tenant_id (FK) │◀────│ module_id (FK) │
│ tenant_id (FK) │ │ name │ │ name │
│ granted_by (FK) │ │ slug │ │ ... │
│ expires_at │ │ ... │ └────────┬────────┘
│ ... │ └────────┬────────┘ │
└─────────────────┘ │ │
│ ▼ ▼
│ ┌─────────────────┐ ┌─────────────────┐
│ │role_permissions │ │ permissions │
│ ├─────────────────┤ ├─────────────────┤
│ │ role_id (FK) │────▶│ id (PK) │
└──────────────│ permission_id │ │ group_id (FK) │
│ (FK) │ │ name │
└─────────────────┘ │ slug (scope) │
│ ... │
┌─────────────────┐ └─────────────────┘
│user_permissions │
├─────────────────┤
│ user_id (FK) │◀─────────────────────────────────────────────┐
│ tenant_id (FK) │ │
│ permission_id │ ┌─────────────────┐ ┌─────────────────┐
│ type (ALLOW/DENY│ │tenant_feature_ │ │ audit_logs │
│ granted_by (FK) │ │ flags │ ├─────────────────┤
│ ... │ ├─────────────────┤ │ id (PK) │
└─────────────────┘ │ tenant_id (FK) │ │ user_id (FK) │
│ feature │ │ acting_on_behalf│
│ enabled │ │ tenant_id (FK) │
│ ... │ │ old_value (JSONB│
└─────────────────┘ │ new_value (JSONB│
│ request_id (UUID│
│ ... │
└─────────────────┘
Key Tables with Annotations
-- Users table
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT REFERENCES tenants(id),
name VARCHAR(255),
email VARCHAR(255) UNIQUE,
is_active BOOLEAN DEFAULT true,
suspended_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- user_roles — includes expires_at for time-bound access
CREATE TABLE user_roles (
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
role_id BIGINT REFERENCES roles(id) ON DELETE CASCADE,
tenant_id BIGINT REFERENCES tenants(id) ON DELETE CASCADE,
granted_by BIGINT REFERENCES users(id),
expires_at TIMESTAMPTZ, -- NULL = never expires
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (user_id, role_id, tenant_id)
);
-- user_permissions — includes ALLOW|DENY type
CREATE TABLE user_permissions (
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
permission_id BIGINT REFERENCES permissions(id) ON DELETE CASCADE,
tenant_id BIGINT REFERENCES tenants(id) ON DELETE CASCADE,
type VARCHAR(10) NOT NULL CHECK (type IN ('ALLOW', 'DENY')),
granted_by BIGINT REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (user_id, permission_id, tenant_id)
);
-- roles — tenant_id is nullable (NULL = system-wide)
CREATE TABLE roles (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
slug VARCHAR(100) NOT NULL,
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(tenant_id, slug)
);
-- permissions — linked to groups for manageable UI
CREATE TABLE permissions (
id BIGSERIAL PRIMARY KEY,
group_id BIGINT REFERENCES permission_groups(id),
name VARCHAR(100) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL, -- e.g., "users.update:own"
description TEXT
);
-- tenant_feature_flags — checked in permission pipeline
CREATE TABLE tenant_feature_flags (
tenant_id BIGINT REFERENCES tenants(id) ON DELETE CASCADE,
feature VARCHAR(100) NOT NULL,
enabled BOOLEAN DEFAULT true,
PRIMARY KEY (tenant_id, feature)
);
08 — When to Use Spatie — And When to Outgrow It
Spatie's laravel-permission is excellent for most applications. But it has architectural constraints worth knowing:
| Capability | Spatie (default) | Custom Architecture |
|---|---|---|
| Basic RBAC | ✅ Excellent | ✅ Full control |
| Multi-tenancy | ⚠️ Manual / plugin | ✅ Native |
| Explicit DENY | ❌ Not supported | ✅ First-class |
| Permission scoping | ⚠️ Convention only | ✅ Schema-enforced |
| Cache invalidation | ⚠️ Single flat key | ✅ Tag-based |
| Setup time | ✅ Hours | ⚠️ Days / weeks |
Recommendation: Start with Spatie for MVPs and side projects. Build the custom architecture when you have:
- Multi-tenancy requirements
- Explicit deny use cases
- Compliance obligations (HIPAA, SOC2, etc.)
The decision point is usually around 5–10 developers or Series A scale.
09 — The Architecture Checklist
Before calling your permission system enterprise-ready:
Foundation
- [ ] Hybrid RBAC: users have roles and direct permission overrides
- [ ] Explicit DENY type on user-level overrides — DENY beats ALLOW
- [ ] Permission slugs include optional scope (
:own,:department,:tenant)
Multi-Tenancy
- [ ] Tenant ID enforced at ORM level via global scope — not per-query
- [ ] Roles are nullable-tenant: system-global or tenant-local, never shared implicitly
- [ ] Cache keys are compound:
permissions:{tenant_id}:{user_id}
Performance & Invalidation
- [ ] Cache uses tags for role-level invalidation
- [ ] TTL is <15 min; user suspension immediately flushes cache and revokes tokens
Architecture
- [ ] Permission resolution is a single, centralized pipeline — no ad-hoc checks
- [ ] Feature flags are checked in the pipeline, not separately
Audit & Compliance
- [ ] Audit logs capture
old_value,new_value,acting_on_behalf_of, andrequest_id - [ ]
user_roles.expires_atenables time-bound grants
Maintainability
- [ ] Permissions organized into groups and modules — admin UI stays manageable
Summary
| Concept | Anti-Pattern | Correct Approach |
|---|---|---|
| Permission checking |
if ($user->isAdmin()) scattered everywhere |
Centralized permission resolver with pipeline |
| Multi-tenancy |
where tenant_id = ? in each query |
ORM-level global scope + compound cache keys |
| Cache invalidation | Fixed TTL only | Tag-based invalidation on role/user changes |
| Audit logs | User + action + timestamp only | Full before/after snapshots + impersonation tracking |
| Permission scoping | Global flags only | Scoped suffixes (:own, :department, :tenant) |
| Role management | User has exactly one role | Hybrid RBAC: roles + direct ALLOW/DENY overrides |
Top comments (0)