91 lines
3.8 KiB
TypeScript
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 };
|
|
}
|