Authentication & RBAC
Authentication Architecture
Overview
Comp AI uses session-based authentication (no JWTs) through Better Auth, with the NestJS API as the single source of truth. Three authentication methods are supported, evaluated in order:
Request arrives
│
├── Has X-API-Key header?
│ └── YES → API Key auth (scoped permissions)
│
├── Has X-Service-Token header?
│ └── YES → Service Token auth (inter-service calls)
│
└── Has session cookie or Bearer token?
└── YES → Session auth (user identity + org membership)
HybridAuthGuard (apps/api/src/auth/hybrid-auth.guard.ts)
The central authentication guard processes all three methods:
1. API Key Authentication (lines 54-79)
// Header: X-API-Key: comp_abc123...
// Format: comp_ + 32 hex characters
// Storage: SHA256 hash + salt, prefix-indexed for fast lookup
request.organizationId = apiKey.organizationId;
request.authType = 'api-key';
request.isApiKey = true;
request.apiKeyScopes = apiKey.scopes; // e.g., ['control:read', 'policy:read']
request.userRoles = null; // No user identityKey features:
- Prefix-indexed lookup (first 8 chars after
comp_) for O(1) finding - Legacy keys (empty scopes) have full access until April 20, 2026 sunset
- Scoped keys enforce
resource:actionpermission matching lastUsedAtupdated on each validation
2. Service Token Authentication (lines 81-137)
// Headers: X-Service-Token + X-Organization-ID (required) + X-User-ID (optional)
// Tokens: per-service env vars (SERVICE_TOKEN_TRIGGER, etc.)
// Comparison: timing-safe to prevent timing attacks
request.organizationId = orgId;
request.authType = 'service';
request.isServiceToken = true;
request.serviceName = service.name; // 'trigger', 'portal', 'trust'Service definitions (apps/api/src/auth/service-token.config.ts:16-43):
const SERVICE_DEFINITIONS = {
trigger: {
permissions: ['integration:read', 'integration:update',
'cloud-security:update', 'vendor:update', 'email:send']
},
portal: {
permissions: ['training:read', 'training:update']
},
trust: {
permissions: ['trust:read', 'organization:read',
'questionnaire:read', 'questionnaire:update']
}
};3. Session Authentication (lines 139-242)
// Sources: httpOnly cookie (__Secure-better-auth.session_token) or Bearer token
// Validation: better-auth SDK → auth.api.getSession({ headers })
request.organizationId = activeOrgId;
request.authType = 'session';
request.userId = user.id;
request.userEmail = user.email;
request.userRoles = member.role.split(','); // e.g., ['admin', 'auditor']
request.memberId = member.id;
request.memberDepartment = member.department;
request.isPlatformAdmin = user.role === 'admin';
request.impersonatedBy = session.impersonatedBy;Session Cookie Architecture
Cookie: __Secure-better-auth.session_token
Domain: .trycomp.ai (shared across subdomains)
Secure: true (HTTPS only)
HttpOnly: true (no JS access)
SameSite: None (cross-subdomain)
Variants for different environments:
- Production:
__Secure-better-auth.session_token - Staging:
staging-better-auth.session_token - Local:
better-auth.session_token
CORS & Origin Validation (apps/api/src/main.ts:23-52)
// Trusted origins checked dynamically:
// 1. Static list: localhost:*, *.trycomp.ai, *.staging.trycomp.ai, *.trust.inc
// 2. Custom verified domains from DB trust.domain (Redis-cached, 5 min TTL)
// 3. CSRF middleware rejects state-changing requests from untrusted originsBetter Auth Server Configuration (apps/api/src/auth/auth.server.ts)
Plugins enabled:
- admin: Platform admin capabilities, impersonation
- bearer: Bearer token support (for non-browser clients)
- emailOTP: 6-digit codes, 10-minute expiration
- magicLink: 1-hour expiration email links
- multiSession: Multiple active sessions per user
- organization: Organization membership, roles, invitations
Social providers (optional):
- Google, GitHub, Microsoft (with tenant ID support)
RBAC (Role-Based Access Control)
Permission Model
Flat resource:action pairs. No hierarchy, no wildcards, no inheritance.
Source of truth: packages/auth/src/permissions.ts
Resources and Actions
// Standard CRUD resources
const resources = {
// Better-Auth core (extended)
organization: ['create', 'read', 'update', 'delete'],
member: ['create', 'read', 'update', 'delete'],
invitation: ['create', 'read', 'update', 'delete'],
// GRC domain
control: ['create', 'read', 'update', 'delete'],
evidence: ['create', 'read', 'update', 'delete'],
policy: ['create', 'read', 'update', 'delete'],
risk: ['create', 'read', 'update', 'delete'],
vendor: ['create', 'read', 'update', 'delete'],
task: ['create', 'read', 'update', 'delete'],
framework: ['create', 'read', 'update', 'delete'],
finding: ['create', 'read', 'update', 'delete'],
questionnaire: ['create', 'read', 'update', 'delete'],
integration: ['create', 'read', 'update', 'delete'],
// Access gates
app: ['read'], // Can access the main app at all
trust: ['read', 'update'], // Trust portal management
pentest: ['create', 'read', 'delete'],
training: ['read', 'update'],
portal: ['read', 'update'], // Employee portal access
// Admin
audit: ['create', 'read', 'update'],
apiKey: ['create', 'read', 'delete'],
};Built-in Roles
Owner (full access)
All resources: [create, read, update, delete]
ac: [create, read, update, delete] // Access control management
Compliance obligations: YES
Admin (everything except org deletion)
organization: [read, update] // NO delete
All other resources: [create, read, update, delete]
Compliance obligations: YES
Auditor (read + findings)
control, evidence, policy, risk, vendor, task,
framework, questionnaire, integration: [read]
finding: [create, read, update] // Can create/edit findings
audit: [read]
member, invitation: [create, read] // Can invite other auditors
app, trust, pentest: [read]
Compliance obligations: NO
Employee (portal only)
policy: [read] // Can view/sign policies
portal: [read, update] // Can complete training
app: NONE // Cannot access main compliance app
Compliance obligations: YES
Contractor (same as employee)
policy: [read]
portal: [read, update]
app: NONE
Compliance obligations: YES
Role Hierarchy
contractor (1) < employee (2) < auditor (3) < admin (4) < owner (5)
Used for:
- Preventing privilege escalation (can't assign roles above your own)
- UI filtering (restricted roles get assignment-based filtering)
Custom Roles
Organizations can create custom roles stored in organization_role table:
{
name: "Security Lead",
permissions: {
control: ["create", "read", "update"],
risk: ["create", "read", "update", "delete"],
// ... custom permission set
},
obligations: {
compliance: true // Must complete compliance tasks
}
}Multi-Role Support
Users can have multiple roles (comma-separated in member.role):
member.role = "admin,auditor" // Gets union of both permission sets
Resolution in packages/auth/src/permissions.ts:
// Combined permissions = union of all role permissions
function getCombinedPermissions(roles: string[]): Permissions {
return roles.reduce((combined, role) => {
const rolePerms = getPermissionsForRole(role);
return mergePermissions(combined, rolePerms);
}, {});
}PermissionGuard (apps/api/src/auth/permission.guard.ts)
Enforces @RequirePermission metadata on every endpoint:
// Controller declaration
@Controller({ path: 'controls', version: '1' })
@UseGuards(HybridAuthGuard, PermissionGuard)
export class ControlsController {
@Get()
@RequirePermission('control', 'read')
findAll() { ... }
@Post()
@RequirePermission('control', 'create')
create() { ... }
@Delete(':id')
@RequirePermission('control', 'delete')
delete() { ... }
}Permission check cascade (apps/api/src/auth/permission.guard.ts:48-152):
1. API Key → Check scopes array contains resource:action
2. Service Token → Check SERVICE_DEFINITIONS[name].permissions
3. Platform Admin → Bypass (full access)
4. User Session → better-auth.hasPermission({ resource, actions })
└── Resolves from built-in role + custom role + multi-role union
Frontend Permission Gating
Route-level (server-side)
// apps/app/src/lib/permissions.server.ts
await requireRoutePermission('overview', orgId);
// Redirects to / if permission deniedNavigation gating
// ROUTE_PERMISSIONS maps route segments to required permissions
const ROUTE_PERMISSIONS = {
overview: { resource: 'framework', action: 'read' },
trust: { resource: 'trust', action: 'read' },
'penetration-tests': { resource: 'pentest', action: 'read' },
settings: // OR of: organization:update, evidence:read, apiKey:read
};
canAccessRoute(permissions, 'overview') // Boolean checkButton/action gating
hasPermission(permissions, 'control', 'update') // Boolean
hasAnyPermission(permissions, [
{ resource: 'control', action: 'update' },
{ resource: 'policy', action: 'update' }
])Audit Log Integration
The AuditLogInterceptor (apps/api/src/audit/audit-log.interceptor.ts) automatically logs mutations:
// ONLY logs when @RequirePermission metadata is present
// This means: no permission decorator = no audit trail = compliance gap
// Automatically captures:
{
organizationId,
userId,
memberId,
entityType, // from URL path
entityId, // from URL params
description, // "Updated control ctl_abc123"
method, // POST/PATCH/PUT/DELETE
path, // /v1/controls/ctl_abc123
resource, // 'control' (from RequirePermission)
permission, // 'update' (from RequirePermission)
data: {
changes: { // Computed diff
name: { previous: "Old Name", current: "New Name" }
},
impersonatedBy // If admin is impersonating
}
}Security Considerations
What's Done Well
- No JWT storage in browser - httpOnly cookies prevent XSS token theft
- Timing-safe service token comparison - prevents timing attacks
- API key prefix indexing - efficient lookup without exposing full key
- Automatic audit trail - compliance requirement met by default
- Multi-method auth - API keys for automation, service tokens for internal, sessions for humans
- Privilege escalation prevention - role hierarchy prevents granting higher roles
Potential Concerns
- Legacy API key sunset (April 20, 2026) - unscoped keys still have full access until then
- Comma-separated roles in a string field - could benefit from a join table
- Platform admin bypass - a single
user.role === 'admin'check grants full access - Session cookie SameSite: None - required for cross-subdomain but broader than needed
- Custom role permissions as JSON - no schema validation at DB level (validated in app layer)