Permissions
Role-based access where roles are bundles of flat permission strings. Guards check permissions, not roles.
Purpose
Adding a new capability ("content.delete") should not require branching over roles in code. Guards ask "does the current user have permission X?" and the answer is either yes or no. Roles exist only for admin-UX convenience — a way to assign N permissions in one click.
Contract reference: AUTH.md §5.
Implementation
Data model
// src/roles/schemas/role.schema.ts (exists today; extend)
@Schema({ timestamps: true })
class Role {
@Prop({ required: true, unique: true }) name: string; // e.g. "moderator"
@Prop() description?: string;
@Prop({ type: [String], default: [] }) permissions: string[]; // flat strings: "content.approve", etc.
@Prop({ default: true }) isActive: boolean;
}
// User already has roleIds[]
On login, AuthService loads the user, joins their roles, flattens the permissions:
const user = await usersService.findWithRoles(userId);
const perms = [...new Set(user.roles.flatMap(r => r.permissions))];
// perms goes into the JWT
Guards
Three:
// src/common/guards/jwt-auth.guard.ts
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
// 401 on missing/invalid token
// src/common/guards/optional-jwt-auth.guard.ts
@Injectable()
export class OptionalJwtAuthGuard extends AuthGuard('jwt') {
handleRequest<T>(err: unknown, user: T) { return (user ?? null) as T; }
}
// Never 401; request.user is the user if token present, null otherwise
// src/common/guards/permissions.guard.ts
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(ctx: ExecutionContext): boolean {
const required = this.reflector.getAllAndMerge<string[]>('permissions', [
ctx.getHandler(), ctx.getClass(),
]) ?? [];
if (!required.length) return true;
const user = ctx.switchToHttp().getRequest().user as AuthenticatedUser | null;
if (!user) throw new UnauthorizedException({ code: 'auth.missing_token' });
const missing = required.filter(p => !user.permissions.includes(p));
if (missing.length) throw new ForbiddenException({ code: 'auth.forbidden', details: { missing } });
return true;
}
}
Decorator
// src/common/decorators/permissions.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const RequiredPermissions = (...perms: string[]) => SetMetadata('permissions', perms);
Parameter decorator
// src/common/decorators/current-user.decorator.ts
export const CurrentUser = createParamDecorator(
(_: unknown, ctx: ExecutionContext) =>
ctx.switchToHttp().getRequest().user as AuthenticatedUser | null,
);
export interface AuthenticatedUser {
id: string;
permissions: string[];
}
Usage
@Controller('content')
export class ContentController {
@Get()
@UseGuards(OptionalJwtAuthGuard)
list(@Query() q: ContentSearchQueryDto, @CurrentUser() user: AuthenticatedUser | null) {
return this.content.search(q, user);
}
@Post('submit')
@UseGuards(JwtAuthGuard)
submit(@Body() dto: SubmitContentDto, @CurrentUser() user: AuthenticatedUser) {
return this.content.submit(dto, user.id);
}
@Post(':slug/approve')
@UseGuards(JwtAuthGuard, PermissionsGuard)
@RequiredPermissions('content.approve')
approve(@Param('slug') slug: string, @CurrentUser() user: AuthenticatedUser) {
return this.content.approve(slug, user.id);
}
}
Why embed permissions in the JWT
- Guard runs in O(1) on
user.permissions.includes(x). No database hit per request. - Permissions can only change on next login (or next refresh, which re-embeds). See tokens.md for the staleness window.
If real-time revocation is ever required, a tokenVersion on User + check on every request is the answer — pay the DB round-trip at that point.
Permission naming convention
<domain>.<action> — all lowercase, dotted.
| Example | Meaning |
|---|---|
content.submit | Implicit for authenticated users; listed for clarity in seed data. |
content.moderate | Read non-approved content (pending queue visibility). |
content.approve | Transition pending → approved/rejected. |
content.delete | Soft-delete content. |
tag.manage | Create, update, rename tags. |
catalog.manage | Edit surfaces, groups, channels, platforms. |
user.manage | CRUD users. |
user.invite | Issue invites when self-registration is closed. |
role.manage | Edit roles and their permissions. |
Add new permissions by: updating the starter seeds, updating the admin UI's checkbox list, and adding the @RequiredPermissions() decorator where the new capability is exercised.
Required variables and services
AuthModuleprovides the guards.UsersService.findWithRoles(userId)— needed by auth service to mint JWTs with embedded permissions.- No env variables specific to permissions.
Gotchas
- Don't gate on role names.
if (user.role === 'admin')looks harmless and is the wrong shape — a new role without "admin" in the name but with every permission should work. Gate on permissions only. PermissionsGuardrequiresJwtAuthGuardto run first. Decorator order:@UseGuards(JwtAuthGuard, PermissionsGuard). Without JwtAuthGuard,request.useris undefined and PermissionsGuard 401s on every call — confusing symptom.OptionalJwtAuthGuard+ PermissionsGuard is a mistake. If you want "anonymous can read but authenticated gets more," branch in the service based onCurrentUserbeing null. Don't stack the guards.- Seed data is load-bearing. The default roles (
admin,moderator,member) are assumed to exist by first-user flows. If seeding fails, fix it before accepting sign-ups.
Testing
- Unit:
PermissionsGuard— assert behavior for (no decorator), (decorator + matching perms), (decorator + missing perms), (decorator + no user). - Unit: role flattening — two roles with overlapping perms → deduped list.
- Integration: authenticated user without
content.approvecallsPOST /content/<slug>/approve→ 403auth.forbiddenwithdetails.missing === ['content.approve']. - Integration: admin user calls approve → 200.
- Integration: seed + login + JWT payload includes expected permissions.