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:
AbilityFactoryis the single source of truth for what a user can do. It produces aPureAbilityper request based on the user, optional membership, and platform-admin flag.AbilitiesGuardis the only thing that callsability.can(...)on the HTTP path. It loads the subject instance via a pluggableSubjectResolver, 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 fromme.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 theSubjectsunion.- Replace any
me?.id === entity.userIdUI gates withability.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
AbilityFactorygive 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— anyentity.userId !== someUserIdpattern.apps/web/src/**/*.{ts,tsx}— anycurrentUser?.id === entity.userId(orme,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.
Related
Authorization-Pattern.mdc— the Cursor rule that codifies this.- Permissions Architecture — how WoW guild ranks map into
the
Role.permissionsJSON thatAbilityFactory.createForMemberconsumes.