Some checks failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
217 lines
6.4 KiB
TypeScript
217 lines
6.4 KiB
TypeScript
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from "@nestjs/common";
|
|
import { Observable } from "rxjs";
|
|
import { tap } from "rxjs/operators";
|
|
import { ActivityService } from "../activity.service";
|
|
import { ActivityAction, EntityType } from "@prisma/client";
|
|
import type { Prisma } from "@prisma/client";
|
|
import type { CreateActivityLogInput } from "../interfaces/activity.interface";
|
|
import type { AuthenticatedRequest } from "../../common/types/user.types";
|
|
|
|
/**
|
|
* Interceptor for automatic activity logging
|
|
* Logs CREATE, UPDATE, DELETE actions based on HTTP methods
|
|
*/
|
|
@Injectable()
|
|
export class ActivityLoggingInterceptor implements NestInterceptor {
|
|
private readonly logger = new Logger(ActivityLoggingInterceptor.name);
|
|
|
|
constructor(private readonly activityService: ActivityService) {}
|
|
|
|
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
|
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
|
const { method, user } = request;
|
|
|
|
// Only log for authenticated requests
|
|
if (!user) {
|
|
return next.handle();
|
|
}
|
|
|
|
// Skip GET requests (read-only)
|
|
if (method === "GET") {
|
|
return next.handle();
|
|
}
|
|
|
|
return next.handle().pipe(
|
|
tap((result: unknown): void => {
|
|
// Use void to satisfy no-misused-promises rule
|
|
void this.logActivity(context, request, result);
|
|
})
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Logs activity asynchronously (not awaited to avoid blocking response)
|
|
*/
|
|
private async logActivity(
|
|
context: ExecutionContext,
|
|
request: AuthenticatedRequest,
|
|
result: unknown
|
|
): Promise<void> {
|
|
try {
|
|
const { method, params, body, user, ip, headers } = request;
|
|
|
|
if (!user) {
|
|
return;
|
|
}
|
|
|
|
const action = this.mapMethodToAction(method);
|
|
if (!action) {
|
|
return;
|
|
}
|
|
|
|
// Extract entity information
|
|
const resultObj = result as Record<string, unknown> | undefined;
|
|
const entityId = params.id ?? (resultObj?.id as string | undefined);
|
|
|
|
// workspaceId is now optional - log events even when missing
|
|
const workspaceId = user.workspaceId ?? (body.workspaceId as string | undefined);
|
|
|
|
// Log with warning if entityId is missing, but still proceed with logging if workspaceId exists
|
|
if (!entityId) {
|
|
this.logger.warn("Cannot log activity: missing entityId");
|
|
return;
|
|
}
|
|
|
|
// Determine entity type from controller/handler
|
|
const controllerName = context.getClass().name;
|
|
const handlerName = context.getHandler().name;
|
|
const entityType = this.inferEntityType(controllerName, handlerName);
|
|
|
|
// Build activity details with sanitized body
|
|
const sanitizedBody = this.sanitizeSensitiveData(body);
|
|
const details: Prisma.JsonObject = {
|
|
method,
|
|
controller: controllerName,
|
|
handler: handlerName,
|
|
};
|
|
|
|
if (method === "POST") {
|
|
details.data = sanitizedBody;
|
|
} else if (method === "PATCH" || method === "PUT") {
|
|
details.changes = sanitizedBody;
|
|
}
|
|
|
|
// Extract user agent header
|
|
const userAgentHeader = headers["user-agent"];
|
|
const userAgent =
|
|
typeof userAgentHeader === "string" ? userAgentHeader : userAgentHeader?.[0];
|
|
|
|
// Log the activity — workspaceId is optional
|
|
const activityInput: CreateActivityLogInput = {
|
|
userId: user.id,
|
|
action,
|
|
entityType,
|
|
entityId,
|
|
details,
|
|
ipAddress: ip ?? undefined,
|
|
userAgent: userAgent ?? undefined,
|
|
};
|
|
if (workspaceId) {
|
|
activityInput.workspaceId = workspaceId;
|
|
}
|
|
await this.activityService.logActivity(activityInput);
|
|
} catch (error) {
|
|
// Don't fail the request if activity logging fails
|
|
this.logger.error(
|
|
"Failed to log activity",
|
|
error instanceof Error ? error.message : "Unknown error"
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Map HTTP method to ActivityAction
|
|
*/
|
|
private mapMethodToAction(method: string): ActivityAction | null {
|
|
switch (method) {
|
|
case "POST":
|
|
return ActivityAction.CREATED;
|
|
case "PATCH":
|
|
case "PUT":
|
|
return ActivityAction.UPDATED;
|
|
case "DELETE":
|
|
return ActivityAction.DELETED;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Infer entity type from controller/handler names
|
|
*/
|
|
private inferEntityType(controllerName: string, handlerName: string): EntityType {
|
|
const combined = `${controllerName} ${handlerName}`.toLowerCase();
|
|
|
|
if (combined.includes("task")) {
|
|
return EntityType.TASK;
|
|
} else if (combined.includes("event")) {
|
|
return EntityType.EVENT;
|
|
} else if (combined.includes("project")) {
|
|
return EntityType.PROJECT;
|
|
} else if (combined.includes("workspace")) {
|
|
return EntityType.WORKSPACE;
|
|
} else if (combined.includes("user")) {
|
|
return EntityType.USER;
|
|
}
|
|
|
|
// Default to TASK if cannot determine
|
|
return EntityType.TASK;
|
|
}
|
|
|
|
/**
|
|
* Sanitize sensitive data from objects before logging
|
|
* Redacts common sensitive field names
|
|
*/
|
|
private sanitizeSensitiveData(data: unknown): Prisma.JsonValue {
|
|
if (typeof data !== "object" || data === null) {
|
|
return data as Prisma.JsonValue;
|
|
}
|
|
|
|
// List of sensitive field names (case-insensitive)
|
|
const sensitiveFields = [
|
|
"password",
|
|
"token",
|
|
"secret",
|
|
"apikey",
|
|
"api_key",
|
|
"authorization",
|
|
"creditcard",
|
|
"credit_card",
|
|
"cvv",
|
|
"ssn",
|
|
"privatekey",
|
|
"private_key",
|
|
];
|
|
|
|
const sanitize = (obj: unknown): Prisma.JsonValue => {
|
|
if (Array.isArray(obj)) {
|
|
return obj.map((item) => sanitize(item)) as Prisma.JsonArray;
|
|
}
|
|
|
|
if (obj && typeof obj === "object") {
|
|
const sanitized: Prisma.JsonObject = {};
|
|
const objRecord = obj as Record<string, unknown>;
|
|
|
|
for (const key in objRecord) {
|
|
const lowerKey = key.toLowerCase();
|
|
const isSensitive = sensitiveFields.some((field) => lowerKey.includes(field));
|
|
|
|
if (isSensitive) {
|
|
sanitized[key] = "[REDACTED]";
|
|
} else if (typeof objRecord[key] === "object") {
|
|
sanitized[key] = sanitize(objRecord[key]);
|
|
} else {
|
|
sanitized[key] = objRecord[key] as Prisma.JsonValue;
|
|
}
|
|
}
|
|
|
|
return sanitized;
|
|
}
|
|
|
|
return obj as Prisma.JsonValue;
|
|
};
|
|
|
|
return sanitize(data);
|
|
}
|
|
}
|