feat: add CASL authorization and AI configuration
This commit is contained in:
11
apps/server/src/authorization/authorization.module.ts
Normal file
11
apps/server/src/authorization/authorization.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { CaslAbilityFactory } from './casl-ability.factory';
|
||||
import { AuthorizationService } from './authorization.service';
|
||||
import { PoliciesGuard } from './guards/policies.guard';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [CaslAbilityFactory, AuthorizationService, PoliciesGuard],
|
||||
exports: [CaslAbilityFactory, AuthorizationService, PoliciesGuard],
|
||||
})
|
||||
export class AuthorizationModule {}
|
||||
180
apps/server/src/authorization/authorization.service.spec.ts
Normal file
180
apps/server/src/authorization/authorization.service.spec.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { AuthorizationService } from './authorization.service';
|
||||
import { CaslAbilityFactory } from './casl-ability.factory';
|
||||
import { CaslAction, SubjectName } from './casl.constants';
|
||||
import { AuthenticatedUser } from './interfaces';
|
||||
|
||||
describe('AuthorizationService', () => {
|
||||
const factory = new CaslAbilityFactory();
|
||||
const service = new AuthorizationService(factory);
|
||||
|
||||
const superAdmin: AuthenticatedUser = {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
permissions: [],
|
||||
isSuperAdmin: true,
|
||||
roles: ['超管'],
|
||||
};
|
||||
|
||||
const teacher: AuthenticatedUser = {
|
||||
id: 2,
|
||||
username: 'teacher',
|
||||
permissions: ['student:view', 'class:view'],
|
||||
isSuperAdmin: false,
|
||||
roles: ['老师'],
|
||||
};
|
||||
|
||||
const emptyUserReq = (user: AuthenticatedUser) => ({
|
||||
user,
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// HTTP convenience methods
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('can() — HTTP request convenience', () => {
|
||||
it('returns true for super admin on any action/subject', () => {
|
||||
expect(service.can(emptyUserReq(superAdmin), CaslAction.Manage, SubjectName.Student)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(service.can(emptyUserReq(superAdmin), CaslAction.Delete, 'all')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for user with matching permission', () => {
|
||||
expect(service.can(emptyUserReq(teacher), CaslAction.Read, SubjectName.Student)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for user without matching permission', () => {
|
||||
expect(service.can(emptyUserReq(teacher), CaslAction.Create, SubjectName.Student)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(service.can(emptyUserReq(teacher), CaslAction.Read, SubjectName.Bill)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assert() — HTTP request convenience', () => {
|
||||
it('does not throw for super admin', () => {
|
||||
expect(() =>
|
||||
service.assert(emptyUserReq(superAdmin), CaslAction.Delete, SubjectName.Room),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('does not throw for user with permission', () => {
|
||||
expect(() =>
|
||||
service.assert(emptyUserReq(teacher), CaslAction.Read, SubjectName.Student),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws ForbiddenException for user without permission', () => {
|
||||
expect(() =>
|
||||
service.assert(emptyUserReq(teacher), CaslAction.Create, SubjectName.Student),
|
||||
).toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Non-HTTP reuse (Agent Tool / background job pattern)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('abilityForRequest()', () => {
|
||||
it('builds ability from request.user', () => {
|
||||
const ability = service.abilityForRequest(emptyUserReq(teacher));
|
||||
expect(ability.can(CaslAction.Read, SubjectName.Class)).toBe(true);
|
||||
expect(ability.can(CaslAction.Delete, SubjectName.Student)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAbility() / assertAbility() — non-HTTP usage', () => {
|
||||
const ability = factory.createForUser(teacher);
|
||||
|
||||
it('canAbility returns boolean', () => {
|
||||
expect(service.canAbility(ability, CaslAction.Read, SubjectName.Student)).toBe(true);
|
||||
expect(service.canAbility(ability, CaslAction.Delete, SubjectName.Student)).toBe(false);
|
||||
});
|
||||
|
||||
it('assertAbility throws on denial', () => {
|
||||
expect(() =>
|
||||
service.assertAbility(ability, CaslAction.Read, SubjectName.Student),
|
||||
).not.toThrow();
|
||||
|
||||
expect(() => service.assertAbility(ability, CaslAction.Delete, SubjectName.Student)).toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('canAbility and assertAbility work independently of HTTP context', () => {
|
||||
// This is the key Agent Tool pattern:
|
||||
// 1. Build ability from a user object (no req needed)
|
||||
const toolAbility = factory.createForUser({
|
||||
permissions: ['attendance:view', 'attendance:create'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
// 2. Check / assert using the service
|
||||
expect(service.canAbility(toolAbility, CaslAction.Read, SubjectName.Attendance)).toBe(true);
|
||||
expect(service.canAbility(toolAbility, CaslAction.Create, SubjectName.Attendance)).toBe(true);
|
||||
expect(service.canAbility(toolAbility, CaslAction.Delete, SubjectName.Attendance)).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
// 3. assertAbility for write operations
|
||||
expect(() =>
|
||||
service.assertAbility(toolAbility, CaslAction.Create, SubjectName.Attendance),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
service.assertAbility(toolAbility, CaslAction.Delete, SubjectName.Attendance),
|
||||
).toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// canPermission() / assertPermission() — exact-code permission checks
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('canPermission() / assertPermission() — exact-code checks', () => {
|
||||
const ability = factory.createForUser(teacher);
|
||||
|
||||
it('canPermission returns true for owned exact permission code', () => {
|
||||
expect(service.canPermission(ability, 'student:view')).toBe(true);
|
||||
expect(service.canPermission(ability, 'class:view')).toBe(true);
|
||||
});
|
||||
|
||||
it('canPermission returns false for unowned exact permission code', () => {
|
||||
expect(service.canPermission(ability, 'student:delete')).toBe(false);
|
||||
expect(service.canPermission(ability, 'bill:view')).toBe(false);
|
||||
});
|
||||
|
||||
it('canPermission uses Access + permissionCodeSubject, not domain action', () => {
|
||||
// teacher has student:view and class:view. Custom code check is exact.
|
||||
expect(service.canPermission(ability, 'student:export')).toBe(false);
|
||||
});
|
||||
|
||||
it('assertPermission does not throw for owned code', () => {
|
||||
expect(() => service.assertPermission(ability, 'student:view')).not.toThrow();
|
||||
});
|
||||
|
||||
it('assertPermission throws ForbiddenException for unowned code', () => {
|
||||
expect(() => service.assertPermission(ability, 'student:delete')).toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('assertPermission error message includes permission code', () => {
|
||||
expect(() => service.assertPermission(ability, 'bill:view')).toThrow(
|
||||
/bill:view/,
|
||||
);
|
||||
});
|
||||
|
||||
it('super admin canPermission returns true for any code', () => {
|
||||
const saAbility = factory.createForUser(superAdmin);
|
||||
expect(service.canPermission(saAbility, 'student:view')).toBe(true);
|
||||
expect(service.canPermission(saAbility, 'custom:action')).toBe(true);
|
||||
expect(service.canPermission(saAbility, 'bill:export-excel')).toBe(true);
|
||||
});
|
||||
|
||||
it('super admin assertPermission never throws', () => {
|
||||
const saAbility = factory.createForUser(superAdmin);
|
||||
expect(() => service.assertPermission(saAbility, 'ghost:action')).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
95
apps/server/src/authorization/authorization.service.ts
Normal file
95
apps/server/src/authorization/authorization.service.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { CaslAbilityFactory } from './casl-ability.factory';
|
||||
import { AppAbility, AppSubject, AuthorizationRequest } from './interfaces';
|
||||
import { CaslAction, permissionCodeSubject } from './casl.constants';
|
||||
|
||||
/**
|
||||
* Generic authorization service usable both inside and outside of HTTP
|
||||
* request context.
|
||||
*
|
||||
* ### HTTP use
|
||||
* Inject `AuthorizationService` into controllers/services and call
|
||||
* `abilityForRequest(req)` to get the current user's ability.
|
||||
*
|
||||
* ### Non-HTTP use (Agent Tool, background job, etc.)
|
||||
* Build an ability via `abilityFactory.createForUser(user)` and pass it
|
||||
* to `assert` / `can` directly.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuthorizationService {
|
||||
constructor(private readonly abilityFactory: CaslAbilityFactory) {}
|
||||
|
||||
/**
|
||||
* Build an {@link AppAbility} for the current HTTP request.
|
||||
*
|
||||
* @param req — Express/NestJS request with `req.user` populated by JWT.
|
||||
*/
|
||||
abilityForRequest(req: AuthorizationRequest): AppAbility {
|
||||
if (!req.user) {
|
||||
throw new ForbiddenException('缺少可信授权身份');
|
||||
}
|
||||
return this.abilityFactory.createForUser(req.user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the given ability allows the action on the subject.
|
||||
* Throws `ForbiddenException` on denial.
|
||||
*/
|
||||
assertAbility(ability: AppAbility, action: CaslAction, subject: AppSubject): void {
|
||||
if (!ability.can(action, subject)) {
|
||||
throw new ForbiddenException(
|
||||
`权限不足:${action} ${typeof subject === 'string' ? subject : 'resource'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the given ability allows the action on the subject.
|
||||
* Returns boolean — never throws.
|
||||
*/
|
||||
canAbility(ability: AppAbility, action: CaslAction, subject: AppSubject): boolean {
|
||||
return ability.can(action, subject);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the given ability allows the exact permission code.
|
||||
* Uses `CaslAction.Access` with `permissionCodeSubject(code)` — the same
|
||||
* mechanism as {@link PermissionGuard}.
|
||||
*
|
||||
* Returns boolean — never throws.
|
||||
*/
|
||||
canPermission(ability: AppAbility, permissionCode: string): boolean {
|
||||
return ability.can(CaslAction.Access, permissionCodeSubject(permissionCode));
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the given ability allows the exact permission code.
|
||||
* Throws `ForbiddenException` on denial.
|
||||
*/
|
||||
assertPermission(ability: AppAbility, permissionCode: string): void {
|
||||
if (!this.canPermission(ability, permissionCode)) {
|
||||
throw new ForbiddenException(
|
||||
`权限不足:缺少权限码 ${permissionCode}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the user (from request) can perform an action.
|
||||
* Convenience shorthand — builds ability from request.
|
||||
*/
|
||||
assert(req: AuthorizationRequest, action: CaslAction, subject: AppSubject): void {
|
||||
const ability = this.abilityForRequest(req);
|
||||
this.assertAbility(ability, action, subject);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that the user (from request) can perform an action.
|
||||
* Convenience shorthand — builds ability from request.
|
||||
*/
|
||||
can(req: AuthorizationRequest, action: CaslAction, subject: AppSubject): boolean {
|
||||
const ability = this.abilityForRequest(req);
|
||||
return this.canAbility(ability, action, subject);
|
||||
}
|
||||
}
|
||||
327
apps/server/src/authorization/casl-ability.factory.spec.ts
Normal file
327
apps/server/src/authorization/casl-ability.factory.spec.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import { subject } from '@casl/ability';
|
||||
import { CaslAbilityFactory } from './casl-ability.factory';
|
||||
import {
|
||||
CaslAction,
|
||||
SubjectName,
|
||||
permissionCodeSubject,
|
||||
mapPermissionCode,
|
||||
isKnownPermissionCode,
|
||||
} from './casl.constants';
|
||||
|
||||
describe('CaslAbilityFactory', () => {
|
||||
const factory = new CaslAbilityFactory();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('grants manage all for super admin regardless of permissions list', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: [],
|
||||
isSuperAdmin: true,
|
||||
});
|
||||
|
||||
expect(ability.can(CaslAction.Manage, 'all')).toBe(true);
|
||||
expect(ability.can(CaslAction.Read, SubjectName.Student)).toBe(true);
|
||||
expect(ability.can(CaslAction.Delete, SubjectName.Class)).toBe(true);
|
||||
});
|
||||
|
||||
it('super admin ability can manage arbitrary subject strings', () => {
|
||||
const ability = factory.createForUser({ permissions: [], isSuperAdmin: true });
|
||||
expect(ability.can(CaslAction.Manage, 'FictionalEntity')).toBe(true);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Exact-code permissions (layer 1 — collision-free)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('grants exact-code access for specific permission codes', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['bill:view'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('bill:view'))).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT grant exact-code access for a different code in same domain', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['bill:view'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
// bill:view user should NOT have bill:export-excel exact code
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('bill:export-excel'))).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT allow export to satisfy view or vice versa', () => {
|
||||
const viewUser = factory.createForUser({
|
||||
permissions: ['attendance:view'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
const exportUser = factory.createForUser({
|
||||
permissions: ['attendance:export'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
expect(viewUser.can(CaslAction.Access, permissionCodeSubject('attendance:export'))).toBe(false);
|
||||
expect(exportUser.can(CaslAction.Access, permissionCodeSubject('attendance:view'))).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT allow edit to satisfy approve on same resource', () => {
|
||||
const editor = factory.createForUser({
|
||||
permissions: ['deposit:edit'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
expect(editor.can(CaslAction.Access, permissionCodeSubject('deposit:approve'))).toBe(false);
|
||||
});
|
||||
|
||||
it('custom/unknown codes get exact-code ability but NO domain ability', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['student:nuke'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
// Exact code should be granted
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('student:nuke'))).toBe(true);
|
||||
|
||||
// But no domain ability should exist
|
||||
expect(ability.can(CaslAction.Manage, SubjectName.Student)).toBe(false);
|
||||
expect(ability.can(CaslAction.Delete, SubjectName.Student)).toBe(false);
|
||||
expect(ability.can(CaslAction.Read, SubjectName.Student)).toBe(false);
|
||||
expect(ability.can(CaslAction.Update, SubjectName.Student)).toBe(false);
|
||||
expect(ability.can(CaslAction.Create, SubjectName.Student)).toBe(false);
|
||||
});
|
||||
|
||||
it('unknown resource custom code gets exact-code but no domain', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['custom:action'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('custom:action'))).toBe(true);
|
||||
// No domain ability for unknown resource
|
||||
const hasAnyDomain = [SubjectName.Student, SubjectName.Bill, SubjectName.Class].some((s) =>
|
||||
ability.can(CaslAction.Read, s),
|
||||
);
|
||||
expect(hasAnyDomain).toBe(false);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Domain-level permissions (layer 2 — for service scoping)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('maps student:view to domain read Student', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['student:view'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
expect(ability.can(CaslAction.Read, SubjectName.Student)).toBe(true);
|
||||
expect(ability.can(CaslAction.Update, SubjectName.Student)).toBe(false);
|
||||
expect(ability.can(CaslAction.Delete, SubjectName.Student)).toBe(false);
|
||||
});
|
||||
|
||||
it('maps student:edit to domain update Student', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['student:edit'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
expect(ability.can(CaslAction.Update, SubjectName.Student)).toBe(true);
|
||||
expect(ability.can(CaslAction.Read, SubjectName.Student)).toBe(false);
|
||||
});
|
||||
|
||||
it('maps student:create to domain create Student', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['student:create'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
expect(ability.can(CaslAction.Create, SubjectName.Student)).toBe(true);
|
||||
});
|
||||
|
||||
it('maps student:delete to domain delete Student', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['student:delete'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
expect(ability.can(CaslAction.Delete, SubjectName.Student)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not broaden occupancy:checkin into generic create ability', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['occupancy:checkin'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
expect(ability.can(CaslAction.Create, SubjectName.Occupancy)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not broaden bill:export-excel into generic read ability', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['bill:export-excel'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
expect(ability.can(CaslAction.Read, SubjectName.Bill)).toBe(false);
|
||||
// But exact code is separate
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('bill:export-excel'))).toBe(true);
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('bill:view'))).toBe(false);
|
||||
});
|
||||
|
||||
it('cumulative: multiple permissions all apply at both layers', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['student:view', 'room:create', 'bill:delete'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
// Domain layer
|
||||
expect(ability.can(CaslAction.Read, SubjectName.Student)).toBe(true);
|
||||
expect(ability.can(CaslAction.Create, SubjectName.Room)).toBe(true);
|
||||
expect(ability.can(CaslAction.Delete, SubjectName.Bill)).toBe(true);
|
||||
|
||||
// Exact-code layer
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('student:view'))).toBe(true);
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('room:create'))).toBe(true);
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('bill:delete'))).toBe(true);
|
||||
});
|
||||
|
||||
it('recognizes CASL subject instances instead of treating them as all', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['student:view'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
const student = subject(SubjectName.Student, { id: 1, classId: 7 });
|
||||
|
||||
expect(ability.can(CaslAction.Read, student)).toBe(true);
|
||||
expect(ability.can(CaslAction.Update, student)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not let special exact-code permissions grant generic CRUD policies', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['student:import', 'deposit:approve', 'bill:confirm'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
expect(ability.can(CaslAction.Create, SubjectName.Student)).toBe(false);
|
||||
expect(ability.can(CaslAction.Update, SubjectName.Deposit)).toBe(false);
|
||||
expect(ability.can(CaslAction.Update, SubjectName.Bill)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not broaden archive into generic delete ability', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['student:archive'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
expect(ability.can(CaslAction.Delete, SubjectName.Student)).toBe(false);
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('student:archive'))).toBe(true);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Unknown permission codes
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('silently ignores unknown codes for domain but grants exact-code access', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['unknown:stuff', 'student:view'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
// Domain: only known code applies
|
||||
expect(ability.can(CaslAction.Read, SubjectName.Student)).toBe(true);
|
||||
// Exact: both codes get access
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('student:view'))).toBe(true);
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('unknown:stuff'))).toBe(true);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Profile auto-grant
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('does not grant broad Profile read without an explicit permission', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: [],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
expect(ability.can(CaslAction.Read, SubjectName.Profile)).toBe(false);
|
||||
// No exact-code access either
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('profile:view'))).toBe(false);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// createFromPermissions helper
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('createFromPermissions helper works for tests', () => {
|
||||
const ability = factory.createFromPermissions(['student:view']);
|
||||
expect(ability.can(CaslAction.Read, SubjectName.Student)).toBe(true);
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('student:view'))).toBe(true);
|
||||
});
|
||||
|
||||
it('createFromPermissions helper supports isSuperAdmin flag', () => {
|
||||
const ability = factory.createFromPermissions([], true);
|
||||
expect(ability.can(CaslAction.Manage, 'all')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Standalone mapping function tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('mapPermissionCode', () => {
|
||||
it('maps known codes', () => {
|
||||
expect(mapPermissionCode('student:view')).toEqual({
|
||||
action: CaslAction.Read,
|
||||
subject: SubjectName.Student,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for unknown resource', () => {
|
||||
expect(mapPermissionCode('ghost:action')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty string', () => {
|
||||
expect(mapPermissionCode('')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not map occupancy:checkin to a generic domain action', () => {
|
||||
expect(mapPermissionCode('occupancy:checkin')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not map bill:export-excel to generic read', () => {
|
||||
expect(mapPermissionCode('bill:export-excel')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not map bill:generate to generic update', () => {
|
||||
expect(mapPermissionCode('bill:generate')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not map deposit:approve to generic update', () => {
|
||||
expect(mapPermissionCode('deposit:approve')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not map attendance:export to generic read', () => {
|
||||
expect(mapPermissionCode('attendance:export')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not map archive to generic delete', () => {
|
||||
expect(mapPermissionCode('student:archive')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for unknown action on known resource (student:nuke)', () => {
|
||||
expect(mapPermissionCode('student:nuke')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isKnownPermissionCode', () => {
|
||||
it('recognizes known codes', () => {
|
||||
expect(isKnownPermissionCode('student:view')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unknown resources', () => {
|
||||
expect(isKnownPermissionCode('ghost:action')).toBe(false);
|
||||
});
|
||||
});
|
||||
61
apps/server/src/authorization/casl-ability.factory.ts
Normal file
61
apps/server/src/authorization/casl-ability.factory.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AbilityBuilder, createMongoAbility, detectSubjectType, MongoAbility } from '@casl/ability';
|
||||
import { CaslAction, mapPermissionCode, permissionCodeSubject } from './casl.constants';
|
||||
import { AppAbility, AppSubject, AuthPrincipal } from './interfaces';
|
||||
|
||||
/**
|
||||
* Builds a CASL {@link AppAbility} instance for a given user.
|
||||
*
|
||||
* This factory is deliberately free of any NestJS execution-context
|
||||
* dependency so it can be reused outside of HTTP (e.g. Agent Tool
|
||||
* execution, background jobs, etc.).
|
||||
*
|
||||
* ## Two-layer ability model
|
||||
*
|
||||
* | Layer | Condition | Grant |
|
||||
* |---|---|---|
|
||||
* | Exact code | Every code in `user.permissions` | `Access PermissionCode:<code>` |
|
||||
* | Domain | Strict CRUD-equivalent codes only | `(create|read|update|delete) Subject` |
|
||||
* | Super admin | `isSuperAdmin === true` | `manage('all')` |
|
||||
*
|
||||
* Unknown/custom codes get only exact-code ability — no domain ability
|
||||
* is inferred.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CaslAbilityFactory {
|
||||
/**
|
||||
* Build ability for a user loaded from the database (or JWT-refreshed).
|
||||
*/
|
||||
createForUser(user: AuthPrincipal): AppAbility {
|
||||
const { can, build } = new AbilityBuilder<MongoAbility<[CaslAction, AppSubject]>>(
|
||||
createMongoAbility,
|
||||
);
|
||||
|
||||
// 1. Super admin → manage everything
|
||||
if (user.isSuperAdmin) {
|
||||
can(CaslAction.Manage, 'all');
|
||||
return build({ detectSubjectType });
|
||||
}
|
||||
|
||||
// 2. For every permission code the user holds:
|
||||
// a) Always add exact-code ability (layer 1)
|
||||
// b) If code is known, also add domain-level ability (layer 2)
|
||||
for (const code of user.permissions ?? []) {
|
||||
// Layer 1 — exact code (always)
|
||||
can(CaslAction.Access, permissionCodeSubject(code));
|
||||
|
||||
// Layer 2 — domain-level (known codes only)
|
||||
const rule = mapPermissionCode(code);
|
||||
if (rule) can(rule.action, rule.subject);
|
||||
}
|
||||
|
||||
return build({ detectSubjectType });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an ability from raw permission codes — useful in tests.
|
||||
*/
|
||||
createFromPermissions(permissions: string[], isSuperAdmin = false): AppAbility {
|
||||
return this.createForUser({ permissions, isSuperAdmin });
|
||||
}
|
||||
}
|
||||
180
apps/server/src/authorization/casl.constants.ts
Normal file
180
apps/server/src/authorization/casl.constants.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* CASL authorization constants.
|
||||
*
|
||||
* Maps our existing `{resource}:{action}` permission codes into CASL
|
||||
* `Action` + `Subject` pairs.
|
||||
*
|
||||
* ## Two-layer permission model
|
||||
*
|
||||
* 1. **Exact code** — `Access PermissionCode:<code>` grants the specific
|
||||
* `resource:action` code. Every code a user holds (preset or custom)
|
||||
* gets an exact-code ability. PermissionGuard checks exact codes.
|
||||
*
|
||||
* 2. **Domain level** — only strictly equivalent CRUD codes
|
||||
* (`view|read|create|edit|update|delete`) create broad Subject abilities.
|
||||
* Workflow-specific operations remain exact-code-only.
|
||||
*
|
||||
* Unknown/custom codes (e.g. "student:nuke") get only layer 1, never
|
||||
* layer 2 — no domain ability is inferred.
|
||||
*/
|
||||
|
||||
/** CASL action strings. */
|
||||
export const CaslAction = {
|
||||
Manage: 'manage',
|
||||
Create: 'create',
|
||||
Read: 'read',
|
||||
Update: 'update',
|
||||
Delete: 'delete',
|
||||
/** Check exact permission code (e.g. "bill:export-excel").
|
||||
* Used by PermissionGuard so workflow-specific operations remain distinct. */
|
||||
Access: 'access',
|
||||
} as const;
|
||||
export type CaslAction = (typeof CaslAction)[keyof typeof CaslAction];
|
||||
|
||||
/** Subject names for every entity we protect. */
|
||||
export const SubjectName = {
|
||||
all: 'all',
|
||||
Student: 'Student',
|
||||
Room: 'Room',
|
||||
Occupancy: 'Occupancy',
|
||||
Expense: 'Expense',
|
||||
Bill: 'Bill',
|
||||
Deposit: 'Deposit',
|
||||
Classroom: 'Classroom',
|
||||
Organization: 'Organization',
|
||||
ClassRental: 'ClassRental',
|
||||
Class: 'Class',
|
||||
Schedule: 'Schedule',
|
||||
Attendance: 'Attendance',
|
||||
Dashboard: 'Dashboard',
|
||||
Profile: 'Profile',
|
||||
Notification: 'Notification',
|
||||
OperationLog: 'OperationLog',
|
||||
User: 'User',
|
||||
Role: 'Role',
|
||||
Learning: 'Learning',
|
||||
Exam: 'Exam',
|
||||
Sync: 'Sync',
|
||||
Integration: 'Integration',
|
||||
Department: 'Department',
|
||||
AiConfig: 'AiConfig',
|
||||
} as const;
|
||||
export type SubjectName = (typeof SubjectName)[keyof typeof SubjectName];
|
||||
|
||||
/** Build the exact-code CASL subject string for a permission code. */
|
||||
export function permissionCodeSubject(code: string): string {
|
||||
return `PermissionCode:${code}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain-level action mapping: permission code → CASL action
|
||||
// Used ONLY for the domain layer — not for exact-code access checks.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function permissionToAction(permission: string): CaslAction | null {
|
||||
const actionSegment = permission.split(':')[1] ?? permission;
|
||||
|
||||
// Only strictly equivalent CRUD/read permission codes create broad domain
|
||||
// abilities. Workflow-specific operations remain exact-code-only so that,
|
||||
// for example, export cannot satisfy read and approve cannot satisfy update.
|
||||
switch (actionSegment) {
|
||||
case 'create':
|
||||
return CaslAction.Create;
|
||||
case 'view':
|
||||
case 'read':
|
||||
return CaslAction.Read;
|
||||
case 'edit':
|
||||
case 'update':
|
||||
return CaslAction.Update;
|
||||
case 'delete':
|
||||
return CaslAction.Delete;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function permissionToSubject(resource: string): SubjectName | null {
|
||||
switch (resource) {
|
||||
case 'dashboard':
|
||||
return SubjectName.Dashboard;
|
||||
case 'profile':
|
||||
return SubjectName.Profile;
|
||||
case 'notification':
|
||||
return SubjectName.Notification;
|
||||
case 'student':
|
||||
return SubjectName.Student;
|
||||
case 'room':
|
||||
return SubjectName.Room;
|
||||
case 'occupancy':
|
||||
return SubjectName.Occupancy;
|
||||
case 'expense':
|
||||
return SubjectName.Expense;
|
||||
case 'bill':
|
||||
return SubjectName.Bill;
|
||||
case 'deposit':
|
||||
return SubjectName.Deposit;
|
||||
case 'classroom':
|
||||
return SubjectName.Classroom;
|
||||
case 'organization':
|
||||
return SubjectName.Organization;
|
||||
case 'rental':
|
||||
return SubjectName.ClassRental;
|
||||
case 'log':
|
||||
return SubjectName.OperationLog;
|
||||
case 'user':
|
||||
return SubjectName.User;
|
||||
case 'role':
|
||||
return SubjectName.Role;
|
||||
case 'class':
|
||||
return SubjectName.Class;
|
||||
case 'schedule':
|
||||
return SubjectName.Schedule;
|
||||
case 'attendance':
|
||||
return SubjectName.Attendance;
|
||||
case 'learning':
|
||||
return SubjectName.Learning;
|
||||
case 'exam':
|
||||
return SubjectName.Exam;
|
||||
case 'sync':
|
||||
return SubjectName.Sync;
|
||||
case 'integration':
|
||||
return SubjectName.Integration;
|
||||
case 'department':
|
||||
return SubjectName.Department;
|
||||
case 'ai':
|
||||
return SubjectName.AiConfig;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AbilityPermissionRule {
|
||||
action: CaslAction;
|
||||
subject: SubjectName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a known `resource:action` permission code to a domain-level
|
||||
* CASL rule, or `null` if the resource segment is unrecognised.
|
||||
*
|
||||
* Domain-level rules are used by services for data-scoping checks.
|
||||
* They are NOT used for exact-code access control — use
|
||||
* {@link permissionCodeSubject} for that.
|
||||
*/
|
||||
export function mapPermissionCode(code: string): AbilityPermissionRule | null {
|
||||
const [resource] = code.split(':');
|
||||
const subject = permissionToSubject(resource ?? '');
|
||||
if (!subject) return null;
|
||||
const action = permissionToAction(code);
|
||||
if (!action) return null;
|
||||
return { action, subject };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the permission code is "known" — i.e. the resource maps to a
|
||||
* recognised subject.
|
||||
*/
|
||||
export function isKnownPermissionCode(code: string): boolean {
|
||||
const [resource] = code.split(':');
|
||||
return permissionToSubject(resource ?? '') !== null;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { PolicyHandler } from '../interfaces';
|
||||
|
||||
export const CHECK_POLICIES_KEY = 'check_policies';
|
||||
|
||||
/**
|
||||
* Declare CASL-based policy requirements on a route handler or controller.
|
||||
*
|
||||
* Handlers are evaluated with AND semantics — every handler must pass
|
||||
* for the request to be allowed.
|
||||
*
|
||||
* ### Usage — callback handler
|
||||
* ```ts
|
||||
* @CheckPolicies((ability) => ability.can('read', 'Student'))
|
||||
* ```
|
||||
*
|
||||
* ### Usage — class-based handler (prefer this for testability)
|
||||
* ```ts
|
||||
* import { IPolicyHandler } from '../interfaces';
|
||||
*
|
||||
* class ReadStudentPolicyHandler implements IPolicyHandler {
|
||||
* handle(ability: AppAbility) {
|
||||
* return ability.can('read', 'Student');
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* @CheckPolicies(new ReadStudentPolicyHandler())
|
||||
* ```
|
||||
*/
|
||||
export const CheckPolicies = (...handlers: PolicyHandler[]) =>
|
||||
SetMetadata(CHECK_POLICIES_KEY, handlers);
|
||||
201
apps/server/src/authorization/guards/policies.guard.spec.ts
Normal file
201
apps/server/src/authorization/guards/policies.guard.spec.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { PoliciesGuard } from './policies.guard';
|
||||
import { CaslAbilityFactory } from '../casl-ability.factory';
|
||||
import { AppAbility, IPolicyHandler } from '../interfaces';
|
||||
|
||||
describe('PoliciesGuard', () => {
|
||||
const factory = new CaslAbilityFactory();
|
||||
|
||||
/** Build a mock NestJS ExecutionContext for PoliciesGuard */
|
||||
function createContext(
|
||||
user: unknown,
|
||||
opts: {
|
||||
policyHandlers?: Array<((ability: AppAbility) => boolean) | IPolicyHandler> | null;
|
||||
controllerPolicyHandlers?: Array<((ability: AppAbility) => boolean) | IPolicyHandler>;
|
||||
isPublic?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const meta = new Map<string, unknown>();
|
||||
if (opts.policyHandlers !== undefined) meta.set('check_policies', opts.policyHandlers);
|
||||
if (opts.isPublic !== undefined) meta.set('isPublic', opts.isPublic);
|
||||
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn((key: string) => meta.get(key) ?? undefined),
|
||||
getAllAndMerge: jest.fn((key: string) => {
|
||||
if (key !== 'check_policies') return [];
|
||||
return [...(opts.policyHandlers ?? []), ...(opts.controllerPolicyHandlers ?? [])];
|
||||
}),
|
||||
};
|
||||
const guard = new PoliciesGuard(reflector as never, factory);
|
||||
return guard.canActivate({
|
||||
getHandler: () => function handler() {},
|
||||
getClass: () => class Controller {},
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({ user }),
|
||||
}),
|
||||
} as never);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// No @CheckPolicies → pass-through
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('passes through when no @CheckPolicies is declared', () => {
|
||||
expect(createContext(undefined)).toBe(true);
|
||||
expect(createContext(null)).toBe(true);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// @Public interaction
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('skips when @Public is declared, even with @CheckPolicies', () => {
|
||||
expect(
|
||||
createContext(undefined, {
|
||||
policyHandlers: [(ability) => ability.can('read', 'Student')],
|
||||
isPublic: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// User absent
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('denies when @CheckPolicies is declared but no user present', () => {
|
||||
expect(
|
||||
createContext(undefined, {
|
||||
policyHandlers: [(ability) => ability.can('read', 'Student')],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Callback handlers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('grants when all policies pass for super admin', () => {
|
||||
expect(
|
||||
createContext(
|
||||
{ permissions: [], isSuperAdmin: true },
|
||||
{
|
||||
policyHandlers: [
|
||||
(ability) => ability.can('read', 'Student'),
|
||||
(ability) => ability.can('delete', 'Class'),
|
||||
],
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('grants when all policies pass for user with correct permissions', () => {
|
||||
expect(
|
||||
createContext(
|
||||
{ permissions: ['student:view', 'class:view'], isSuperAdmin: false },
|
||||
{
|
||||
policyHandlers: [
|
||||
(ability) => ability.can('read', 'Student'),
|
||||
(ability) => ability.can('read', 'Class'),
|
||||
],
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('denies when any policy fails (AND semantics)', () => {
|
||||
expect(
|
||||
createContext(
|
||||
{ permissions: ['student:view'], isSuperAdmin: false },
|
||||
{
|
||||
policyHandlers: [
|
||||
(ability) => ability.can('read', 'Student'), // passes
|
||||
(ability) => ability.can('delete', 'Student'), // fails
|
||||
],
|
||||
},
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('empty handlers array passes (no policies to check)', () => {
|
||||
expect(
|
||||
createContext({ permissions: ['student:view'], isSuperAdmin: false }, { policyHandlers: [] }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('merges controller and handler policies with AND semantics', () => {
|
||||
expect(
|
||||
createContext(
|
||||
{ permissions: ['student:view'], isSuperAdmin: false },
|
||||
{
|
||||
policyHandlers: [(ability) => ability.can('read', 'Student')],
|
||||
controllerPolicyHandlers: [(ability) => ability.can('read', 'Class')],
|
||||
},
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Class-based handlers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('supports class-based policy handlers', () => {
|
||||
class ReadStudentPolicy implements IPolicyHandler {
|
||||
handle(ability: AppAbility): boolean {
|
||||
return ability.can('read', 'Student');
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
createContext(
|
||||
{ permissions: ['student:view'], isSuperAdmin: false },
|
||||
{ policyHandlers: [new ReadStudentPolicy()] },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('denies when class-based handler fails', () => {
|
||||
class DeleteStudentPolicy implements IPolicyHandler {
|
||||
handle(ability: AppAbility): boolean {
|
||||
return ability.can('delete', 'Student');
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
createContext(
|
||||
{ permissions: ['student:view'], isSuperAdmin: false },
|
||||
{ policyHandlers: [new DeleteStudentPolicy()] },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('mixes callback and class-based handlers', () => {
|
||||
class ReadStudentPolicy implements IPolicyHandler {
|
||||
handle(ability: AppAbility): boolean {
|
||||
return ability.can('read', 'Student');
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
createContext(
|
||||
{ permissions: ['student:view', 'class:view'], isSuperAdmin: false },
|
||||
{
|
||||
policyHandlers: [new ReadStudentPolicy(), (ability) => ability.can('read', 'Class')],
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Instance-level policy
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('custom instance-level policy: checks specific resource conditions', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['student:edit'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
const ownsResource = (ab: typeof ability) => ab.can('update', 'Student');
|
||||
|
||||
expect(ownsResource(ability)).toBe(true);
|
||||
});
|
||||
});
|
||||
79
apps/server/src/authorization/guards/policies.guard.ts
Normal file
79
apps/server/src/authorization/guards/policies.guard.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { CHECK_POLICIES_KEY } from '../decorators/check-policies.decorator';
|
||||
import { IS_PUBLIC_KEY } from '../../auth/decorators/public.decorator';
|
||||
import { CaslAbilityFactory } from '../casl-ability.factory';
|
||||
import { AppAbility, AuthorizationRequest, PolicyHandler } from '../interfaces';
|
||||
|
||||
/**
|
||||
* Evaluates CASL policies declared via @CheckPolicies().
|
||||
*
|
||||
* Registered as a global APP_GUARD — runs after JwtAuthGuard and
|
||||
* PermissionGuard in the guard chain. Only activates when a route
|
||||
* carries @CheckPolicies metadata.
|
||||
*
|
||||
* ## Interaction with other guards
|
||||
*
|
||||
* - @Public() → PoliciesGuard skips (same as other guards).
|
||||
* - @CheckPolicies alone (no @RequirePermission) → PermissionGuard
|
||||
* passes through if user is authenticated; PoliciesGuard evaluates.
|
||||
* - @CheckPolicies + @RequirePermission → both guards run independently;
|
||||
* both must pass.
|
||||
*
|
||||
* ## Handler types
|
||||
*
|
||||
* Two forms are supported:
|
||||
*
|
||||
* ```ts
|
||||
* // Callback form
|
||||
* @CheckPolicies((ability) => ability.can('read', 'Student'))
|
||||
*
|
||||
* // Class-based form (testable, NestJS official pattern)
|
||||
* class ReadStudentPolicyHandler implements IPolicyHandler {
|
||||
* handle(ability: AppAbility) { return ability.can('read', 'Student'); }
|
||||
* }
|
||||
* @CheckPolicies(new ReadStudentPolicyHandler())
|
||||
* ```
|
||||
*/
|
||||
@Injectable()
|
||||
export class PoliciesGuard implements CanActivate {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private abilityFactory: CaslAbilityFactory,
|
||||
) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
// @Public() routes skip all authorization
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
|
||||
const handlers = this.reflector.getAllAndMerge<PolicyHandler[]>(CHECK_POLICIES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
// No @CheckPolicies → let other guards decide
|
||||
if (!handlers || handlers.length === 0) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest<AuthorizationRequest>();
|
||||
const user = request.user;
|
||||
if (!user) return false;
|
||||
|
||||
const ability = this.abilityFactory.createForUser(user);
|
||||
|
||||
return handlers.every((handler) => this.execHandler(handler, ability));
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a policy handler — supports both callback and class-based forms.
|
||||
*/
|
||||
private execHandler(handler: PolicyHandler, ability: AppAbility): boolean {
|
||||
if (typeof handler === 'function') {
|
||||
return handler(ability);
|
||||
}
|
||||
return handler.handle(ability);
|
||||
}
|
||||
}
|
||||
14
apps/server/src/authorization/index.ts
Normal file
14
apps/server/src/authorization/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export { AuthorizationModule } from './authorization.module';
|
||||
export { CaslAbilityFactory } from './casl-ability.factory';
|
||||
export { AuthorizationService } from './authorization.service';
|
||||
export { PoliciesGuard } from './guards/policies.guard';
|
||||
export { CheckPolicies } from './decorators/check-policies.decorator';
|
||||
export { CaslAction, SubjectName, mapPermissionCode } from './casl.constants';
|
||||
export type {
|
||||
AppAbility,
|
||||
AppSubject,
|
||||
AuthenticatedUser,
|
||||
AuthPrincipal,
|
||||
PolicyHandler,
|
||||
IPolicyHandler,
|
||||
} from './interfaces';
|
||||
67
apps/server/src/authorization/interfaces.ts
Normal file
67
apps/server/src/authorization/interfaces.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { MongoAbility } from '@casl/ability';
|
||||
import { CaslAction } from './casl.constants';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subject type union — all entity classes we protect with CASL.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CASL expects the subject to be either the class constructor or a string.
|
||||
// We use string subjects (SubjectName) for simplicity when no instance is
|
||||
// available, and concrete instance types for per-resource checks.
|
||||
export type AppSubject = string | Record<string, unknown>;
|
||||
|
||||
export type AppAbility = MongoAbility<[CaslAction, AppSubject]>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authenticated user — what the JWT strategy places on `request.user`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AuthenticatedUser {
|
||||
id: number;
|
||||
username: string;
|
||||
/** Flat list of `resource:action` permission codes. */
|
||||
permissions: string[];
|
||||
/** Whether the user has a super-admin role. */
|
||||
isSuperAdmin: boolean;
|
||||
/** Role names (display/debug only — NEVER used for authorization). */
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum authorization principal — the subset of AuthenticatedUser
|
||||
* needed by CaslAbilityFactory and AuthorizationService.
|
||||
*/
|
||||
export type AuthPrincipal = { readonly permissions: readonly string[]; readonly isSuperAdmin: boolean };
|
||||
|
||||
/** Request-like carrier populated only by the trusted authentication layer. */
|
||||
export interface AuthorizationRequest {
|
||||
user?: AuthPrincipal;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Policy handler types for @CheckPolicies()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Interface for class-based policy handlers.
|
||||
*
|
||||
* Implement this interface in a class to create a testable,
|
||||
* NestJS-official CASL policy handler:
|
||||
*
|
||||
* ```ts
|
||||
* class ReadStudentPolicyHandler implements IPolicyHandler {
|
||||
* handle(ability: AppAbility) {
|
||||
* return ability.can('read', 'Student');
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface IPolicyHandler {
|
||||
handle(ability: AppAbility): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A policy handler — either a callback or a class implementing
|
||||
* {@link IPolicyHandler}.
|
||||
*/
|
||||
export type PolicyHandler = ((ability: AppAbility) => boolean) | IPolicyHandler;
|
||||
Reference in New Issue
Block a user