authorization.md 27 KB

Authorization Patterns

Comprehensive reference for authorization models: RBAC, ABAC, ReBAC, row-level security, multi-tenant, and audit logging.

RBAC (Role-Based Access Control)

The most common authorization model. Users are assigned roles, and roles have permissions.

Data Model

-- Core RBAC tables
CREATE TABLE roles (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name        TEXT UNIQUE NOT NULL,      -- 'admin', 'editor', 'viewer'
    description TEXT,
    created_at  TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE permissions (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    resource    TEXT NOT NULL,              -- 'posts', 'users', 'settings'
    action      TEXT NOT NULL,              -- 'create', 'read', 'update', 'delete'
    UNIQUE(resource, action)
);

CREATE TABLE role_permissions (
    role_id       UUID REFERENCES roles(id) ON DELETE CASCADE,
    permission_id UUID REFERENCES permissions(id) ON DELETE CASCADE,
    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,
    granted_by UUID REFERENCES users(id),
    granted_at TIMESTAMPTZ DEFAULT now(),
    PRIMARY KEY (user_id, role_id)
);

Role Hierarchy

super_admin
  └─ admin
       ├─ editor
       │    └─ viewer
       └─ moderator
            └─ viewer
-- Role hierarchy table
CREATE TABLE role_hierarchy (
    parent_role_id UUID REFERENCES roles(id),
    child_role_id  UUID REFERENCES roles(id),
    PRIMARY KEY (parent_role_id, child_role_id)
);

-- Query: Get all permissions for a user (including inherited)
WITH RECURSIVE effective_roles AS (
    -- Direct roles
    SELECT role_id FROM user_roles WHERE user_id = $1
    UNION
    -- Inherited roles (parent inherits child permissions)
    SELECT rh.child_role_id
    FROM role_hierarchy rh
    JOIN effective_roles er ON er.role_id = rh.parent_role_id
)
SELECT DISTINCT p.resource, p.action
FROM effective_roles er
JOIN role_permissions rp ON rp.role_id = er.role_id
JOIN permissions p ON p.id = rp.permission_id;

Middleware Patterns

Node.js / Express

// Permission checking middleware
function requirePermission(resource, action) {
  return async (req, res, next) => {
    const userId = req.auth.sub;

    const hasPermission = await db.query(`
      WITH RECURSIVE effective_roles AS (
        SELECT role_id FROM user_roles WHERE user_id = $1
        UNION
        SELECT rh.child_role_id
        FROM role_hierarchy rh
        JOIN effective_roles er ON er.role_id = rh.parent_role_id
      )
      SELECT EXISTS (
        SELECT 1
        FROM effective_roles er
        JOIN role_permissions rp ON rp.role_id = er.role_id
        JOIN permissions p ON p.id = rp.permission_id
        WHERE p.resource = $2 AND p.action = $3
      )
    `, [userId, resource, action]);

    if (!hasPermission.rows[0].exists) {
      return res.status(403).json({
        error: 'Forbidden',
        required: `${action}:${resource}`,
      });
    }

    next();
  };
}

// Usage
app.get('/api/posts', requirePermission('posts', 'read'), listPosts);
app.post('/api/posts', requirePermission('posts', 'create'), createPost);
app.delete('/api/posts/:id', requirePermission('posts', 'delete'), deletePost);

Python / FastAPI

from fastapi import Depends, HTTPException, status
from functools import wraps

def require_permission(resource: str, action: str):
    async def checker(user: User = Depends(get_current_user)):
        has_perm = await db.fetch_val("""
            SELECT EXISTS (
                SELECT 1 FROM user_roles ur
                JOIN role_permissions rp ON rp.role_id = ur.role_id
                JOIN permissions p ON p.id = rp.permission_id
                WHERE ur.user_id = $1 AND p.resource = $2 AND p.action = $3
            )
        """, user.id, resource, action)

        if not has_perm:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail=f"Missing permission: {action}:{resource}",
            )
        return user
    return Depends(checker)

# Usage
@app.get("/api/posts")
async def list_posts(user: User = require_permission("posts", "read")):
    return await get_posts()

@app.post("/api/posts")
async def create_post(
    post: PostCreate,
    user: User = require_permission("posts", "create"),
):
    return await insert_post(post, user.id)

Go

// Middleware pattern
func RequirePermission(resource, action string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            userID := r.Context().Value("userID").(string)

            allowed, err := checkPermission(r.Context(), userID, resource, action)
            if err != nil {
                http.Error(w, "Internal error", http.StatusInternalServerError)
                return
            }
            if !allowed {
                http.Error(w, "Forbidden", http.StatusForbidden)
                return
            }

            next.ServeHTTP(w, r)
        })
    }
}

// Usage with chi router
r.Route("/api/posts", func(r chi.Router) {
    r.With(RequirePermission("posts", "read")).Get("/", listPosts)
    r.With(RequirePermission("posts", "create")).Post("/", createPost)
    r.With(RequirePermission("posts", "delete")).Delete("/{id}", deletePost)
})

When RBAC Breaks Down

Signal Problem Solution
50+ roles Role explosion Consider ABAC
Roles like "alice-docs-editor" Per-user roles Consider ReBAC
Roles vary by context Context-dependent access Consider ABAC
"Owner can edit own resources" Relationship-based Consider ReBAC

ABAC (Attribute-Based Access Control)

Decisions based on attributes of the subject, resource, action, and environment.

Policy Structure

PERMIT if:
  subject.role == "doctor"
  AND resource.type == "medical_record"
  AND resource.department == subject.department
  AND environment.time BETWEEN 08:00 AND 18:00
  AND action == "read"

Implementation

// Policy engine
class PolicyEngine {
  constructor(policies) {
    this.policies = policies;
  }

  evaluate(subject, resource, action, environment) {
    for (const policy of this.policies) {
      const result = policy.evaluate(subject, resource, action, environment);
      if (result === 'PERMIT') return true;
      if (result === 'DENY') return false;
      // 'NOT_APPLICABLE' continues to next policy
    }
    return false; // Default deny
  }
}

// Define policies
const policies = [
  {
    name: 'owner-full-access',
    evaluate: (subject, resource, action, env) => {
      if (resource.ownerId === subject.id) return 'PERMIT';
      return 'NOT_APPLICABLE';
    },
  },
  {
    name: 'department-read',
    evaluate: (subject, resource, action, env) => {
      if (
        action === 'read' &&
        subject.department === resource.department
      ) {
        return 'PERMIT';
      }
      return 'NOT_APPLICABLE';
    },
  },
  {
    name: 'business-hours-only',
    evaluate: (subject, resource, action, env) => {
      const hour = env.currentTime.getHours();
      if (resource.classification === 'restricted' && (hour < 8 || hour > 18)) {
        return 'DENY';
      }
      return 'NOT_APPLICABLE';
    },
  },
];

// Usage
const engine = new PolicyEngine(policies);
const allowed = engine.evaluate(
  { id: 'user_123', role: 'doctor', department: 'cardiology' },
  { type: 'record', ownerId: 'user_456', department: 'cardiology', classification: 'normal' },
  'read',
  { currentTime: new Date() }
);

Combining Algorithms

Algorithm Behavior
Deny-overrides Any DENY wins (most restrictive)
Permit-overrides Any PERMIT wins (most permissive)
First-applicable First matching policy decides
Only-one-applicable Error if multiple policies match

Recommendation: Use deny-overrides for security-critical systems, first-applicable for performance.

ReBAC (Relationship-Based Access Control)

Based on Google's Zanzibar paper. Access is determined by relationships between users and resources.

Core Concepts

Relationship tuple: user:alice#viewer@document:report

  • Subject: user:alice
  • Relation: viewer
  • Object: document:report

Reading: "Alice is a viewer of document:report"

Authorization Model (OpenFGA/SpiceDB)

# OpenFGA model
model:
  schema 1.1

type user

type organization
  relations
    define member: [user]
    define admin: [user]

type folder
  relations
    define org: [organization]
    define owner: [user]
    define editor: [user, organization#member] or owner
    define viewer: [user, organization#member] or editor

type document
  relations
    define parent: [folder]
    define owner: [user]
    define editor: [user] or owner or editor from parent
    define viewer: [user] or editor or viewer from parent

Relationship Tuples

# Alice owns the Engineering folder
user:alice#owner@folder:engineering

# Engineering folder belongs to Acme org
organization:acme#org@folder:engineering

# Bob is a member of Acme
user:bob#member@organization:acme

# Report document is in Engineering folder
folder:engineering#parent@document:report

# Now: Can Bob view document:report?
# Bob is member of Acme → Acme is org of Engineering folder
# → folder viewers include org members → document viewers include folder viewers
# → YES, Bob can view document:report

OpenFGA Integration

// OpenFGA SDK
import { OpenFgaClient } from '@openfga/sdk';

const fga = new OpenFgaClient({
  apiUrl: process.env.OPENFGA_API_URL,
  storeId: process.env.OPENFGA_STORE_ID,
});

// Write a relationship
await fga.write({
  writes: [
    {
      user: 'user:alice',
      relation: 'editor',
      object: 'document:report',
    },
  ],
});

// Check access
const { allowed } = await fga.check({
  user: 'user:bob',
  relation: 'viewer',
  object: 'document:report',
});

if (!allowed) {
  return res.status(403).json({ error: 'Access denied' });
}

// List objects a user can access
const { objects } = await fga.listObjects({
  user: 'user:alice',
  relation: 'viewer',
  type: 'document',
});
// objects: ['document:report', 'document:spec', ...]

// List users with access to an object
const { users } = await fga.listUsers({
  object: { type: 'document', id: 'report' },
  relation: 'viewer',
  user_filters: [{ type: 'user' }],
});

SpiceDB Integration

// SpiceDB with authzed-go
import (
    v1 "github.com/authzed/authzed-go/proto/authzed/api/v1"
    "github.com/authzed/authzed-go/v1"
)

client, err := authzed.NewClient(
    "localhost:50051",
    grpc.WithInsecure(),
    grpcutil.WithInsecureBearerToken("my-token"),
)

// Write relationship
_, err = client.WriteRelationships(ctx, &v1.WriteRelationshipsRequest{
    Updates: []*v1.RelationshipUpdate{
        {
            Operation: v1.RelationshipUpdate_OPERATION_CREATE,
            Relationship: &v1.Relationship{
                Resource: &v1.ObjectReference{
                    ObjectType: "document",
                    ObjectId:   "report",
                },
                Relation: "viewer",
                Subject: &v1.SubjectReference{
                    Object: &v1.ObjectReference{
                        ObjectType: "user",
                        ObjectId:   "alice",
                    },
                },
            },
        },
    },
})

// Check permission
resp, err := client.CheckPermission(ctx, &v1.CheckPermissionRequest{
    Resource: &v1.ObjectReference{
        ObjectType: "document",
        ObjectId:   "report",
    },
    Permission: "view",
    Subject: &v1.SubjectReference{
        Object: &v1.ObjectReference{
            ObjectType: "user",
            ObjectId:   "bob",
        },
    },
})

if resp.Permissionship == v1.CheckPermissionResponse_PERMISSIONSHIP_HAS_PERMISSION {
    // Access granted
}

When to Use ReBAC

Scenario RBAC ReBAC
"Admins can manage users" Good fit Overkill
"Owner can edit their documents" Awkward Good fit
"Folder viewers can view contained documents" Cannot model Good fit
"Shared-with users can view" Cannot model Good fit
"Org members can access org resources" Possible but fragile Good fit

Row-Level Security (RLS)

Database-level authorization that filters query results based on the current user.

PostgreSQL RLS

-- Enable RLS on table
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;

-- Force RLS for table owner too (optional, for safety)
ALTER TABLE documents FORCE ROW LEVEL SECURITY;

-- Policy: Users can only see their own documents
CREATE POLICY "users_own_documents" ON documents
    FOR ALL
    USING (owner_id = current_setting('app.current_user_id')::uuid);

-- Policy: Users can see documents shared with them
CREATE POLICY "shared_documents" ON documents
    FOR SELECT
    USING (
        id IN (
            SELECT document_id FROM document_shares
            WHERE user_id = current_setting('app.current_user_id')::uuid
        )
    );

-- Policy: Admins can see all documents
CREATE POLICY "admin_full_access" ON documents
    FOR ALL
    USING (
        EXISTS (
            SELECT 1 FROM user_roles
            WHERE user_id = current_setting('app.current_user_id')::uuid
            AND role = 'admin'
        )
    );

-- Set the current user context before queries
SET app.current_user_id = 'user_abc123';
SELECT * FROM documents; -- Only returns allowed rows

Supabase RLS

-- Supabase provides auth.uid() and auth.jwt() functions

-- Users can read their own profile
CREATE POLICY "read_own_profile" ON profiles
    FOR SELECT USING (auth.uid() = id);

-- Users can update their own profile
CREATE POLICY "update_own_profile" ON profiles
    FOR UPDATE USING (auth.uid() = id);

-- Users can read posts in their organization
CREATE POLICY "org_posts" ON posts
    FOR SELECT USING (
        org_id IN (
            SELECT org_id FROM org_members
            WHERE user_id = auth.uid()
        )
    );

-- Service role bypasses RLS (for admin operations)
-- Use supabase.createClient(url, SERVICE_ROLE_KEY) for admin access

Application-Level Row Filtering

When you can't use RLS (e.g., non-PostgreSQL databases):

// Query builder pattern
class AuthorizedQuery {
  constructor(user) {
    this.user = user;
    this.filters = [];
  }

  forResource(table) {
    if (this.user.role === 'admin') {
      // No filter for admins
    } else if (this.user.role === 'manager') {
      this.filters.push(`${table}.org_id = ?`, this.user.orgId);
    } else {
      this.filters.push(`${table}.owner_id = ?`, this.user.id);
    }
    return this;
  }

  apply(queryBuilder) {
    for (const filter of this.filters) {
      queryBuilder.where(filter);
    }
    return queryBuilder;
  }
}

// Usage
const query = new AuthorizedQuery(currentUser)
  .forResource('documents')
  .apply(db('documents').select('*'));

Multi-Tenant Authorization

Tenant Isolation Strategies

Strategy Isolation Complexity Use When
Shared database, shared schema Row-level (tenant_id column) Low SaaS with many small tenants
Shared database, separate schemas Schema-level Medium Moderate data isolation needs
Separate databases Complete High Strict compliance, large tenants

Shared Schema with tenant_id

-- Every table has a tenant_id
CREATE TABLE documents (
    id        UUID PRIMARY KEY,
    tenant_id UUID NOT NULL REFERENCES tenants(id),
    title     TEXT NOT NULL,
    content   TEXT,
    owner_id  UUID NOT NULL REFERENCES users(id),
    CONSTRAINT fk_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);

-- RLS policy enforces tenant isolation
CREATE POLICY "tenant_isolation" ON documents
    FOR ALL
    USING (tenant_id = current_setting('app.current_tenant_id')::uuid);

-- Index for performance
CREATE INDEX idx_documents_tenant ON documents(tenant_id);

Tenant-Scoped Roles

-- Roles are scoped to a tenant
CREATE TABLE tenant_user_roles (
    tenant_id UUID REFERENCES tenants(id),
    user_id   UUID REFERENCES users(id),
    role      TEXT NOT NULL, -- 'owner', 'admin', 'member', 'viewer'
    PRIMARY KEY (tenant_id, user_id)
);

-- A user can be admin in one tenant and viewer in another
INSERT INTO tenant_user_roles VALUES
    ('tenant_1', 'alice', 'admin'),
    ('tenant_2', 'alice', 'viewer');

Middleware: Tenant Context

// Express middleware - set tenant context
async function tenantContext(req, res, next) {
  // Determine tenant from subdomain, header, or JWT claim
  const tenantId =
    req.headers['x-tenant-id'] ||
    req.auth?.tenantId ||
    extractFromSubdomain(req.hostname);

  if (!tenantId) {
    return res.status(400).json({ error: 'Tenant not specified' });
  }

  // Verify user belongs to tenant
  const membership = await db.query(
    'SELECT role FROM tenant_user_roles WHERE tenant_id = $1 AND user_id = $2',
    [tenantId, req.auth.sub]
  );

  if (!membership.rows.length) {
    return res.status(403).json({ error: 'Not a member of this tenant' });
  }

  req.tenant = { id: tenantId, role: membership.rows[0].role };

  // Set PostgreSQL session variable for RLS
  await db.query("SET app.current_tenant_id = $1", [tenantId]);

  next();
}

Cross-Tenant Access

// Controlled cross-tenant access (e.g., shared documents)
async function checkCrossTenantAccess(userId, resourceId, targetTenantId) {
  // Check if resource has cross-tenant sharing enabled
  const share = await db.query(`
    SELECT permission FROM cross_tenant_shares
    WHERE resource_id = $1
    AND shared_with_tenant_id = $2
    AND (expires_at IS NULL OR expires_at > now())
  `, [resourceId, targetTenantId]);

  if (!share.rows.length) return false;
  return share.rows[0].permission; // 'read', 'write', etc.
}

API Authorization

Scope-Based (OAuth2 Scopes)

// Middleware that checks OAuth2 scopes
function requireScopes(...scopes) {
  return (req, res, next) => {
    const tokenScopes = req.auth.scope?.split(' ') || [];
    const missing = scopes.filter((s) => !tokenScopes.includes(s));
    if (missing.length) {
      return res.status(403).json({
        error: 'insufficient_scope',
        missing,
      });
    }
    next();
  };
}

Claims-Based (JWT Claims)

// Check JWT claims for authorization
function requireClaim(claim, value) {
  return (req, res, next) => {
    if (req.auth[claim] !== value) {
      return res.status(403).json({ error: `Required claim: ${claim}=${value}` });
    }
    next();
  };
}

// Usage
app.get('/admin', requireClaim('role', 'admin'), adminDashboard);
app.get('/org/:orgId', (req, res, next) => {
  if (req.auth.org_id !== req.params.orgId) {
    return res.status(403).json({ error: 'Wrong organization' });
  }
  next();
}, orgDashboard);

API Key Permissions

// API key with scoped permissions
async function apiKeyAuth(req, res, next) {
  const apiKey = req.headers['x-api-key'];
  if (!apiKey) return res.status(401).json({ error: 'API key required' });

  // Look up by prefix, verify by hash
  const prefix = apiKey.substring(0, 8);
  const keyRecord = await db.query(
    'SELECT * FROM api_keys WHERE prefix = $1 AND revoked = false',
    [prefix]
  );

  if (!keyRecord.rows.length) {
    return res.status(401).json({ error: 'Invalid API key' });
  }

  const record = keyRecord.rows[0];
  const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');

  if (!crypto.timingSafeEqual(
    Buffer.from(keyHash),
    Buffer.from(record.key_hash)
  )) {
    return res.status(401).json({ error: 'Invalid API key' });
  }

  // Check expiry
  if (record.expires_at && record.expires_at < new Date()) {
    return res.status(401).json({ error: 'API key expired' });
  }

  req.apiKey = {
    id: record.id,
    permissions: record.permissions, // ['read:data', 'write:data']
    rateLimitTier: record.rate_limit_tier,
  };

  next();
}

Feature Flags as Authorization

Feature flags can serve as a lightweight authorization mechanism for feature rollouts.

// Simple feature flag implementation
class FeatureFlags {
  constructor(config) {
    this.flags = config;
  }

  isEnabled(flag, context = {}) {
    const config = this.flags[flag];
    if (!config) return false;

    // Global enable/disable
    if (typeof config === 'boolean') return config;

    // Percentage rollout
    if (config.percentage !== undefined) {
      const hash = this.hashUser(context.userId);
      return hash % 100 < config.percentage;
    }

    // User allowlist
    if (config.allowedUsers?.includes(context.userId)) return true;

    // Role-based
    if (config.allowedRoles?.includes(context.role)) return true;

    // Tenant-based
    if (config.allowedTenants?.includes(context.tenantId)) return true;

    return config.defaultValue ?? false;
  }

  hashUser(userId) {
    return parseInt(
      crypto.createHash('md5').update(userId).digest('hex').slice(0, 8),
      16
    ) % 100;
  }
}

// Usage
const flags = new FeatureFlags({
  new_editor: { percentage: 25 },
  beta_api: { allowedRoles: ['admin'], allowedTenants: ['acme'] },
  dark_mode: true,
});

if (flags.isEnabled('new_editor', { userId: user.id })) {
  // Show new editor
}

Audit Logging

What to Log

Category Events
Authentication Login success/failure, logout, password change, MFA enable/disable
Authorization Permission denied, role changes, policy evaluations
Data access Read sensitive data, export data, search queries
Data modification Create, update, delete operations
Admin actions User management, configuration changes, key rotation
Security Suspicious activity, rate limit hits, blocked requests

Audit Log Schema

CREATE TABLE audit_logs (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    timestamp   TIMESTAMPTZ NOT NULL DEFAULT now(),
    actor_id    UUID,                          -- Who did it
    actor_type  TEXT NOT NULL,                 -- 'user', 'service', 'system'
    action      TEXT NOT NULL,                 -- 'user.login', 'document.delete'
    resource    TEXT,                          -- 'document:123', 'user:456'
    outcome     TEXT NOT NULL,                 -- 'success', 'failure', 'denied'
    metadata    JSONB DEFAULT '{}',            -- Additional context
    ip_address  INET,
    user_agent  TEXT,
    tenant_id   UUID,
    request_id  UUID                           -- Correlation ID
);

-- Index for common queries
CREATE INDEX idx_audit_actor ON audit_logs(actor_id, timestamp DESC);
CREATE INDEX idx_audit_action ON audit_logs(action, timestamp DESC);
CREATE INDEX idx_audit_resource ON audit_logs(resource, timestamp DESC);
CREATE INDEX idx_audit_tenant ON audit_logs(tenant_id, timestamp DESC);

-- Prevent modification (append-only)
REVOKE UPDATE, DELETE ON audit_logs FROM app_user;

Audit Logging Implementation

// Audit logger
class AuditLogger {
  constructor(db) {
    this.db = db;
  }

  async log(event) {
    await this.db.query(`
      INSERT INTO audit_logs
        (actor_id, actor_type, action, resource, outcome, metadata, ip_address, user_agent, tenant_id, request_id)
      VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
    `, [
      event.actorId,
      event.actorType || 'user',
      event.action,
      event.resource,
      event.outcome || 'success',
      JSON.stringify(event.metadata || {}),
      event.ipAddress,
      event.userAgent,
      event.tenantId,
      event.requestId,
    ]);
  }
}

// Express middleware for automatic audit logging
function auditMiddleware(action, resourceFn) {
  return (req, res, next) => {
    const originalJson = res.json.bind(res);

    res.json = (body) => {
      const resource = resourceFn ? resourceFn(req, body) : undefined;
      const outcome = res.statusCode < 400 ? 'success' : 'failure';

      // Fire and forget (don't block response)
      auditLogger.log({
        actorId: req.auth?.sub,
        actorType: 'user',
        action,
        resource,
        outcome,
        metadata: {
          method: req.method,
          path: req.path,
          statusCode: res.statusCode,
        },
        ipAddress: req.ip,
        userAgent: req.headers['user-agent'],
        tenantId: req.tenant?.id,
        requestId: req.headers['x-request-id'],
      }).catch(console.error);

      return originalJson(body);
    };

    next();
  };
}

// Usage
app.delete('/api/documents/:id',
  auditMiddleware('document.delete', (req) => `document:${req.params.id}`),
  requirePermission('documents', 'delete'),
  deleteDocument
);

Compliance Considerations

Requirement Implementation
Immutability Append-only table, no UPDATE/DELETE permissions
Retention Partition by month, archive to cold storage after N months
Integrity Hash chain (each entry includes hash of previous)
Access Separate read permissions for audit logs
Availability Async writes with retry queue, separate storage
Search Index on actor, action, resource, timestamp

Testing Authorization

Unit Testing Policies

// Test RBAC permissions
describe('Authorization', () => {
  it('admin can delete posts', async () => {
    const allowed = await checkPermission('admin', 'posts', 'delete');
    expect(allowed).toBe(true);
  });

  it('viewer cannot delete posts', async () => {
    const allowed = await checkPermission('viewer', 'posts', 'delete');
    expect(allowed).toBe(false);
  });

  it('editor can update posts', async () => {
    const allowed = await checkPermission('editor', 'posts', 'update');
    expect(allowed).toBe(true);
  });
});

Permission Matrix Testing

// Test every role × resource × action combination
const matrix = {
  admin:   { posts: ['create', 'read', 'update', 'delete'], users: ['create', 'read', 'update', 'delete'] },
  editor:  { posts: ['create', 'read', 'update'],           users: ['read'] },
  viewer:  { posts: ['read'],                                users: ['read'] },
};

for (const [role, permissions] of Object.entries(matrix)) {
  for (const [resource, actions] of Object.entries(permissions)) {
    for (const action of ['create', 'read', 'update', 'delete']) {
      const expected = actions.includes(action);
      it(`${role} ${expected ? 'can' : 'cannot'} ${action} ${resource}`, async () => {
        const result = await checkPermission(role, resource, action);
        expect(result).toBe(expected);
      });
    }
  }
}

Integration Testing

// Test authorization at the HTTP level
describe('POST /api/posts', () => {
  it('returns 403 for viewer', async () => {
    const res = await request(app)
      .post('/api/posts')
      .set('Authorization', `Bearer ${viewerToken}`)
      .send({ title: 'Test' });
    expect(res.status).toBe(403);
  });

  it('returns 201 for editor', async () => {
    const res = await request(app)
      .post('/api/posts')
      .set('Authorization', `Bearer ${editorToken}`)
      .send({ title: 'Test' });
    expect(res.status).toBe(201);
  });

  it('prevents cross-tenant access', async () => {
    const res = await request(app)
      .get('/api/posts/123') // belongs to tenant_1
      .set('Authorization', `Bearer ${tenant2UserToken}`);
    expect(res.status).toBe(404); // 404 not 403 to avoid info leakage
  });
});