feat(audit): 收口领域写接口权限审计
This commit is contained in:
14
apps/server/src/common/audit/audit-mutation.decorator.ts
Normal file
14
apps/server/src/common/audit/audit-mutation.decorator.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import type { PermissionScopeOptions } from '../auth/permission.decorator';
|
||||
|
||||
export const AUDIT_MUTATION_METADATA_KEY = 'ftb:audit-mutation';
|
||||
|
||||
export interface AuditMutationMetadata extends PermissionScopeOptions {
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityIdParam?: string;
|
||||
}
|
||||
|
||||
export function AuditMutation(metadata: AuditMutationMetadata) {
|
||||
return SetMetadata(AUDIT_MUTATION_METADATA_KEY, metadata);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { lastValueFrom, of } from 'rxjs';
|
||||
import { AuditMutationInterceptor } from './audit-mutation.interceptor';
|
||||
import { AuditMutation } from './audit-mutation.decorator';
|
||||
|
||||
describe('AuditMutationInterceptor', () => {
|
||||
it('writes an audit event after a successful mutation response', async () => {
|
||||
const record = jest.fn().mockResolvedValue({ id: 'audit-1' });
|
||||
const resolveCurrentUser = jest.fn().mockResolvedValue({ id: 'm-8', name: '超级管理员', roleId: 'role-admin' });
|
||||
const interceptor = new AuditMutationInterceptor(
|
||||
new (jest.requireActual('@nestjs/core').Reflector)(),
|
||||
{ record } as any,
|
||||
{ resolveCurrentUser } as any,
|
||||
);
|
||||
const handler = decorate(() => undefined);
|
||||
|
||||
const result = await lastValueFrom(interceptor.intercept(contextFor(handler), {
|
||||
handle: () => of({ item: { id: 'task-1', productId: 'product-1', projectId: 'project-1', versionId: 'version-1' } }),
|
||||
} as any));
|
||||
|
||||
expect(result).toEqual({ item: { id: 'task-1', productId: 'product-1', projectId: 'project-1', versionId: 'version-1' } });
|
||||
expect(record).toHaveBeenCalledWith({
|
||||
actor: { id: 'm-8', name: '超级管理员', roleId: 'role-admin' },
|
||||
action: 'dev_task.update',
|
||||
entityType: 'dev_task',
|
||||
entityId: 'task-1',
|
||||
productId: 'product-1',
|
||||
projectId: 'project-1',
|
||||
versionId: 'version-1',
|
||||
scope: { productId: 'product-1', projectId: 'project-1', versionId: 'version-1' },
|
||||
after: { item: { id: 'task-1', productId: 'product-1', projectId: 'project-1', versionId: 'version-1' } },
|
||||
metadata: { route: 'PATCH /versions/version-1/dev-tasks/task-1' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function decorate(handler: Function) {
|
||||
AuditMutation({
|
||||
action: 'dev_task.update',
|
||||
entityType: 'dev_task',
|
||||
entityIdParam: 'id',
|
||||
versionIdParam: 'versionId',
|
||||
})(handler as any, undefined as any, undefined as any);
|
||||
return handler;
|
||||
}
|
||||
|
||||
function contextFor(handler: Function) {
|
||||
const request = {
|
||||
method: 'PATCH',
|
||||
originalUrl: '/versions/version-1/dev-tasks/task-1',
|
||||
params: { id: 'task-1', versionId: 'version-1' },
|
||||
body: {},
|
||||
headers: {},
|
||||
};
|
||||
return {
|
||||
getHandler: () => handler,
|
||||
getClass: () => class TestController {},
|
||||
switchToHttp: () => ({ getRequest: () => request }),
|
||||
} as any;
|
||||
}
|
||||
90
apps/server/src/common/audit/audit-mutation.interceptor.ts
Normal file
90
apps/server/src/common/audit/audit-mutation.interceptor.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { mergeMap, Observable } from 'rxjs';
|
||||
import { AuthContextService, type AuthenticatedRequest } from '../auth/auth-context.service';
|
||||
import { AuditService } from '../../modules/audit/audit.service';
|
||||
import { AUDIT_MUTATION_METADATA_KEY, type AuditMutationMetadata } from './audit-mutation.decorator';
|
||||
|
||||
type MutationRequest = AuthenticatedRequest & {
|
||||
method?: string;
|
||||
originalUrl?: string;
|
||||
url?: string;
|
||||
params?: Record<string, string>;
|
||||
body?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AuditMutationInterceptor implements NestInterceptor {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly audit: AuditService,
|
||||
private readonly authContext: AuthContextService,
|
||||
) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const metadata = this.reflector.getAllAndOverride<AuditMutationMetadata>(AUDIT_MUTATION_METADATA_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!metadata) return next.handle();
|
||||
|
||||
const request = context.switchToHttp().getRequest<MutationRequest>();
|
||||
return next.handle().pipe(mergeMap(async (result) => {
|
||||
const actor = await this.authContext.resolveCurrentUser(request);
|
||||
const entity = extractEntity(result);
|
||||
const scope = resolveScope(metadata, request, entity);
|
||||
await this.audit.record({
|
||||
actor,
|
||||
action: metadata.action,
|
||||
entityType: metadata.entityType,
|
||||
entityId: resolveEntityId(metadata, request, entity),
|
||||
productId: scope.productId,
|
||||
projectId: scope.projectId,
|
||||
versionId: scope.versionId,
|
||||
scope,
|
||||
after: result,
|
||||
metadata: { route: `${request.method ?? 'UNKNOWN'} ${request.originalUrl ?? request.url ?? ''}`.trim() },
|
||||
});
|
||||
return result;
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function extractEntity(result: unknown): Record<string, unknown> | undefined {
|
||||
if (!result || typeof result !== 'object') return undefined;
|
||||
const record = result as Record<string, unknown>;
|
||||
if (record.item && typeof record.item === 'object') return record.item as Record<string, unknown>;
|
||||
if (Array.isArray(record.items) && record.items[0] && typeof record.items[0] === 'object') return record.items[0] as Record<string, unknown>;
|
||||
return record;
|
||||
}
|
||||
|
||||
function resolveEntityId(metadata: AuditMutationMetadata, request: MutationRequest, entity: Record<string, unknown> | undefined) {
|
||||
const fromParam = metadata.entityIdParam ? request.params?.[metadata.entityIdParam] : undefined;
|
||||
const fromEntity = entity?.id;
|
||||
return fromParam ?? (typeof fromEntity === 'string' ? fromEntity : 'unknown');
|
||||
}
|
||||
|
||||
function resolveScope(metadata: AuditMutationMetadata, request: MutationRequest, entity: Record<string, unknown> | undefined) {
|
||||
return compact({
|
||||
productId: scopedValue(metadata.productIdParam, metadata.productIdBody, 'productId', request, entity),
|
||||
projectId: scopedValue(metadata.projectIdParam, metadata.projectIdBody, 'projectId', request, entity),
|
||||
versionId: scopedValue(metadata.versionIdParam, metadata.versionIdBody, 'versionId', request, entity),
|
||||
});
|
||||
}
|
||||
|
||||
function scopedValue(
|
||||
paramKey: string | undefined,
|
||||
bodyKey: string | undefined,
|
||||
resultKey: string,
|
||||
request: MutationRequest,
|
||||
entity: Record<string, unknown> | undefined,
|
||||
): string | undefined {
|
||||
const value = (paramKey ? request.params?.[paramKey] : undefined)
|
||||
?? (bodyKey ? request.body?.[bodyKey] : undefined)
|
||||
?? entity?.[resultKey];
|
||||
return typeof value === 'string' && value ? value : undefined;
|
||||
}
|
||||
|
||||
function compact<T extends Record<string, string | undefined>>(value: T): { [K in keyof T]?: string } {
|
||||
return Object.fromEntries(Object.entries(value).filter(([, item]) => item)) as { [K in keyof T]?: string };
|
||||
}
|
||||
18
apps/server/src/common/audit/protected-mutation.decorator.ts
Normal file
18
apps/server/src/common/audit/protected-mutation.decorator.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { applyDecorators, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { PermissionGuard } from '../auth/permission.guard';
|
||||
import { RequirePermission, type PermissionScopeOptions } from '../auth/permission.decorator';
|
||||
import { AuditMutation, type AuditMutationMetadata } from './audit-mutation.decorator';
|
||||
import { AuditMutationInterceptor } from './audit-mutation.interceptor';
|
||||
|
||||
export function ProtectedMutation(
|
||||
permission: string,
|
||||
scope: PermissionScopeOptions,
|
||||
audit: AuditMutationMetadata,
|
||||
) {
|
||||
return applyDecorators(
|
||||
UseGuards(PermissionGuard),
|
||||
RequirePermission(permission, scope),
|
||||
UseInterceptors(AuditMutationInterceptor),
|
||||
AuditMutation(audit),
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export class AuthContextService {
|
||||
|
||||
const headerUser: CurrentUser = {
|
||||
id: userId,
|
||||
name: headerValue(request, 'x-ftb-user-name'),
|
||||
name: decodeHeader(headerValue(request, 'x-ftb-user-name')),
|
||||
username: headerValue(request, 'x-ftb-user-username'),
|
||||
roleId: headerValue(request, 'x-ftb-user-role-id') || '',
|
||||
email: headerValue(request, 'x-ftb-user-email'),
|
||||
@@ -60,3 +60,11 @@ function headerValue(request: AuthenticatedRequest, name: string): string {
|
||||
if (Array.isArray(value)) return value[0] ?? '';
|
||||
return value ?? '';
|
||||
}
|
||||
|
||||
function decodeHeader(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ export interface PermissionScopeOptions {
|
||||
productIdParam?: string;
|
||||
projectIdParam?: string;
|
||||
versionIdParam?: string;
|
||||
productIdBody?: string;
|
||||
projectIdBody?: string;
|
||||
versionIdBody?: string;
|
||||
}
|
||||
|
||||
export interface RequiredPermissionMetadata extends PermissionScopeOptions {
|
||||
|
||||
@@ -19,16 +19,28 @@ export class PermissionGuard implements CanActivate {
|
||||
]);
|
||||
if (!metadata) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest & { params?: Record<string, string> }>();
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest & {
|
||||
params?: Record<string, string>;
|
||||
body?: Record<string, unknown>;
|
||||
}>();
|
||||
const user = await this.authContext.resolveCurrentUser(request);
|
||||
if (!user) throw new UnauthorizedException('Authentication required');
|
||||
|
||||
const allowed = await this.permissions.can(user, metadata.permission, {
|
||||
productId: metadata.productIdParam ? request.params?.[metadata.productIdParam] : undefined,
|
||||
projectId: metadata.projectIdParam ? request.params?.[metadata.projectIdParam] : undefined,
|
||||
versionId: metadata.versionIdParam ? request.params?.[metadata.versionIdParam] : undefined,
|
||||
productId: scopedValue(request, metadata.productIdParam, metadata.productIdBody),
|
||||
projectId: scopedValue(request, metadata.projectIdParam, metadata.projectIdBody),
|
||||
versionId: scopedValue(request, metadata.versionIdParam, metadata.versionIdBody),
|
||||
});
|
||||
if (!allowed) throw new ForbiddenException(`Missing permission: ${metadata.permission}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function scopedValue(
|
||||
request: { params?: Record<string, string>; body?: Record<string, unknown> },
|
||||
paramKey?: string,
|
||||
bodyKey?: string,
|
||||
): string | undefined {
|
||||
const value = (paramKey ? request.params?.[paramKey] : undefined) ?? (bodyKey ? request.body?.[bodyKey] : undefined);
|
||||
return typeof value === 'string' && value ? value : undefined;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user