/** * TASK-0133 — org-scoped AUDIT VIEWER service. * * Org scoping (recorded decision — no new index; EXPLAIN on the live 12k-row * table uses `audit_logs_workspaceId_action_createdAt_idx`): the org's rows * are * - rows in ANY of the org's workspaces (`workspaceId IN wsIds`), PLUS * - ORG-LEVEL rows: `workspaceId IS NULL` and (metadata.orgId = orgId — * the SSO/SCIM admin + login emitters) OR (resourceType * "organization"|"org" and resourceId = orgId — org.created/org.updated/ * SSO-admin + my own org.policy.updated/org.member_updated). * * Read-only by construction: no UPDATE/DELETE path exists on AuditLog (the * audit data is immutable — the router-walk test in the integration suite * asserts the admin surface exposes no mutation routes). * * CSV export (the stable 13-column contract in `audit-csv.ts`): * - ≤10k matching rows → SYNCHRONOUS 200 (streamed text/csv) * - >10k → 202 {exportId}: a `maintenance` queue job * (`audit.export`) streams cursor batches (5k) into S3 * `exports/{orgId}/{exportId}.csv` (retained 7 d), capped at 500k rows * (`truncated:true`). `GET .../exports/:id` → status + a SINGLE-USE, * 15-minute presigned download (the flag is flipped when the URL is * issued; a second poll without a download returns the status again * after the TTL, and an explicit re-request via `?redownload=1` mints a * fresh one). One active export per org (409 otherwise). */ import { Inject, Injectable } from "@nestjs/common"; import type { Prisma } from "@prisma/client"; import { randomUUID } from "node:crypto"; // The API compiles under node10 moduleResolution → root barrel only (the // shared package re-exports the TASK-0133 audit surface at the root). import { AUDIT_CATALOG_NAMES, auditOrgScopeWhere, auditScopedWhere, renderAuditCsv, AUDIT_CSV_HEADER, type AuditFilters as SharedAuditFilters, type AuditCsvRow } from "@factory/shared"; import type { Request } from "express"; import { ApiError } from "../../common/errors.js"; import { jobTraceMetadata } from "../../common/trace-metadata.js"; import { PrismaService } from "../../common/prisma.service.js"; import { RedisService } from "../../common/redis.service.js"; import { AuditService } from "../../common/audit.service.js"; import { requireUser } from "../../common/types.js"; import { assertOrgAdmin } from "./guards.js"; const DAY_MS = 86_400_000; const SYNC_ROW_LIMIT = 10_000; const ASYNC_ROW_CAP = 500_000; const EXPORT_TTL_SEC = 7 * 86_400; export type ExportStatus = "processing" | "ready" | "failed"; // ────────────────────────────── query core ───────────────────────────────── // The org scope + filter builders live in `@factory/shared/events/audit-scope` // (single source shared with the worker's retention purge + async export). export type AuditFilters = SharedAuditFilters; export interface ParsedAuditQuery extends AuditFilters { page: number; pageSize: number; } /** * Parse + validate the viewer query. `from`/`to` are ISO date-strings (or * Dates); the window may not exceed 90 days (400). `pageSize` ≤ 100, default * 50. Throws ApiError.validation on bad input. */ export function parseAuditQuery(q: Record): ParsedAuditQuery { const str = (k: string): string | undefined => { const v = q[k]; if (v === undefined || v === null || v === "") return undefined; if (typeof v !== "string") throw ApiError.validation(`Invalid ${k}.`, [{ field: k, issue: "not_a_string" }]); return v; }; let from: Date | undefined; let to: Date | undefined; const fromRaw = str("from"); const toRaw = str("to"); if (fromRaw) { from = new Date(fromRaw); if (Number.isNaN(from.getTime())) throw ApiError.validation("Invalid from date.", [{ field: "from", issue: "bad_date" }]); } if (toRaw) { to = new Date(toRaw); if (Number.isNaN(to.getTime())) throw ApiError.validation("Invalid to date.", [{ field: "to", issue: "bad_date" }]); } if (from && to && to.getTime() - from.getTime() > 90 * DAY_MS) { throw ApiError.validation("Date range is limited to 90 days per query.", [{ reason: "date_range_too_wide" }]); } if (from && to && to < from) { throw ApiError.validation("to must be after from.", [{ reason: "date_range_inverted" }]); } const page = q.page === undefined ? 1 : Number(q.page); const pageSize = q.pageSize === undefined ? 50 : Number(q.pageSize); if (!Number.isInteger(page) || page < 1) throw ApiError.validation("Invalid page.", [{ field: "page", issue: "bad_page" }]); if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 100) { throw ApiError.validation("pageSize must be 1..100.", [{ field: "pageSize", issue: "bad_pageSize" }]); } return { actorId: str("actorId"), actorType: str("actorType"), action: str("action"), actionPrefix: str("actionPrefix"), resourceType: str("resourceType"), workspaceId: str("workspaceId"), from, to, page, pageSize }; } @Injectable() export class OrgAuditService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(RedisService) private readonly redis: RedisService, @Inject(AuditService) private readonly audit: AuditService ) {} /** The org-admin gate (every audit-viewer route is admin-only). */ private async requireAdmin(orgId: string, req: Request): Promise { const userId = requireUser(req); await assertOrgAdmin(this.prisma, orgId, userId); } private async wsIds(orgId: string): Promise { const rows = await this.prisma.workspace.findMany({ where: { organizationId: orgId }, select: { id: true } }); return rows.map((r) => r.id); } /** The full filter set (org scope + user filters) for a query. */ private async scope(orgId: string, f: AuditFilters): Promise { const ids = await this.wsIds(orgId); return auditScopedWhere(orgId, ids, f) as Prisma.AuditLogWhereInput; } /** `GET /orgs/:orgId/admin/audit` — paginated + the deep-link existence map. */ async list(orgId: string, rawQuery: Record, req: Request) { await this.requireAdmin(orgId, req); const q = parseAuditQuery(rawQuery); const where = await this.scope(orgId, q); const [total, rows] = await Promise.all([ this.prisma.auditLog.count({ where }), this.prisma.auditLog.findMany({ where, orderBy: [{ createdAt: "desc" }, { id: "desc" }], skip: (q.page - 1) * q.pageSize, take: q.pageSize }) ]); // Deep-link existence for this page's linkable resources (pages, // databases, records) — one bulk query per type (the drawer deep-links // only when the resource still exists; gone → plain id). const linkTypes: Record = {}; const idsFor = (type: string) => [...new Set(rows.filter((r) => r.resourceType === type && r.resourceId).map((r) => r.resourceId))]; const [pageIds, dbIds, recIds] = await Promise.all([ this.prisma.page.findMany({ where: { id: { in: idsFor("page") } }, select: { id: true } }), this.prisma.database.findMany({ where: { id: { in: idsFor("database") } }, select: { id: true } }), this.prisma.databaseRecord.findMany({ where: { id: { in: idsFor("record") } }, select: { id: true } }) ]); for (const p of pageIds) linkTypes[p.id] = "page"; for (const d of dbIds) linkTypes[d.id] = "database"; for (const r of recIds) linkTypes[r.id] = "record"; return { data: rows.map((r) => ({ ...r, resourceLink: r.resourceId && linkTypes[r.resourceId] ? { type: linkTypes[r.resourceId] } : null })), total, page: q.page, pageSize: q.pageSize }; } /** `GET /orgs/:orgId/admin/audit/actions` — the autocomplete list. */ async distinctActions(orgId: string, req: Request) { await this.requireAdmin(orgId, req); const wsIds = await this.wsIds(orgId); const known = new Set(AUDIT_CATALOG_NAMES as readonly string[]); const rows = await this.prisma.auditLog.findMany({ where: auditOrgScopeWhere(orgId, wsIds) as Prisma.AuditLogWhereInput, select: { action: true }, distinct: ["action"], take: 1000 }); const actions = [...new Set(rows.map((r) => r.action))].sort(); // Catalog actions first (the expected surface), then anything observed. const ordered = [...actions.filter((a) => known.has(a)), ...actions.filter((a) => !known.has(a))]; return { data: ordered }; } // ────────────────────────────── CSV export ─────────────────────────────── /** The S3 object key for an export (the worker writes to the SAME key). */ private exportKey(orgId: string, exportId: string): string { return `exports/${orgId}/${exportId}.csv`; } private async nameMaps(rows: AuditCsvRow[]): Promise<{ users: Map; workspaces: Map; }> { const userIds = [...new Set(rows.filter((r) => r.actorType === "USER").map((r) => r.actorId))]; const wsIds = [...new Set(rows.map((r) => r.workspaceId).filter((w): w is string => Boolean(w)))]; const [users, workspaces] = await Promise.all([ this.prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true } }), this.prisma.workspace.findMany({ where: { id: { in: wsIds } }, select: { id: true, name: true } }) ]); return { users: new Map(users.map((u) => [u.id, u.name])), workspaces: new Map(workspaces.map((w) => [w.id, w.name])) }; } async countForExport(orgId: string, f: AuditFilters): Promise { return this.prisma.auditLog.count({ where: await this.scope(orgId, f) }); } /** * `POST /orgs/:orgId/admin/audit/export`. Returns either the inline CSV * (sync, ≤10k rows) or `202 {exportId}` (async). The caller sets the * response shape (the controller inspects `kind`). */ async startExport(orgId: string, rawQuery: Record, req: Request) { await this.requireAdmin(orgId, req); const q = parseAuditQuery(rawQuery); const where = await this.scope(orgId, q); const total = await this.prisma.auditLog.count({ where }); if (total <= SYNC_ROW_LIMIT) { const rows = await this.prisma.auditLog.findMany({ where, orderBy: [{ createdAt: "desc" }, { id: "desc" }], take: SYNC_ROW_LIMIT }); const csvRows: AuditCsvRow[] = rows.map((r) => ({ id: r.id, createdAt: r.createdAt, actorType: r.actorType, actorId: r.actorId, workspaceId: r.workspaceId, resourceType: r.resourceType, resourceId: r.resourceId, action: r.action, ipAddress: r.ipAddress, userAgent: r.userAgent, metadata: r.metadata })); const maps = await this.nameMaps(csvRows); return { kind: "sync" as const, total, csv: renderAuditCsv(csvRows, maps.users, maps.workspaces) }; } // Async: one active export per org. const active = await this.prisma.auditExport.findFirst({ where: { organizationId: orgId, status: "processing" }, select: { id: true } }); if (active) { throw ApiError.conflict("An export is already running for this organization.", [ { reason: "export_in_progress", exportId: active.id } ]); } const exportId = randomUUID(); await this.prisma.auditExport.create({ data: { id: exportId, organizationId: orgId, status: "processing", filters: JSON.stringify(q) as unknown as object, totalRows: total, capRows: ASYNC_ROW_CAP } }); return { kind: "async" as const, total, exportId, cap: ASYNC_ROW_CAP }; } /** Enqueue the worker `audit.export` job (maintenance queue). */ async enqueueExportJob(exportId: string, orgId: string): Promise { const { Queue } = await import("bullmq"); const q = new Queue("maintenance", { connection: this.redis.getRedis().options as never }); try { await q.add("maintenance", { type: "audit.export", orgId, exportId }, { jobId: exportId, ...jobTraceMetadata() }); } finally { await q.close(); } } /** `GET /orgs/:orgId/admin/audit/exports/:exportId` — status + single-use URL. */ async exportStatus(orgId: string, exportId: string, redownload: boolean, req: Request) { await this.requireAdmin(orgId, req); const row = await this.prisma.auditExport.findFirst({ where: { id: exportId, organizationId: orgId } }); if (!row) throw ApiError.notFound("Export not found."); const payload: Record = { exportId: row.id, status: row.status.toLowerCase(), rows: row.rows, truncated: row.truncated, createdAt: row.createdAt, completedAt: row.completedAt }; if (row.error) payload.error = row.error; if (row.status === "ready" && row.downloadUsed === false && (redownload || row.downloadUrlAt === null)) { // Mint the single-use URL (15 min) + flip the flag. const key = `exports/${orgId}/${row.id}.csv`; const url = await this.presignCsv(orgId, key); await this.prisma.auditExport.update({ where: { id: row.id }, data: { downloadUrlAt: new Date(), downloadUsed: true } }); payload.downloadUrl = url; payload.urlTtlSec = 900; } return payload; } /** * The S3 presign for a CSV (15 min, attachment disposition). Uses the API's * files-bucket SigV4 signer (no network call — pure signing), matching the * files module's bucket. */ private async presignCsv(orgId: string, key: string): Promise { const { filesBucketConfig } = await import("../files/s3.js"); const { presignGet } = await import("@factory/storage"); const signed = presignGet({ config: filesBucketConfig(), key, expiresSec: 900, now: new Date(), responseContentDisposition: `attachment; filename="audit-${orgId.slice(0, 8)}.csv"` }); return signed.url; } } export { AUDIT_CSV_HEADER, EXPORT_TTL_SEC };