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),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user