import type { Request } from "express"; import { Inject, Injectable } from "@nestjs/common"; import type { AuthContext } from "./types.js"; import { logger } from "./logger.js"; import { PrismaService } from "./prisma.service.js"; export type AuditActorType = "USER" | "API_KEY" | "SYSTEM" | "INTEGRATION"; export interface AuditWrite { actorId?: string; /** The actor kind. `write()` infers `USER`/`SYSTEM` from `actorId`; the * `audit()` convenience derives it from `req.auth` (TASK-0127: an API-key * request is `API_KEY`, never `USER`). */ actorType?: AuditActorType; action: string; // e.g. "user.login", "page.created", "permission.changed" resourceType: string; // "user" | "page" | "workspace" | ... resourceId: string; workspaceId?: string; pageId?: string; metadata?: Record; } /** * Audit trail. Every mutating endpoint calls `write` (or the `audit` * convenience with the incoming request) so the AuditLog row captures * actor type/id, workspace, resource, IP, user agent and metadata. * * Failures are logged, never thrown: audit must not break the request. */ @Injectable() export class AuditService { constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} async write(input: AuditWrite): Promise { try { await this.prisma.auditLog.create({ data: { actorType: input.actorId ? "USER" : "SYSTEM", actorId: input.actorId ?? "system", workspaceId: input.workspaceId, pageId: input.pageId, resourceType: input.resourceType, resourceId: input.resourceId, action: input.action, metadata: (input.metadata ?? {}) as object, }, }); } catch (err) { logger.warn(`audit write failed (${input.action}): ${(err as Error).message}`); } } /** * Convenience: derive actor + network context from the request. `req` is * optional so a SYSTEM actor (a background/budget hook with no request * context) can still record the event — the network fields stay null. */ async audit( req: Request | undefined, auth: AuthContext | undefined, input: Omit & { actorId?: string; actorType?: AuditActorType; }, ): Promise { // TASK-0127: an API-key request is attributed to the KEY // (`actorType: "API_KEY"`, `actorId: "api:{keyId}"`) — NEVER to the // key's creator. The creator (`auth.userId`) is the attribution of a // USER request only; consulting it for a key would let an audit trail // conflate "the workspace acted via its key" with "the user acted" // (docs/api/authentication.md §7: the key acts as the workspace). const isKey = auth?.kind === "api"; const actorId = input.actorId ?? (isKey ? `api:${auth.apiKeyId ?? "unknown"}` : (auth?.userId ?? undefined)); try { await this.prisma.auditLog.create({ data: { actorType: input.actorType ?? (isKey ? "API_KEY" : auth ? "USER" : "SYSTEM"), actorId: actorId ?? "system", workspaceId: input.workspaceId ?? auth?.workspaceId, pageId: input.pageId, resourceType: input.resourceType, resourceId: input.resourceId, action: input.action, ipAddress: req?.ip ?? null, userAgent: req?.headers["user-agent"] ?? null, metadata: (input.metadata ?? {}) as object, }, }); } catch (err) { logger.warn(`audit write failed (${input.action}): ${(err as Error).message}`); } } }