audit.ts201 lines · main
1import { createHash, randomBytes } from 'node:crypto';
2
3import { newId } from '@briven/shared';
4import { and, desc, eq, inArray, like, sql } from 'drizzle-orm';
5
6import { getDb } from '../db/client.js';
7import { auditLogs, type NewAuditLog } from '../db/schema.js';
8import { env } from '../env.js';
9import { log } from '../lib/logger.js';
10
11/**
12 * Resolve the audit-log IP pepper. Refuses to boot in non-development when
13 * BRIVEN_AUDIT_IP_PEPPER is unset. In dev we generate a per-process random
14 * value — sufficient to keep dev workflows running without baking a
15 * publicly-known string into the open-core source.
16 *
17 * The pepper is intentionally separate from BRIVEN_BETTER_AUTH_SECRET so a
18 * leak of the audit-log column (or shell on the API host) can't be combined
19 * with knowledge of the auth secret to de-anonymise every actor IP.
20 */
21let cachedPepper: string | null = null;
22function getAuditIpPepper(): string {
23 if (cachedPepper) return cachedPepper;
24 if (env.BRIVEN_AUDIT_IP_PEPPER) {
25 cachedPepper = env.BRIVEN_AUDIT_IP_PEPPER;
26 return cachedPepper;
27 }
28 if (env.BRIVEN_ENV === 'development') {
29 log.warn(
30 'BRIVEN_AUDIT_IP_PEPPER not set — using ephemeral per-process pepper. Audit IP hashes will not correlate across restarts.',
31 );
32 cachedPepper = randomBytes(32).toString('hex');
33 return cachedPepper;
34 }
35 throw new Error(
36 'BRIVEN_AUDIT_IP_PEPPER is required outside development. Set a value of at least 32 chars.',
37 );
38}
39
40/**
41 * Hash the caller IP with the audit-log pepper so we can correlate abuse
42 * without ever storing raw IPs (CLAUDE.md §5.1). The pepper is resolved
43 * internally from BRIVEN_AUDIT_IP_PEPPER — callers no longer pass it.
44 */
45export function hashIp(ip: string | null | undefined): string | null {
46 if (!ip) return null;
47 return createHash('sha256').update(`${getAuditIpPepper()}:${ip}`).digest('hex');
48}
49
50export interface AuditEntry {
51 actorId: string | null;
52 projectId: string | null;
53 action: string;
54 ipHash: string | null;
55 userAgent: string | null;
56 metadata?: Record<string, unknown>;
57}
58
59export interface AuditRow {
60 id: string;
61 action: string;
62 actorId: string | null;
63 ipHash: string | null;
64 metadata: Record<string, unknown> | null;
65 createdAt: Date;
66}
67
68/**
69 * Recent audit entries for a project, newest first. Used by the dashboard
70 * "activity" tab. The IP hash stays opaque; CLAUDE.md §5.1 forbids surfacing
71 * raw IPs to the dashboard.
72 */
73export async function listAuditForProject(
74 projectId: string,
75 opts: { limit?: number; actionPrefix?: string } = {},
76): Promise<AuditRow[]> {
77 const db = getDb();
78 const where = opts.actionPrefix
79 ? and(eq(auditLogs.projectId, projectId), like(auditLogs.action, `${opts.actionPrefix}%`))
80 : eq(auditLogs.projectId, projectId);
81 const rows = await db
82 .select({
83 id: auditLogs.id,
84 action: auditLogs.action,
85 actorId: auditLogs.actorId,
86 ipHash: auditLogs.ipHash,
87 metadata: auditLogs.metadata,
88 createdAt: auditLogs.createdAt,
89 })
90 .from(auditLogs)
91 .where(where)
92 .orderBy(desc(auditLogs.createdAt))
93 .limit(opts.limit ?? 100);
94 return rows.map((r) => ({
95 ...r,
96 metadata: (r.metadata as Record<string, unknown> | null) ?? null,
97 }));
98}
99
100/**
101 * Recent platform-level audit entries matching an action prefix
102 * (e.g. "mittera.email.") — used by the admin dashboard to render
103 * email-event history without provisioning a dedicated table.
104 * Newest first; both project-scoped and platform-wide rows surface.
105 */
106export async function listAuditByActionPrefix(
107 prefix: string,
108 limit = 200,
109): Promise<AuditRow[]> {
110 const db = getDb();
111 const rows = await db
112 .select({
113 id: auditLogs.id,
114 action: auditLogs.action,
115 actorId: auditLogs.actorId,
116 ipHash: auditLogs.ipHash,
117 metadata: auditLogs.metadata,
118 createdAt: auditLogs.createdAt,
119 })
120 .from(auditLogs)
121 .where(like(auditLogs.action, `${prefix}%`))
122 .orderBy(desc(auditLogs.createdAt))
123 .limit(limit);
124 return rows.map((r) => ({
125 ...r,
126 metadata: (r.metadata as Record<string, unknown> | null) ?? null,
127 }));
128}
129
130/**
131 * Audit entries scoped to a specific migration request id. The request
132 * id lives in audit_logs.metadata.requestId (jsonb). Used by the
133 * customer-facing /dashboard/migrations/[id] timeline to show every
134 * status change + operator action chronologically.
135 *
136 * Restricts to known migration-related action prefixes so a malicious
137 * payload that happens to put a forged requestId into an unrelated
138 * action's metadata never surfaces here.
139 */
140const MIGRATION_AUDIT_ACTIONS = [
141 'migration_request.create',
142 'migration_request.public_create',
143 'admin.migration_request.update',
144 'admin.migration_request.promote_to_user',
145] as const;
146
147export async function listAuditForMigrationRequest(
148 requestId: string,
149 limit = 100,
150): Promise<AuditRow[]> {
151 const db = getDb();
152 const rows = await db
153 .select({
154 id: auditLogs.id,
155 action: auditLogs.action,
156 actorId: auditLogs.actorId,
157 ipHash: auditLogs.ipHash,
158 metadata: auditLogs.metadata,
159 createdAt: auditLogs.createdAt,
160 })
161 .from(auditLogs)
162 .where(
163 and(
164 inArray(auditLogs.action, [...MIGRATION_AUDIT_ACTIONS]),
165 sql`${auditLogs.metadata}->>'requestId' = ${requestId}`,
166 ),
167 )
168 .orderBy(desc(auditLogs.createdAt))
169 .limit(limit);
170 return rows.map((r) => ({
171 ...r,
172 metadata: (r.metadata as Record<string, unknown> | null) ?? null,
173 }));
174}
175
176/**
177 * Write an audit log entry. Failures never bubble up — an audit write
178 * failing must not break the request the audit is for. We still log the
179 * failure so the operator sees it.
180 */
181export async function audit(entry: AuditEntry): Promise<void> {
182 try {
183 const db = getDb();
184 const row: NewAuditLog = {
185 id: newId('au'),
186 actorId: entry.actorId,
187 projectId: entry.projectId,
188 action: entry.action,
189 ipHash: entry.ipHash,
190 userAgent: entry.userAgent,
191 metadata: entry.metadata ?? null,
192 };
193 await db.insert(auditLogs).values(row);
194 } catch (err) {
195 log.warn('audit_write_failed', {
196 action: entry.action,
197 projectId: entry.projectId,
198 message: err instanceof Error ? err.message : String(err),
199 });
200 }
201}