audit.ts186 lines · main
1/**
2 * briven-engine security audit trail on Doltgres.
3 *
4 * Writes auth events (sign-in, fail, logout, secret change) for operator review.
5 * Never stores raw IPs — only a short hash hint (CLAUDE.md §5.1).
6 */
7
8import { createHash, randomBytes } from 'node:crypto';
9
10import { getEnginePool } from './db.js';
11import { mapProjectToAuthCore } from './project-map.js';
12import { log } from '../../lib/logger.js';
13
14export type BrivenEngineAuditAction =
15 | 'signin.password'
16 | 'signin.password.fail'
17 | 'signup.password'
18 | 'signin.passwordless'
19 | 'signin.passwordless.code_created'
20 | 'signin.passwordless.fail'
21 | 'signin.social'
22 | 'signin.social.fail'
23 | 'signin.passkey'
24 | 'signin.sso'
25 | 'session.created'
26 | 'session.revoked'
27 | 'mfa.totp.verified'
28 | 'mfa.totp.fail'
29 | 'config.methods.updated'
30 | 'config.sms_secrets.saved'
31 | 'config.oauth_secrets.saved'
32 | 'config.branding.saved'
33 | 'm2m.client.created'
34 | 'm2m.client.revoked'
35 | 'm2m.token.issued'
36 | 'm2m.token.fail'
37 | 'oidc.client.created'
38 | 'oidc.client.revoked'
39 | 'oidc.consent.granted'
40 | 'oidc.consent.denied'
41 | 'oidc.code.issued'
42 | 'oidc.token.issued'
43 | 'oidc.token.revoked'
44
45export type RecordBrivenEngineAuditInput = {
46 action: BrivenEngineAuditAction | string;
47 tenantId?: string | null;
48 projectId?: string | null;
49 userId?: string | null;
50 /** Raw IP — hashed to a short hint before store. */
51 ip?: string | null;
52 userAgent?: string | null;
53 metadata?: Record<string, unknown>;
54};
55
56export type BrivenEngineAuditRow = {
57 id: string;
58 tenantId: string;
59 projectId: string | null;
60 userId: string | null;
61 action: string;
62 /** First 8 chars of hash — correlation only, never raw IP. */
63 ipHashHint: string | null;
64 userAgent: string | null;
65 metadata: Record<string, unknown>;
66 occurredAt: string;
67};
68
69function newAuditId(): string {
70 return `bea_${randomBytes(12).toString('hex')}`;
71}
72
73/** Short correlation hint only — never store or return the raw IP. */
74export function auditIpHashHint(ip: string | null | undefined): string | null {
75 if (!ip) return null;
76 const full = createHash('sha256').update(`briven-engine-audit:${ip}`).digest('hex');
77 return full.slice(0, 8);
78}
79
80function ipHint(ip: string | null | undefined): string | null {
81 return auditIpHashHint(ip);
82}
83
84/**
85 * Fire-and-forget friendly: never throws to callers (auth must not fail on audit).
86 */
87export async function recordBrivenEngineAudit(
88 input: RecordBrivenEngineAuditInput,
89): Promise<void> {
90 try {
91 let tenantId = input.tenantId?.trim() || null;
92 let projectId = input.projectId?.trim() || null;
93 if (projectId && !tenantId) {
94 tenantId = mapProjectToAuthCore(projectId).tenantId;
95 }
96 if (!tenantId) tenantId = 'public';
97
98 const pool = getEnginePool();
99 const id = newAuditId();
100 const ua = input.userAgent?.slice(0, 512) ?? null;
101 const meta = JSON.stringify(input.metadata ?? {});
102 await pool.query(
103 `INSERT INTO be_audit_events
104 (id, tenant_id, project_id, user_id, action, ip_hash_hint, user_agent, metadata_json, occurred_at)
105 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())`,
106 [
107 id,
108 tenantId,
109 projectId,
110 input.userId ?? null,
111 input.action,
112 ipHint(input.ip),
113 ua,
114 meta,
115 ],
116 );
117 } catch (err) {
118 log.warn('briven_engine_audit_write_failed', {
119 action: input.action,
120 message: err instanceof Error ? err.message : String(err),
121 });
122 }
123}
124
125export async function listBrivenEngineAudit(opts: {
126 projectId: string;
127 limit?: number;
128 action?: string | null;
129 userId?: string | null;
130}): Promise<{ ok: true; engine: 'briven-engine'; items: BrivenEngineAuditRow[] }> {
131 const map = mapProjectToAuthCore(opts.projectId);
132 const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
133 const pool = getEnginePool();
134
135 const params: unknown[] = [map.tenantId, opts.projectId];
136 let where =
137 '(tenant_id = $1 OR project_id = $2)';
138 if (opts.action) {
139 params.push(opts.action);
140 where += ` AND action = $${params.length}`;
141 }
142 if (opts.userId) {
143 params.push(opts.userId);
144 where += ` AND user_id = $${params.length}`;
145 }
146 params.push(limit);
147
148 const res = await pool.query(
149 `SELECT id, tenant_id, project_id, user_id, action, ip_hash_hint, user_agent, metadata_json, occurred_at
150 FROM be_audit_events
151 WHERE ${where}
152 ORDER BY occurred_at DESC
153 LIMIT $${params.length}`,
154 params,
155 );
156
157 const items: BrivenEngineAuditRow[] = (res.rows as Array<Record<string, unknown>>).map(
158 (r) => {
159 let metadata: Record<string, unknown> = {};
160 try {
161 metadata = JSON.parse(String(r.metadata_json ?? '{}')) as Record<
162 string,
163 unknown
164 >;
165 } catch {
166 metadata = {};
167 }
168 return {
169 id: String(r.id),
170 tenantId: String(r.tenant_id),
171 projectId: r.project_id ? String(r.project_id) : null,
172 userId: r.user_id ? String(r.user_id) : null,
173 action: String(r.action),
174 ipHashHint: r.ip_hash_hint ? String(r.ip_hash_hint) : null,
175 userAgent: r.user_agent ? String(r.user_agent) : null,
176 metadata,
177 occurredAt:
178 r.occurred_at instanceof Date
179 ? r.occurred_at.toISOString()
180 : String(r.occurred_at),
181 };
182 },
183 );
184
185 return { ok: true, engine: 'briven-engine', items };
186}