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.
| Role | Description | Typical User |
|---|---|---|
admin | Full access to all resources | Platform administrators |
staff | Full read/write, limited delete | Internal team members |
provider | Clinical read/write access | Healthcare providers |
support | Read-only access to profiles and data | Customer support agents |
customer | Own data only | End users / patients |
Role Resolution
When a user has multiple roles, the highest-priority role is used:
admin > staff > provider > support > customerPermission Matrix
| Resource | Action | Admin | Staff | Provider | Support | Customer |
|---|---|---|---|---|---|---|
profile | read | ✅ | ✅ | ✅ | ✅ | ownOnly |
profile | write | ✅ | ✅ | ❌ | ❌ | ownOnly |
profile | delete | ✅ | ✅ | ❌ | ❌ | ❌ |
lab_results | read | ✅ | ✅ | ✅ | ✅ | ownOnly |
lab_results | write | ✅ | ✅ | ❌ | ❌ | ownOnly |
lab_results | delete | ✅ | ✅ | ❌ | ❌ | ❌ |
protocols | read | ✅ | ✅ | ✅ | ✅ | ownOnly |
protocols | write | ✅ | ✅ | ✅ | ❌ | ownOnly |
protocols | delete | ✅ | ✅ | ❌ | ❌ | ❌ |
events | read | ✅ | ✅ | ✅ | ✅ | ownOnly |
events | write | ✅ | ✅ | ✅ | ❌ | ownOnly |
audit_log | read | ✅ | ✅ | ❌ | ❌ | ❌ |
Legend:
- ✅ = Full access
- ❌ = No access
- ownOnly = Can only access data where
customerIdmatches 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
customerIdfrom query parameters - Verifies
customerId === user.id - If no
customerIdprovided, auto-filters to user’s own data
Single resource endpoints (requireOwnership('param')):
- Extracts
:idfrom 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: AuditEntryConfiguring Roles
Roles are assigned in Clerk’s user management:
- Navigate to Clerk Dashboard → Users
- Select a user
- Edit
publicMetadata - 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:
- Add the resource to the
rbac_resourceenum in the schema - Add permissions to
PERMISSION_MATRIXinpackages/shared/src/rbac/permissions.ts - Apply
requireAccess(resource, action)middleware to the route - Add
requireOwnershipif customers should only see their own data