auth-audit.ts184 lines · main
1import { ValidationError } from '@briven/shared';
2
3import { runInProjectDatabase } from '../db/data-plane.js';
4
5/**
6 * Briven auth audit log reader.
7 *
8 * Reads `_briven_auth_audit_log` for a project. Same redaction rules as
9 * the rest of the auth surface (CLAUDE.md §5.1): raw IP never surfaces;
10 * the dashboard sees the first 8 chars of `ip_address_hash` as a stable
11 * opaque correlation key. `user_agent` passes through (already truncated
12 * to 512 chars at write time).
13 *
14 * Cursor pagination on `(occurred_at, id)` — descending so the most
15 * recent entries come first.
16 */
17
18const MAX_LIMIT = 200;
19const DEFAULT_LIMIT = 50;
20
21export interface ListAuditOpts {
22 readonly limit?: number;
23 readonly cursor?: string | null;
24 /** Filter by exact action (e.g. `signin`, `session.revoked`). */
25 readonly action?: string | null;
26 /** Filter to a single user's events. */
27 readonly userId?: string | null;
28}
29
30export interface AuditEntry {
31 id: string;
32 userId: string | null;
33 action: string;
34 /** Opaque correlation key. Never the raw IP. */
35 ipAddressHashHint: string | null;
36 userAgent: string | null;
37 /** Free-form context blob (provider id on signin, reason on revoke, etc). */
38 metadata: Record<string, unknown>;
39 occurredAt: string;
40}
41
42export interface AuditListResult {
43 items: AuditEntry[];
44 nextCursor: string | null;
45}
46
47interface RawAuditRow {
48 id: string;
49 user_id: string | null;
50 action: string;
51 ip_address_hash: string | null;
52 user_agent: string | null;
53 metadata: unknown;
54 occurred_at: Date;
55}
56
57/**
58 * Mask the ip_address_hash to its first 8 chars — enough for an operator
59 * to tell two distinct hashes apart in the dashboard without echoing the
60 * full digest. The full hash stays on the server; truncation here means
61 * a database leak of the dashboard's cached JSON still doesn't reveal
62 * the full IP-correlation key.
63 */
64export function ipHashHint(raw: string | null): string | null {
65 if (!raw) return null;
66 return raw.slice(0, 8);
67}
68
69export function shapeAuditRow(row: {
70 id: string;
71 userId: string | null;
72 action: string;
73 ipAddressHash: string | null;
74 userAgent: string | null;
75 metadata: unknown;
76 occurredAt: string;
77}): AuditEntry {
78 return {
79 id: row.id,
80 userId: row.userId,
81 action: row.action,
82 ipAddressHashHint: ipHashHint(row.ipAddressHash),
83 userAgent: row.userAgent,
84 metadata: isPlainObject(row.metadata) ? row.metadata : {},
85 occurredAt: row.occurredAt,
86 };
87}
88
89function isPlainObject(v: unknown): v is Record<string, unknown> {
90 return v !== null && typeof v === 'object' && !Array.isArray(v);
91}
92
93export async function listAuditEntries(
94 projectId: string,
95 opts: ListAuditOpts = {},
96): Promise<AuditListResult> {
97 const limit = clamp(opts.limit ?? DEFAULT_LIMIT, 1, MAX_LIMIT);
98
99 const where: string[] = ['1=1'];
100 const params: unknown[] = [];
101
102 if (opts.cursor) {
103 const parsed = parseCursor(opts.cursor);
104 params.push(parsed.occurredAt, parsed.id);
105 where.push(
106 `(occurred_at < $${params.length - 1}::timestamptz OR (occurred_at = $${params.length - 1}::timestamptz AND id < $${params.length}))`,
107 );
108 }
109 if (opts.action) {
110 if (!/^[a-z0-9._-]{1,64}$/i.test(opts.action)) {
111 throw new ValidationError('invalid action filter', { action: opts.action });
112 }
113 params.push(opts.action);
114 where.push(`action = $${params.length}`);
115 }
116 if (opts.userId) {
117 if (!/^[a-zA-Z0-9_]{1,64}$/.test(opts.userId)) {
118 throw new ValidationError('invalid user id filter', { userId: opts.userId });
119 }
120 params.push(opts.userId);
121 where.push(`user_id = $${params.length}`);
122 }
123
124 params.push(limit + 1);
125 const limitPlaceholder = `$${params.length}`;
126
127 const rows = await runInProjectDatabase<RawAuditRow[]>(projectId, async (tx) => {
128 return (await tx.unsafe(
129 `
130 SELECT id, user_id, action, ip_address_hash, user_agent, metadata, occurred_at
131 FROM "_briven_auth_audit_log"
132 WHERE ${where.join(' AND ')}
133 ORDER BY occurred_at DESC, id DESC
134 LIMIT ${limitPlaceholder}
135 `,
136 params as never[],
137 )) as RawAuditRow[];
138 });
139
140 const hasMore = rows.length > limit;
141 const page = hasMore ? rows.slice(0, limit) : rows;
142
143 const items = page.map((r) =>
144 shapeAuditRow({
145 id: r.id,
146 userId: r.user_id,
147 action: r.action,
148 ipAddressHash: r.ip_address_hash,
149 userAgent: r.user_agent,
150 metadata: r.metadata,
151 occurredAt: r.occurred_at.toISOString(),
152 }),
153 );
154
155 const nextCursor = hasMore
156 ? formatCursor({
157 occurredAt: page[page.length - 1]!.occurred_at.toISOString(),
158 id: page[page.length - 1]!.id,
159 })
160 : null;
161
162 return { items, nextCursor };
163}
164
165function clamp(n: number, lo: number, hi: number): number {
166 if (!Number.isFinite(n)) return lo;
167 return Math.min(hi, Math.max(lo, Math.floor(n)));
168}
169
170function formatCursor(args: { occurredAt: string; id: string }): string {
171 return `${args.occurredAt}__${args.id}`;
172}
173
174function parseCursor(raw: string): { occurredAt: string; id: string } {
175 const sepAt = raw.indexOf('__');
176 if (sepAt <= 0) throw new ValidationError('invalid cursor', { cursor: raw });
177 const occurredAt = raw.slice(0, sepAt);
178 const id = raw.slice(sepAt + 2);
179 if (!id) throw new ValidationError('invalid cursor', { cursor: raw });
180 if (Number.isNaN(Date.parse(occurredAt))) {
181 throw new ValidationError('invalid cursor', { cursor: raw });
182 }
183 return { occurredAt, id };
184}