DEV Community

Cover image for The Real Guide to Enterprise Role & Permission Architecture
Black Lover
Black Lover

Posted on

The Real Guide to Enterprise Role & Permission Architecture

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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
);
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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                                          │
└─────────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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)
                    );
                }
            );
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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()
);
Enter fullscreen mode Exit fullscreen mode

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'),
]);
Enter fullscreen mode Exit fullscreen mode

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│
                                                │ ...             │
                                                └─────────────────┘
Enter fullscreen mode Exit fullscreen mode

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)
);
Enter fullscreen mode Exit fullscreen mode

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, and request_id
  • [ ] user_roles.expires_at enables 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)