feat: add CASL authorization and AI configuration
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user