TRAILBASE|DOCUMENTATION

Implement Resource-Level RBAC

Build a complete Role-Based Access Control system with resource-level permissions, hierarchical grants, and transparent decision explanations.

Overview

Traditional RBAC systems only check if a user has a role (e.g., "editor"). Trailbase's RBAC engine goes further by allowing permissions on specific resource instances (e.g., "alice can edit document_123"). This guide shows you how to implement both patterns.

Data Model Setup

1. Define Roles

Start by creating roles in your database:

// Database schema (Prisma example)
model Role {
  id          String   @id @default(cuid())
  tenantId    String
  name        String   // 'admin', 'editor', 'viewer'
  description String?

  userRoles       UserRole[]
  rolePermissions RolePermission[]

  @@unique([tenantId, name])
}

model UserRole {
  id       String @id @default(cuid())
  userId   String
  roleId   String

  user User @relation(fields: [userId], references: [id])
  role Role @relation(fields: [roleId], references: [id])

  @@unique([userId, roleId])
}

model RolePermission {
  id            String @id @default(cuid())
  roleId        String
  permissionKey String // 'document:read', 'document:write'

  role Role @relation(fields: [roleId], references: [id])

  @@unique([roleId, permissionKey])
}

model Grant {
  id            String @id @default(cuid())
  tenantId      String
  subjectType   String // 'USER' or 'ROLE'
  subjectId     String
  permissionKey String
  resourceType  String
  resourceId    String

  createdAt DateTime @default(now())
  createdBy String?

  @@index([tenantId, subjectType, subjectId])
  @@index([tenantId, resourceType, resourceId])
}

2. Seed Initial Roles

// seed.ts
import { db } from './db';

async function seedRoles(tenantId: string) {
  // Create roles
  const admin = await db.role.create({
    data: {
      tenantId,
      name: 'admin',
      description: 'Full system access',
    },
  });

  const editor = await db.role.create({
    data: {
      tenantId,
      name: 'editor',
      description: 'Can create and edit content',
    },
  });

  const viewer = await db.role.create({
    data: {
      tenantId,
      name: 'viewer',
      description: 'Read-only access',
    },
  });

  // Assign default permissions to roles
  await db.rolePermission.createMany({
    data: [
      // Admin gets all permissions
      { roleId: admin.id, permissionKey: 'document:read' },
      { roleId: admin.id, permissionKey: 'document:write' },
      { roleId: admin.id, permissionKey: 'document:delete' },
      { roleId: admin.id, permissionKey: 'user:manage' },

      // Editor can read and write
      { roleId: editor.id, permissionKey: 'document:read' },
      { roleId: editor.id, permissionKey: 'document:write' },

      // Viewer can only read
      { roleId: viewer.id, permissionKey: 'document:read' },
    ],
  });
}

Permission Check Integration

Basic Permission Check

Check if a user has permission to perform an action:

// lib/rbac.ts
import { db } from '@/lib/db';

export async function checkPermission(
  userId: string,
  permissionKey: string,
  resource: { type: string; id: string },
  parentResource?: { type: string; id: string }
) {
  // 1. Get user's roles
  const userRoles = await db.userRole.findMany({
    where: { userId },
    include: { role: true },
  });

  const roleIds = userRoles.map((ur) => ur.roleId);

  // 2. Get direct grants (user or role grants on exact resource)
  const directGrants = await db.grant.findMany({
    where: {
      OR: [
        { subjectType: 'USER', subjectId: userId },
        { subjectType: 'ROLE', subjectId: { in: roleIds } },
      ],
      permissionKey,
      resourceType: resource.type,
      resourceId: resource.id,
    },
  });

  // 3. Get parent grants (if provided)
  let parentGrants: any[] = [];
  if (parentResource) {
    parentGrants = await db.grant.findMany({
      where: {
        OR: [
          { subjectType: 'USER', subjectId: userId },
          { subjectType: 'ROLE', subjectId: { in: roleIds } },
        ],
        permissionKey,
        resourceType: parentResource.type,
        resourceId: parentResource.id,
      },
    });
  }

  // 4. Get role default permissions
  const rolePermissions = await db.rolePermission.findMany({
    where: {
      roleId: { in: roleIds },
      permissionKey,
    },
  });

  // 5. Evaluate using Trailbase's decision engine
  const { evaluatePermission } = await import('@trailbase/shared');

  return evaluatePermission(
    {
      tenantId: userRoles[0]?.role.tenantId || 'unknown',
      actorId: userId,
      roles: roleIds,
      permissionKey,
      resourceType: resource.type,
      resourceId: resource.id,
      parentResource,
    },
    {
      directGrants: directGrants.map((g) => ({
        subjectType: g.subjectType as 'USER' | 'ROLE',
        subjectId: g.subjectId,
        permissionKey: g.permissionKey,
        resourceType: g.resourceType,
        resourceId: g.resourceId,
      })),
      parentGrants: parentGrants.map((g) => ({
        subjectType: g.subjectType as 'USER' | 'ROLE',
        subjectId: g.subjectId,
        permissionKey: g.permissionKey,
        resourceType: g.resourceType,
        resourceId: g.resourceId,
      })),
      rolePermissions: rolePermissions.map((rp) => ({
        roleId: rp.roleId,
        permissionKey: rp.permissionKey,
      })),
      tenantActive: true,
    }
  );
}

Usage in API Routes

// app/api/documents/[id]/route.ts
import { checkPermission } from '@/lib/rbac';
import { getSession } from '@/lib/auth';

export async function DELETE(
  req: Request,
  context: { params: Promise<{ id: string }> }
) {
  const session = await getSession();
  if (!session) {
    return new Response('Unauthorized', { status: 401 });
  }

  const { id } = await context.params;

  // Check permission
  const decision = await checkPermission(
    session.userId,
    'document:delete',
    { type: 'document', id }
  );

  if (!decision.allow) {
    // Log the denial
    await trailbase.log({
      action: 'document.delete',
      actor: { id: session.userId, email: session.userEmail },
      resource: { type: 'document', id },
      outcome: 'denied',
      metadata: {
        reason: decision.reasonCode,
        explain: decision.explain,
      },
    });

    return new Response(
      JSON.stringify({
        error: 'Permission denied',
        reasonCode: decision.reasonCode,
        explain: decision.explain,
      }),
      { status: 403 }
    );
  }

  // Perform deletion
  await db.document.delete({ where: { id } });

  // Log the success
  await trailbase.log({
    action: 'document.delete',
    actor: { id: session.userId, email: session.userEmail },
    resource: { type: 'document', id },
    outcome: 'success',
  });

  return new Response(JSON.stringify({ success: true }));
}

Granting Permissions

Grant Permission to User

// services/rbac.service.ts
export async function grantPermission({
  subjectType,
  subjectId,
  permissionKey,
  resourceType,
  resourceId,
  grantedBy,
}: {
  subjectType: 'USER' | 'ROLE';
  subjectId: string;
  permissionKey: string;
  resourceType: string;
  resourceId: string;
  grantedBy: string;
}) {
  const grant = await db.grant.create({
    data: {
      tenantId: 'your-tenant-id', // Get from context
      subjectType,
      subjectId,
      permissionKey,
      resourceType,
      resourceId,
      createdBy: grantedBy,
    },
  });

  // Audit the grant
  await trailbase.log({
    action: 'permission.grant',
    actor: { id: grantedBy, email: 'admin@example.com' },
    resource: { type: resourceType, id: resourceId },
    outcome: 'success',
    metadata: {
      grant_id: grant.id,
      subject_type: subjectType,
      subject_id: subjectId,
      permission: permissionKey,
    },
  });

  return grant;
}

// Usage:
await grantPermission({
  subjectType: 'USER',
  subjectId: 'user_alice',
  permissionKey: 'document:write',
  resourceType: 'document',
  resourceId: 'doc_123',
  grantedBy: 'admin_bob',
});

Revoke Permission

export async function revokePermission({
  grantId,
  revokedBy,
}: {
  grantId: string;
  revokedBy: string;
}) {
  const grant = await db.grant.findUnique({ where: { id: grantId } });
  if (!grant) {
    throw new Error('Grant not found');
  }

  await db.grant.delete({ where: { id: grantId } });

  // Audit the revocation
  await trailbase.log({
    action: 'permission.revoke',
    actor: { id: revokedBy, email: 'admin@example.com' },
    resource: { type: grant.resourceType, id: grant.resourceId },
    outcome: 'success',
    metadata: {
      grant_id: grantId,
      subject_type: grant.subjectType,
      subject_id: grant.subjectId,
      permission: grant.permissionKey,
    },
  });
}

Hierarchical Permissions

Grant permissions on parent resources that cascade to children:

Example: Folder Permissions

// Check if user can edit a document in a folder
const decision = await checkPermission(
  'user_alice',
  'document:write',
  { type: 'document', id: 'doc_123' },
  { type: 'folder', id: 'folder_projects' } // Parent resource
);

// Decision order:
// 1. Direct grant on doc_123? No
// 2. Parent grant on folder_projects? Yes!
//    -> Alice has document:write on folder_projects
//    -> Allow access to doc_123

Implementation Pattern

// Get parent resource when checking permissions
async function checkDocumentPermission(
  userId: string,
  permissionKey: string,
  documentId: string
) {
  // Fetch document with parent folder
  const document = await db.document.findUnique({
    where: { id: documentId },
    include: { folder: true },
  });

  if (!document) {
    throw new Error('Document not found');
  }

  return checkPermission(
    userId,
    permissionKey,
    { type: 'document', id: documentId },
    document.folderId
      ? { type: 'folder', id: document.folderId }
      : undefined
  );
}

Using the Explain Graph

The explain graph shows exactly why a permission was allowed or denied:

const decision = await checkPermission(
  'user_alice',
  'document:delete',
  { type: 'document', id: 'doc_123' }
);

console.log(JSON.stringify(decision.explain, null, 2));

// Output:
{
  "type": "check",
  "label": "Evaluate: document:delete on document/doc_123",
  "passed": true,
  "children": [
    {
      "type": "check",
      "label": "Tenant active check",
      "passed": true
    },
    {
      "type": "check",
      "label": "Direct grant on document/doc_123",
      "passed": false
    },
    {
      "type": "check",
      "label": "Role default permission for document:delete",
      "passed": true,
      "children": [
        {
          "type": "result",
          "label": "Role \"admin\" has permission",
          "passed": true
        }
      ]
    }
  ]
}

Display Explain Graph in UI

Show the explain graph to help users understand permission decisions:

// components/ExplainGraph.tsx
export function ExplainGraph({ node }: { node: ExplainNode }) {
  return (
    <div style={{ paddingLeft: '1rem', borderLeft: '2px solid var(--color-border)' }}>
      <div style={{
        display: 'flex',
        alignItems: 'center',
        gap: '0.5rem',
        marginBottom: '0.5rem',
      }}>
        <span style={{ color: node.passed ? '#22c55e' : '#ef4444' }}>
          {node.passed ? '✓' : '✗'}
        </span>
        <span>{node.label}</span>
      </div>
      {node.children?.map((child, i) => (
        <ExplainGraph key={i} node={child} />
      ))}
    </div>
  );
}

Bulk Operations

Bulk Grant to Role

// Grant all documents in a folder to a role
async function grantFolderAccessToRole(
  folderId: string,
  roleId: string,
  permission: string
) {
  // Get all documents in folder
  const documents = await db.document.findMany({
    where: { folderId },
  });

  // Create grants in bulk
  await db.grant.createMany({
    data: documents.map((doc) => ({
      tenantId: 'your-tenant-id',
      subjectType: 'ROLE',
      subjectId: roleId,
      permissionKey: permission,
      resourceType: 'document',
      resourceId: doc.id,
    })),
  });

  // Audit the bulk operation
  await trailbase.log({
    action: 'permission.bulk_grant',
    actor: { id: 'admin', email: 'admin@example.com' },
    resource: { type: 'folder', id: folderId },
    outcome: 'success',
    metadata: {
      role_id: roleId,
      permission,
      document_count: documents.length,
    },
  });
}

Testing RBAC

Unit Tests

// __tests__/rbac.test.ts
import { checkPermission } from '@/lib/rbac';
import { db } from '@/lib/db';

describe('RBAC', () => {
  beforeEach(async () => {
    // Setup test data
    await db.role.create({
      data: {
        id: 'role_admin',
        tenantId: 'test_tenant',
        name: 'admin',
      },
    });

    await db.rolePermission.create({
      data: {
        roleId: 'role_admin',
        permissionKey: 'document:delete',
      },
    });

    await db.userRole.create({
      data: {
        userId: 'user_alice',
        roleId: 'role_admin',
      },
    });
  });

  it('should allow admin to delete documents', async () => {
    const decision = await checkPermission(
      'user_alice',
      'document:delete',
      { type: 'document', id: 'doc_123' }
    );

    expect(decision.allow).toBe(true);
    expect(decision.reasonCode).toBe('role_permission');
  });

  it('should deny non-admin users', async () => {
    const decision = await checkPermission(
      'user_bob', // Not an admin
      'document:delete',
      { type: 'document', id: 'doc_123' }
    );

    expect(decision.allow).toBe(false);
    expect(decision.reasonCode).toBe('no_grant');
  });
});

Next Steps

Your RBAC system is now ready! Learn how to automate compliance checks on your audit logs.

Edit this page on GitHub