Appearance
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:
- Core Authorization Module (
apps/api/src/core/authorization/): Houses the foundational components, including the ability factory and validation services. - 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). - Persisted Permissions: Dynamic JSON rules stored in
Role → PolicyandUserPolicytables, 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:
- Any endpoint that uses
@OrgId()is marked as org-scoped by the decorator. - The global
OrgAccessGuardreadsx-org-id, validates the authenticated session context, and delegates to the registered permissions provider. - 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
- the requested organization belongs to the current session agency (
This closes two gaps:
- routes with
@OrgId()but without@CheckPolicies()still validate org access x-org-idcan 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:
- Always returns base self-management permissions:
read/updateownUser(with${user.id}condition). - Validates org scope: loads the target organization and rejects if it does not belong to the current session agency.
- Validates org membership: queries
OrganizationMemberbyuserId + orgId. If not found, throwsForbiddenException— this preventsx-org-idspoofing inside the same agency too. - If the member has roles → fetches all
Policy[]attached to those roles. - Fetches all
UserPolicy[]foruserId + orgId. - Merges: role policies first, then user policies (user policies extend/override).
- 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
tenantIdin the repository layer usingQueryBuilder, while CASL strictly protects mutations and targeted read operations. - Typed Interfaces:
IUserContext,ITenantContext, andIRawPermissionprovide type safety across the authorization pipeline. - Collection-Level Filtering: For
getAlland paginated queries, use the CASL Access Filters system to translate policy conditions into TypeORM WHERE clauses instead of post-filtering in memory.