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_123Implementation 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.