Skip to main content

Authorization (CASL)

CASL is the only authorization mechanism on the backend. Inline ownership checks (entity.userId !== currentUserId, currentUser.id === entity.userId, manual rank string comparisons, etc.) are forbidden — they always belong as CASL rules.

This page documents how the system is wired end-to-end and how to extend it when you add a new user-owned subject.

Architecture

flowchart LR
Req[HTTP Request] --> Guard[AbilitiesGuard]
Guard --> Resolver[SubjectResolver registry]
Resolver --> DB[(Prisma)]
Guard --> Factory[AbilityFactory]
Factory --> Rules[CASL rules]
Guard -->|allow / forbid| Ctrl[Controller]
Ctrl --> UseCase[Use Case]
UseCase -->|business logic only| Resp[Response]

Key principles:

  • AbilityFactory is the single source of truth for what a user can do. It produces a PureAbility per request based on the user, optional membership, and platform-admin flag.
  • AbilitiesGuard is the only thing that calls ability.can(...) on the HTTP path. It loads the subject instance via a pluggable SubjectResolver, then evaluates the abilities declared on the route by @RequireAbilities.
  • Use cases / services stay free of ForbiddenException. By the time they run, the guard has already proved the caller is allowed.
  • Frontend mirrors the same rules via packed rules served from GET /auth/me. UI gating (ability.can(...)) must match the backend exactly — never recompute ownership from me.id === entity.userId.

The 6-Step Recipe (adding a new user-owned subject)

Use this checklist any time you add a new entity the owning user should be able to manage (Character, WishlistItem, Friendship, GuildCraftingRequest, MediaAsset, etc.).

1. Register the subject

apps/api/src/auth/abilities/ability.types.ts:

export type AppSubjects =
| 'Organization'
| 'Event'
| 'Character'
| 'WishlistItem'
| 'YourNewSubject' // ← add here
| 'all';

2. Declare the rule

apps/api/src/auth/abilities/ability.factory.ts — inside createForUser:

can(Action.Update, 'YourNewSubject', { userId: user.id });
can(Action.Delete, 'YourNewSubject', { userId: user.id });

For nested ownership (e.g. a row that belongs through a Character):

can(Action.Update, 'GuildCraftingRequest', {
requester: { is: { character: { is: { userId: user.id } } } },
});

3. Create a SubjectResolver

apps/api/src/auth/abilities/subject-resolvers/your-new-subject.resolver.ts:

@Injectable()
export class YourNewSubjectResolver implements SubjectResolver {
readonly subject = 'YourNewSubject' as const;

constructor(private readonly prisma: PrismaService) {}

async resolve(request: Request): Promise<unknown | null> {
const id = (request.params as Record<string, string>).id;
if (!id) return null;
return this.prisma.yourNewSubject.findUnique({
where: { id },
// include ONLY the fields the CASL rule needs
select: { id: true, userId: true },
});
}
}

4. Register the resolver

apps/api/src/auth/auth.module.ts — add it to providers and to the SUBJECT_RESOLVERS aggregate so AbilitiesGuard can find it.

5. Decorate the controller

@Patch(':id')
@UseGuards(AbilitiesGuard)
@RequireAbilities({ action: Action.Update, subject: 'YourNewSubject' })
async update(@Param('id') id: string, @Body() dto: UpdateDto) {
return this.useCase.execute(id, dto); // no userId param for auth purposes
}

The use case is now pure business logic. Drop any ForbiddenException ownership checks inside it.

6. Mirror on the frontend

  • apps/web/src/lib/auth/abilities.ts — add 'YourNewSubject' to the Subjects union.
  • Replace any me?.id === entity.userId UI gates with ability.can(Action.Update, caslSubject('YourNewSubject', entity)).

The backend serializes user-level rules into /auth/me's abilityRules field; the AbilityContext.Provider in RootLayout rebuilds the global ability on every fetch, so new subjects automatically light up across the app.

Why use cases stay clean

Before:

// ❌ Scattered, duplicated, untestable in isolation
if (character.userId !== userId) {
throw new ForbiddenException('Not your character');
}

After:

// ✅ Pure business logic. AbilitiesGuard already proved ownership.
const character = await this.repo.findById(id);
if (!character) throw new NotFoundException();
// ...mutate

This pays off because:

  • All ownership logic lives in one place (ability.factory.ts).
  • Tests for AbilityFactory give complete authorization coverage. Use case tests only have to cover the happy path + business validation errors.
  • Future services (e.g. the chat-node service) can consume the same factory via RPC and stay in sync with the API.

Lint Guard: scripts/check-authorization-drift.js

The pre-commit hook (.husky/pre-commit) runs this script to catch new inline ownership checks before they land:

node scripts/check-authorization-drift.js

What it flags:

  • apps/api/src/domains/**/*.ts — any entity.userId !== someUserId pattern.
  • apps/web/src/**/*.{ts,tsx} — any currentUser?.id === entity.userId (or me, user, sessionUser, session) pattern.

What it allows:

  • Test files (*.spec.ts, *.test.tsx).
  • apps/api/src/admin/** (platform-admin paths are intentionally privileged).
  • Any line annotated with // casl-allow: <reason> (on the same line or the line directly above). Reserve this for genuine non-authorization uses like data anonymization or filtering by the current user's own records.

Run it manually anytime:

pnpm lint:authorization

Auditing Existing Code

# Backend
rg --type ts '\.userId\s*!==\s*\w+Id' apps/api/src/domains

# Frontend
rg --type ts '\b(currentUser|me|user|sessionUser)\??\.id\s*===\s*\w+\.userId' apps/web/src

Any match outside test files and apps/api/src/admin/** is debt — open a task to migrate it to CASL.

Frontend Specifics

The frontend has its own AbilityFactory-equivalent (apps/web/src/lib/auth/abilities.ts). It accepts the packed rules served from /auth/me and rehydrates them into a PureAbility.

Notable: the frontend matchConditions matcher implements just the subset of CASL Prisma operators the backend actually uses (flat scalar equality, nested relation objects, and the is wrapper). When a rule needs anything else — in, not, arrays — add it to the matcher at the same time you add it to the backend rule. Keep the two sides in sync.