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 | |
| 8 | import { createHash, randomBytes } from 'node:crypto'; |
| 9 | |
| 10 | import { getEnginePool } from './db.js'; |
| 11 | import { mapProjectToAuthCore } from './project-map.js'; |
| 12 | import { log } from '../../lib/logger.js'; |
| 13 | |
| 14 | export 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 | |
| 45 | export 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 | |
| 56 | export 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 | |
| 69 | function newAuditId(): string { |
| 70 | return `bea_${randomBytes(12).toString('hex')}`; |
| 71 | } |
| 72 | |
| 73 | /** Short correlation hint only — never store or return the raw IP. */ |
| 74 | export 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 | |
| 80 | function 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 | */ |
| 87 | export 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 | |
| 125 | export 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 | } |