audit.ts188 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 | 'ai.agent_token.created'
45 | 'ai.agent_token.revoked'
46
47export type RecordBrivenEngineAuditInput = {
48 action: BrivenEngineAuditAction | string;
49 tenantId?: string | null;
50 projectId?: string | null;
51 userId?: string | null;
52 /** Raw IP — hashed to a short hint before store. */
53 ip?: string | null;
54 userAgent?: string | null;
55 metadata?: Record<string, unknown>;
56};
57
58export type BrivenEngineAuditRow = {
59 id: string;
60 tenantId: string;
61 projectId: string | null;
62 userId: string | null;
63 action: string;
64 /** First 8 chars of hash — correlation only, never raw IP. */
65 ipHashHint: string | null;
66 userAgent: string | null;
67 metadata: Record<string, unknown>;
68 occurredAt: string;
69};
70
71function newAuditId(): string {
72 return `bea_${randomBytes(12).toString('hex')}`;
73}
74
75/** Short correlation hint only — never store or return the raw IP. */
76export function auditIpHashHint(ip: string | null | undefined): string | null {
77 if (!ip) return null;
78 const full = createHash('sha256').update(`briven-engine-audit:${ip}`).digest('hex');
79 return full.slice(0, 8);
80}
81
82function ipHint(ip: string | null | undefined): string | null {
83 return auditIpHashHint(ip);
84}
85
86/**
87 * Fire-and-forget friendly: never throws to callers (auth must not fail on audit).
88 */
89export async function recordBrivenEngineAudit(
90 input: RecordBrivenEngineAuditInput,
91): Promise<void> {
92 try {
93 let tenantId = input.tenantId?.trim() || null;
94 let projectId = input.projectId?.trim() || null;
95 if (projectId && !tenantId) {
96 tenantId = mapProjectToAuthCore(projectId).tenantId;
97 }
98 if (!tenantId) tenantId = 'public';
99
100 const pool = getEnginePool();
101 const id = newAuditId();
102 const ua = input.userAgent?.slice(0, 512) ?? null;
103 const meta = JSON.stringify(input.metadata ?? {});
104 await pool.query(
105 `INSERT INTO be_audit_events
106 (id, tenant_id, project_id, user_id, action, ip_hash_hint, user_agent, metadata_json, occurred_at)
107 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())`,
108 [
109 id,
110 tenantId,
111 projectId,
112 input.userId ?? null,
113 input.action,
114 ipHint(input.ip),
115 ua,
116 meta,
117 ],
118 );
119 } catch (err) {
120 log.warn('briven_engine_audit_write_failed', {
121 action: input.action,
122 message: err instanceof Error ? err.message : String(err),
123 });
124 }
125}
126
127export async function listBrivenEngineAudit(opts: {
128 projectId: string;
129 limit?: number;
130 action?: string | null;
131 userId?: string | null;
132}): Promise<{ ok: true; engine: 'briven-engine'; items: BrivenEngineAuditRow[] }> {
133 const map = mapProjectToAuthCore(opts.projectId);
134 const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
135 const pool = getEnginePool();
136
137 const params: unknown[] = [map.tenantId, opts.projectId];
138 let where =
139 '(tenant_id = $1 OR project_id = $2)';
140 if (opts.action) {
141 params.push(opts.action);
142 where += ` AND action = $${params.length}`;
143 }
144 if (opts.userId) {
145 params.push(opts.userId);
146 where += ` AND user_id = $${params.length}`;
147 }
148 params.push(limit);
149
150 const res = await pool.query(
151 `SELECT id, tenant_id, project_id, user_id, action, ip_hash_hint, user_agent, metadata_json, occurred_at
152 FROM be_audit_events
153 WHERE ${where}
154 ORDER BY occurred_at DESC
155 LIMIT $${params.length}`,
156 params,
157 );
158
159 const items: BrivenEngineAuditRow[] = (res.rows as Array<Record<string, unknown>>).map(
160 (r) => {
161 let metadata: Record<string, unknown> = {};
162 try {
163 metadata = JSON.parse(String(r.metadata_json ?? '{}')) as Record<
164 string,
165 unknown
166 >;
167 } catch {
168 metadata = {};
169 }
170 return {
171 id: String(r.id),
172 tenantId: String(r.tenant_id),
173 projectId: r.project_id ? String(r.project_id) : null,
174 userId: r.user_id ? String(r.user_id) : null,
175 action: String(r.action),
176 ipHashHint: r.ip_hash_hint ? String(r.ip_hash_hint) : null,
177 userAgent: r.user_agent ? String(r.user_agent) : null,
178 metadata,
179 occurredAt:
180 r.occurred_at instanceof Date
181 ? r.occurred_at.toISOString()
182 : String(r.occurred_at),
183 };
184 },
185 );
186
187 return { ok: true, engine: 'briven-engine', items };
188}