CodeDocs Vault

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 identity

Key features:

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;
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:

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 origins

Better Auth Server Configuration (apps/api/src/auth/auth.server.ts)

Plugins enabled:

Social providers (optional):


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:

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 denied
// 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 check

Button/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

  1. No JWT storage in browser - httpOnly cookies prevent XSS token theft
  2. Timing-safe service token comparison - prevents timing attacks
  3. API key prefix indexing - efficient lookup without exposing full key
  4. Automatic audit trail - compliance requirement met by default
  5. Multi-method auth - API keys for automation, service tokens for internal, sessions for humans
  6. Privilege escalation prevention - role hierarchy prevents granting higher roles

Potential Concerns

  1. Legacy API key sunset (April 20, 2026) - unscoped keys still have full access until then
  2. Comma-separated roles in a string field - could benefit from a join table
  3. Platform admin bypass - a single user.role === 'admin' check grants full access
  4. Session cookie SameSite: None - required for cross-subdomain but broader than needed
  5. Custom role permissions as JSON - no schema validation at DB level (validated in app layer)