Skip to Content
Patient GraphRBAC & Permissions

RBAC & Permissions

The Patient Graph enforces role-based access control (RBAC) on every API request. Permissions are defined in a matrix of roles × resources × actions, with an additional ownership layer for customer-scoped data.

Roles

Roles are derived from the Clerk JWT’s publicMetadata.adminRole field. If no admin role is set, the user defaults to customer.

RoleDescriptionTypical User
adminFull access to all resourcesPlatform administrators
staffFull read/write, limited deleteInternal team members
providerClinical read/write accessHealthcare providers
supportRead-only access to profiles and dataCustomer support agents
customerOwn data onlyEnd users / patients

Role Resolution

When a user has multiple roles, the highest-priority role is used:

admin > staff > provider > support > customer

Permission Matrix

ResourceActionAdminStaffProviderSupportCustomer
profilereadownOnly
profilewriteownOnly
profiledelete
lab_resultsreadownOnly
lab_resultswriteownOnly
lab_resultsdelete
protocolsreadownOnly
protocolswriteownOnly
protocolsdelete
eventsreadownOnly
eventswriteownOnly
audit_logread

Legend:

  • ✅ = Full access
  • ❌ = No access
  • ownOnly = Can only access data where customerId matches the user’s own ID

How RBAC Works

Step 1: Authentication

The clerkAuth middleware extracts the user from the Clerk JWT:

// Extracted from JWT const user = { id: 'user_clerk_123', email: 'user@example.com', tier: 'pro', roles: ['customer'], // or ['admin'], ['staff'], etc. };

Step 2: Permission Check

The requireAccess(resource, action) middleware checks the permission matrix:

// Check if role has access to resource + action const permission = getPermission(role, resource, action); // Returns: 'allowed' | 'ownOnly' | 'denied'

If the result is denied, a 403 Forbidden response is returned immediately.

Step 3: Ownership Check

For ownOnly permissions, the ownership middleware verifies the user owns the resource:

List endpoints (requireOwnership('query')):

  • Extracts customerId from query parameters
  • Verifies customerId === user.id
  • If no customerId provided, auto-filters to user’s own data

Single resource endpoints (requireOwnership('param')):

  • Extracts :id from URL parameters
  • Verifies the resource belongs to the user

Step 4: Audit Logging

Every access check (allowed or denied) is recorded in the rbac_logs table.

Access Check Function

The checkAccess() function in @loop/shared/rbac performs the complete check:

import { checkAccess } from '@loop/shared/rbac'; const decision = checkAccess({ role: 'customer', resource: 'lab_results', action: 'read', actorId: 'user_clerk_123', targetCustomerId: 'user_clerk_123', }); // decision.allowed: boolean // decision.reason: string // decision.auditEntry: AuditEntry

Configuring Roles

Roles are assigned in Clerk’s user management:

  1. Navigate to Clerk Dashboard → Users
  2. Select a user
  3. Edit publicMetadata
  4. Set adminRole:
{ "adminRole": "admin" }

Valid values: admin, staff, support, provider

Users without adminRole in their metadata are treated as customer.

Adding New Resources

To add a new RBAC-protected resource:

  1. Add the resource to the rbac_resource enum in the schema
  2. Add permissions to PERMISSION_MATRIX in packages/shared/src/rbac/permissions.ts
  3. Apply requireAccess(resource, action) middleware to the route
  4. Add requireOwnership if customers should only see their own data