Skip to content

CASL Authorization

This system uses a modular, multi-tenant authorization mechanism backed by CASL. It enforces centralized, fine-grained access control policies across all domain modules without coupling handlers directly to complex authorization logic.

Overview

We designed the authorization flow to support the "agency/business tenant + granular roles" problem efficiently within our DDD/CQRS architecture.

The implementation relies on:

  1. Core Authorization Module (apps/api/src/core/authorization/): Houses the foundational components, including the ability factory and validation services.
  2. Subject Loaders: A plugin pattern where each domain module teaches the core how to resolve a minimal instance of its own entities (e.g. User, Agent).
  3. Persisted Permissions: Dynamic JSON rules stored in Role → Policy and UserPolicy tables, interpolated at runtime with ${user.id}, ${tenant.id}, ${tenant.orgId}.

Key Components

1. AuthorizationModule and CaslAbilityFactory

The core AuthorizationModule constructs a dynamic MongoAbility for each request. It evaluates a set of raw permission rules associated with a user and tenantContext.

The CaslAbilityFactory handles interpolating context variables like ${user.id} and ${tenant.id} into JSON condition rules dynamically.

json
// Example Persisted Rule:
{
  "action": "manage",
  "subject": "Business",
  "conditions": { "organizationId": "${tenant.id}" }
}

2. IRawPermission Interface

All permission rules flowing through the system conform to:

typescript
interface IRawPermission {
  action: string;
  subject: string;
  conditions?: Record<string, any> | null;
  inverted?: boolean; // deny rule support
}

3. Guard-Level Checks

For simple, broad checks at the HTTP boundary, the @CheckPolicies decorator combined with the PoliciesGuard block unauthorized requests early.

typescript
  @UseGuards(PoliciesGuard)
  @CheckPolicies((ability) => ability.can('manage', 'all'))
  @Get('my')
  async getMyOrganization() { ... }

4. Subject Loaders

Because CASL validates objects (not just string IDs), but our CQRS commands often receive IDs via the request body, we implemented a generic SubjectLoader pattern.

Each domain module implements a loader (e.g., UserSubjectLoader) that knows how to resolve the minimal attributes of a subject (id, organizationId, ownerId) and registers itself with the AuthorizationService.

5. AuthorizationService for Command Handlers

In our application layer (commands/queries), we use the AuthorizationService to assert permissions directly at the use-case boundary.

typescript
async execute(command: UpdateUserDetailsCommand) {
  // Pass the context, action, subject string, and target ID
  await this.authorizationService.assertCan(
    command.authContext,
    'update',
    'User',
    command.targetUserId
  );
  // ... proceed with logic
}

This ensures handlers remain clean and focus only on domain execution while relying on the centralized authorization mechanism.

6. OrgAccessGuard + IdentityPermissionsProvider (DB-backed)

Org-scoped routes now have an explicit access-validation layer before controller code runs:

  1. Any endpoint that uses @OrgId() is marked as org-scoped by the decorator.
  2. The global OrgAccessGuard reads x-org-id, validates the authenticated session context, and delegates to the registered permissions provider.
  3. The permissions provider verifies two things before allowing the request to continue:
    • the requested organization belongs to the current session agency (organization.agencyId === user.agencyId)
    • the authenticated user is a member of that organization

This closes two gaps:

  • routes with @OrgId() but without @CheckPolicies() still validate org access
  • x-org-id can no longer point to an organization outside the agency carried in the token

The same provider validation is reused later by PoliciesGuard and AuthorizationService.assertCan(...), so mutation handlers and policy-protected reads keep the same org/agency guarantees.

7. IdentityPermissionsProvider permission loading

The IdentityPermissionsProvider implements IPermissionsProvider and is the bridge between the database-backed RBAC system and the CASL evaluation engine.

Flow:

  1. Always returns base self-management permissions: read/update own User (with ${user.id} condition).
  2. Validates org scope: loads the target organization and rejects if it does not belong to the current session agency.
  3. Validates org membership: queries OrganizationMember by userId + orgId. If not found, throws ForbiddenException — this prevents x-org-id spoofing inside the same agency too.
  4. If the member has roles → fetches all Policy[] attached to those roles.
  5. Fetches all UserPolicy[] for userId + orgId.
  6. Merges: role policies first, then user policies (user policies extend/override).
  7. Returns as IRawPermission[].

8. Deny Rules (inverted)

Both Policy and UserPolicy support an inverted: boolean field (default false). When inverted: true, CASL treats the rule as a deny rule — the user is explicitly forbidden from performing that action on that subject, even if another rule grants it.

json
// Example: Allow manage Agent, but deny delete Agent
[
  { "action": "manage", "subject": "Agent" },
  { "action": "delete", "subject": "Agent", "inverted": true }
]

9. Security: __caslSubjectType__ Injection Prevention

The AuthorizationService.assertCan() method strips __caslSubjectType__ from user-provided objects before passing them to CASL's subject() helper. This prevents callers from injecting a different subject type to bypass permission checks.

Conventions

  • String Subjects Only: We strictly use string subjects (e.g., 'User', 'Business') instead of ORM classes. This aligns with DDD principles by decoupling domain logic from persistence entities.
  • Precomputed Access Scopes for Queries: Instead of translating dynamic CASL abstract syntax trees into TypeORM queries (which adds high complexity), we use Option C: precomputed access scopes. List endpoints are structurally hard-scoped by tenantId in the repository layer using QueryBuilder, while CASL strictly protects mutations and targeted read operations.
  • Typed Interfaces: IUserContext, ITenantContext, and IRawPermission provide type safety across the authorization pipeline.
  • Collection-Level Filtering: For getAll and paginated queries, use the CASL Access Filters system to translate policy conditions into TypeORM WHERE clauses instead of post-filtering in memory.