Files
ftb-project-management/apps/server/src/common/audit/audit-mutation.interceptor.ts

91 lines
3.8 KiB
TypeScript

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