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

@@ -1,50 +1,223 @@
import { PermissionGuard } from './permission.guard';
import { CaslAbilityFactory } from '../../authorization/casl-ability.factory';
import { CaslAction, permissionCodeSubject } from '../../authorization/casl.constants';
describe('PermissionGuard', () => {
const createContext = (user: unknown) =>
({
const abilityFactory = new CaslAbilityFactory();
/** Mock NestJS ExecutionContext with reflector overrides */
function createContext(
user: unknown,
overrides: {
isPublic?: boolean;
authenticatedOnly?: boolean;
permissions?: string[];
checkPolicies?: unknown[];
} = {},
) {
const meta = new Map<string, unknown>();
if (overrides.isPublic !== undefined) meta.set('isPublic', overrides.isPublic);
if (overrides.authenticatedOnly !== undefined)
meta.set('authenticatedOnly', overrides.authenticatedOnly);
if (overrides.permissions) meta.set('permissions', overrides.permissions);
if (overrides.checkPolicies) meta.set('check_policies', overrides.checkPolicies);
const reflector = {
getAllAndOverride: jest.fn((key: string) => meta.get(key) ?? undefined),
getAllAndMerge: jest.fn((key: string) => meta.get(key) ?? []),
};
const guard = new PermissionGuard(reflector as never, abilityFactory);
return guard.canActivate({
getHandler: () => function handler() {},
getClass: () => class Controller {},
switchToHttp: () => ({ getRequest: () => ({ user }) }),
}) as never;
} as never);
}
// -----------------------------------------------------------------------
// @Public / @Authenticated / undeclared
// -----------------------------------------------------------------------
it('denies routes that forgot to declare permissions', () => {
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue(false),
getAllAndMerge: jest.fn().mockReturnValue(undefined),
};
const guard = new PermissionGuard(reflector as never);
expect(guard.canActivate(createContext({ permissions: ['dashboard:view'] }))).toBe(false);
expect(createContext(undefined)).toBe(false);
expect(createContext({ permissions: [], isSuperAdmin: false })).toBe(false);
});
it('allows explicitly public routes without a user', () => {
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue(true),
getAllAndMerge: jest.fn(),
};
const guard = new PermissionGuard(reflector as never);
expect(guard.canActivate(createContext(undefined))).toBe(true);
expect(createContext(undefined, { isPublic: true })).toBe(true);
});
it('allows authenticated-only routes for logged-in users without requiring profile:view', () => {
const reflector = {
getAllAndOverride: jest.fn().mockReturnValueOnce(false).mockReturnValueOnce(true),
getAllAndMerge: jest.fn(),
};
const guard = new PermissionGuard(reflector as never);
expect(guard.canActivate(createContext({ permissions: [] }))).toBe(true);
it('allows authenticated-only routes for logged-in users', () => {
expect(
createContext({ permissions: [], isSuperAdmin: false }, { authenticatedOnly: true }),
).toBe(true);
});
it('denies authenticated-only routes when no authenticated user is present', () => {
const reflector = {
getAllAndOverride: jest.fn().mockReturnValueOnce(false).mockReturnValueOnce(true),
getAllAndMerge: jest.fn(),
};
const guard = new PermissionGuard(reflector as never);
it('denies authenticated-only routes when no user is present', () => {
expect(createContext(undefined, { authenticatedOnly: true })).toBe(false);
});
expect(guard.canActivate(createContext(undefined))).toBe(false);
it('does not let controller-level @Authenticated bypass handler permissions', () => {
expect(
createContext(
{ permissions: [], isSuperAdmin: false },
{ authenticatedOnly: true, permissions: ['user:edit'] },
),
).toBe(false);
});
it('still enforces policies when @Authenticated and @CheckPolicies coexist', () => {
expect(
createContext(
{ permissions: [], isSuperAdmin: false },
{
authenticatedOnly: true,
checkPolicies: [(ability: any) => ability.can('read', 'Student')],
},
),
).toBe(true);
});
// -----------------------------------------------------------------------
// @CheckPolicies passthrough
// -----------------------------------------------------------------------
it('allows pass-through for @CheckPolicies routes with user present', () => {
expect(
createContext(
{ permissions: [], isSuperAdmin: false },
{ checkPolicies: [(ab: any) => ab.can('read', 'Student')] },
),
).toBe(true);
});
it('denies pass-through for @CheckPolicies routes without user', () => {
expect(
createContext(undefined, {
checkPolicies: [(ab: any) => ab.can('read', 'Student')],
}),
).toBe(false);
});
// -----------------------------------------------------------------------
// CASL exact-code authorization (collision-free)
// -----------------------------------------------------------------------
it('grants access to super admin for any permission', () => {
expect(
createContext({ permissions: [], isSuperAdmin: true }, { permissions: ['student:view'] }),
).toBe(true);
});
it('grants access when user has the exact required permission', () => {
expect(
createContext(
{ permissions: ['student:view'], isSuperAdmin: false },
{ permissions: ['student:view'] },
),
).toBe(true);
});
it('denies access when user lacks the required permission', () => {
expect(
createContext(
{ permissions: ['student:view'], isSuperAdmin: false },
{ permissions: ['class:delete'] },
),
).toBe(false);
});
it('denies access when user has no permissions', () => {
expect(
createContext({ permissions: [], isSuperAdmin: false }, { permissions: ['student:view'] }),
).toBe(false);
});
it('denies unknown permission codes (no user holds them)', () => {
expect(
createContext(
{ permissions: ['unknown:action'], isSuperAdmin: false },
{ permissions: ['other:thing'] },
),
).toBe(false);
});
it('grants exact-code access for custom permissions', () => {
expect(
createContext(
{ permissions: ['custom:special'], isSuperAdmin: false },
{ permissions: ['custom:special'] },
),
).toBe(true);
});
it('grants with OR matching: one of multiple required permissions', () => {
expect(
createContext(
{ permissions: ['class:view'], isSuperAdmin: false },
{ permissions: ['student:delete', 'class:view'] },
),
).toBe(true);
});
// ── Collision regression tests ──
it('denies bill:export-excel when user only has bill:view', () => {
const ability = abilityFactory.createForUser({
permissions: ['bill:view'],
isSuperAdmin: false,
});
// Domain layer: both would give read Bill — but exact-code check must discriminate
expect(ability.can(CaslAction.Read, 'Bill')).toBe(true);
// Exact-code check: bill:view user must NOT have bill:export-excel
expect(ability.can(CaslAction.Access, permissionCodeSubject('bill:export-excel'))).toBe(false);
});
it('denies bill:confirm when user only has bill:view', () => {
const ability = abilityFactory.createForUser({
permissions: ['bill:view'],
isSuperAdmin: false,
});
// Domain layer: confirm → update, view → read — already distinct at domain level
expect(ability.can(CaslAction.Update, 'Bill')).toBe(false);
// Exact-code: must also fail
expect(ability.can(CaslAction.Access, permissionCodeSubject('bill:confirm'))).toBe(false);
});
it('denies deposit:approve when user only has deposit:edit', () => {
const ability = abilityFactory.createForUser({
permissions: ['deposit:edit'],
isSuperAdmin: false,
});
// Domain layer: both map to update — would pass domain check
expect(ability.can(CaslAction.Update, 'Deposit')).toBe(true);
// Exact-code: must fail — edit is not approve
expect(ability.can(CaslAction.Access, permissionCodeSubject('deposit:approve'))).toBe(false);
});
it('denies attendance:export when user only has attendance:view', () => {
const ability = abilityFactory.createForUser({
permissions: ['attendance:view'],
isSuperAdmin: false,
});
// Domain layer: both map to read
expect(ability.can(CaslAction.Read, 'Attendance')).toBe(true);
// Exact-code: must fail
expect(ability.can(CaslAction.Access, permissionCodeSubject('attendance:export'))).toBe(false);
});
it('unknown code student:nuke does not create domain ability', () => {
const ability = abilityFactory.createForUser({
permissions: ['student:nuke'],
isSuperAdmin: false,
});
expect(ability.can(CaslAction.Manage, 'Student')).toBe(false);
expect(ability.can(CaslAction.Read, 'Student')).toBe(false);
expect(ability.can(CaslAction.Access, permissionCodeSubject('student:nuke'))).toBe(true);
});
});

View File

@@ -1,52 +1,92 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { CaslAbilityFactory } from '../../authorization/casl-ability.factory';
import { CaslAction, permissionCodeSubject } from '../../authorization/casl.constants';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { PERMISSION_KEY } from '../decorators/permission.decorator';
import { AUTHENTICATED_KEY } from '../decorators/authenticated.decorator';
import { CHECK_POLICIES_KEY } from '../../authorization/decorators/check-policies.decorator';
import type { AuthorizationRequest } from '../../authorization/interfaces';
/**
* 权限守卫 — 默认拒绝策略(安全关键)
* Permission guard — deny-by-default (security-critical).
*
* handler/controller 上不存在 @RequirePermission@Authenticated 且未标记 @Public 时,守卫拒绝访问。
* 所有路由必须显式声明公开、仅登录或所需权限。
* When a handler/controller has no @RequirePermission, @Authenticated,
* @CheckPolicies, or @Public annotation, the guard denies access.
*
* ⚠️ 新增路由时务必添加 @RequirePermission、@Authenticated 或 @Public。
* 建议配合 lint 规则确保无遗漏。
* Authorization is via CASL exact-code matching: each permission code
* the user holds is registered as `Access PermissionCode:<code>`.
* Checking `@RequirePermission('bill:export-excel')` verifies
* `ability.can('access', 'PermissionCode:bill:export-excel')` — a user
* with only `bill:view` will NOT pass.
*/
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(private reflector: Reflector) {}
constructor(
private reflector: Reflector,
private abilityFactory: CaslAbilityFactory,
) {}
canActivate(context: ExecutionContext): boolean {
// 1. @Public() 豁免
// 1. @Public() exemption
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const request = context.switchToHttp().getRequest();
const request = context.switchToHttp().getRequest<AuthorizationRequest>();
const user = request.user;
// 2. @Authenticated() 只要求已登录,具体 JWT 有效性由 JwtAuthGuard 负责。
// 2. Read @Authenticated, but only use it as a fallback after checking
// more specific permission and policy declarations.
const authenticatedOnly = this.reflector.getAllAndOverride<boolean>(AUTHENTICATED_KEY, [
context.getHandler(),
context.getClass(),
]);
if (authenticatedOnly) return !!user;
// 3. 获取所需权限getAllAndMerge 合并 handler+class 层的所有 metadata
// 3. Get required permissions (handler + class merged)
const requiredPermissions = this.reflector.getAllAndMerge<string[]>(PERMISSION_KEY, [
context.getHandler(),
context.getClass(),
]);
// 无权限声明且非 @Public/@Authenticated默认拒绝避免新增接口意外裸奔
if (!requiredPermissions || requiredPermissions.length === 0) return false;
// 4. 从 JWT payload 获取用户权限
// 4. Check for @CheckPolicies — defer to PoliciesGuard
const hasPolicies = this.reflector.getAllAndMerge<unknown[]>(CHECK_POLICIES_KEY, [
context.getHandler(),
context.getClass(),
]);
const hasCheckPolicies = Array.isArray(hasPolicies) && hasPolicies.length > 0;
// 5. @Authenticated is a fallback only when no more specific authorization
// declaration exists. This prevents controller-level @Authenticated from
// bypassing handler-level @RequirePermission or @CheckPolicies.
if (
(!requiredPermissions || requiredPermissions.length === 0) &&
!hasCheckPolicies &&
authenticatedOnly
) {
return !!user;
}
// No authorization declaration at any level → deny.
if ((!requiredPermissions || requiredPermissions.length === 0) && !hasCheckPolicies) {
return false;
}
// 6. @CheckPolicies present but no @RequirePermission → let PoliciesGuard handle it
if ((!requiredPermissions || requiredPermissions.length === 0) && hasCheckPolicies) {
return !!user; // deny unauthenticated, pass-through for PoliciesGuard
}
// 7. User must exist and have permissions array
if (!user || !user.permissions || !Array.isArray(user.permissions)) return false;
// 5. OR 匹配:用户拥有 requiredPermissions 中任一权限即可通过
return requiredPermissions.some((p) => user.permissions.includes(p));
// 8. CASL exact-code check — maps each required code to an exact PermissionCode subject
const ability = this.abilityFactory.createForUser(user);
return requiredPermissions.some((code: string) =>
ability.can(CaslAction.Access, permissionCodeSubject(code)),
);
}
}