feat: add CASL authorization and AI configuration

This commit is contained in:
2026-07-11 14:25:34 +08:00
parent 8f0991a51f
commit 1e1c476bc3
59 changed files with 7733 additions and 120 deletions

View 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 {}

View 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();
});
});
});

View 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);
}
}

View 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);
});
});

View 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 });
}
}

View 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;
}

View File

@@ -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);

View 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);
});
});

View 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);
}
}

View 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';

View 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;