|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import type { HookContext } from '@objectstack/spec/data'; |
| 4 | +import type { IDataEngine } from '@objectstack/spec/contracts'; |
| 5 | + |
| 6 | +/** |
| 7 | + * Audit writer hook installer. |
| 8 | + * |
| 9 | + * Subscribes to the ObjectQL engine's wildcard `before*` / `after*` lifecycle |
| 10 | + * events and writes: |
| 11 | + * |
| 12 | + * - `sys_audit_log` rows — immutable, compliance-grade entries with |
| 13 | + * field-level `old_value` / `new_value` diffs. |
| 14 | + * - `sys_activity` rows — denormalized, human-readable summaries shown |
| 15 | + * in the dashboard recent-activity feed and per-record timelines. |
| 16 | + * |
| 17 | + * Skip rules avoid recursion and noise: |
| 18 | + * - Never audit the audit/activity tables themselves. |
| 19 | + * - Never audit session/presence/auth tables (high-frequency, low value). |
| 20 | + * - Read-only operations (`afterFind`) are never audited. |
| 21 | + * |
| 22 | + * All writes go through `ctx.api.sudo()` so they bypass record-level |
| 23 | + * permissions and always succeed regardless of the calling user's RBAC. |
| 24 | + */ |
| 25 | + |
| 26 | +/** Tables that are intentionally excluded from audit/activity writes. */ |
| 27 | +const SKIP_OBJECTS = new Set<string>([ |
| 28 | + 'sys_audit_log', |
| 29 | + 'sys_activity', |
| 30 | + 'sys_comment', |
| 31 | + 'sys_session', |
| 32 | + 'sys_presence', |
| 33 | + 'sys_account', |
| 34 | + 'sys_account_session', |
| 35 | + 'sys_account_verification', |
| 36 | + 'sys_account_account', |
| 37 | +]); |
| 38 | + |
| 39 | +/** Fields that are noise in diffs (always change, never user-meaningful). */ |
| 40 | +const NOISE_FIELDS = new Set<string>([ |
| 41 | + 'updated_at', |
| 42 | + 'updated_by', |
| 43 | + 'created_at', |
| 44 | + 'created_by', |
| 45 | +]); |
| 46 | + |
| 47 | +/** Action name produced from a HookContext.event string. */ |
| 48 | +function actionFor(event: string): 'create' | 'update' | 'delete' | null { |
| 49 | + if (event === 'afterInsert') return 'create'; |
| 50 | + if (event === 'afterUpdate') return 'update'; |
| 51 | + if (event === 'afterDelete') return 'delete'; |
| 52 | + return null; |
| 53 | +} |
| 54 | + |
| 55 | +/** Activity type produced from an audit action. */ |
| 56 | +function activityTypeFor(action: 'create' | 'update' | 'delete'): 'created' | 'updated' | 'deleted' { |
| 57 | + return action === 'create' ? 'created' : action === 'update' ? 'updated' : 'deleted'; |
| 58 | +} |
| 59 | + |
| 60 | +/** |
| 61 | + * Compute the human-readable record label from a record by trying common |
| 62 | + * label fields. Falls back to record id. |
| 63 | + */ |
| 64 | +function recordLabel(record: any, id: string): string { |
| 65 | + if (!record || typeof record !== 'object') return id; |
| 66 | + const candidates = ['name', 'subject', 'title', 'full_name', 'label', 'first_name', 'company', 'email']; |
| 67 | + for (const k of candidates) { |
| 68 | + const v = record[k]; |
| 69 | + if (typeof v === 'string' && v.trim()) return v.trim(); |
| 70 | + } |
| 71 | + return id; |
| 72 | +} |
| 73 | + |
| 74 | +/** |
| 75 | + * Compute a shallow JSON diff between two records. Returns only keys whose |
| 76 | + * value changed (and ignores keys in `NOISE_FIELDS`). Both sides are |
| 77 | + * serialisable via `JSON.stringify` — values that fail to serialise are |
| 78 | + * coerced to `String(value)`. |
| 79 | + */ |
| 80 | +function diff(before: Record<string, any>, after: Record<string, any>): { old: Record<string, any>; next: Record<string, any> } { |
| 81 | + const oldOut: Record<string, any> = {}; |
| 82 | + const newOut: Record<string, any> = {}; |
| 83 | + const keys = new Set<string>([...Object.keys(before || {}), ...Object.keys(after || {})]); |
| 84 | + for (const k of keys) { |
| 85 | + if (NOISE_FIELDS.has(k)) continue; |
| 86 | + const b = before?.[k]; |
| 87 | + const a = after?.[k]; |
| 88 | + if (safeStringify(b) !== safeStringify(a)) { |
| 89 | + oldOut[k] = b ?? null; |
| 90 | + newOut[k] = a ?? null; |
| 91 | + } |
| 92 | + } |
| 93 | + return { old: oldOut, next: newOut }; |
| 94 | +} |
| 95 | + |
| 96 | +function safeStringify(v: any): string { |
| 97 | + try { return JSON.stringify(v); } catch { return String(v); } |
| 98 | +} |
| 99 | + |
| 100 | +/** |
| 101 | + * Install audit + activity writers on the given engine. Idempotent per |
| 102 | + * `packageId` — calling twice with the same id replaces the previous |
| 103 | + * registration. |
| 104 | + */ |
| 105 | +export function installAuditWriters(engine: any, packageId = 'com.objectstack.audit'): void { |
| 106 | + if (!engine || typeof engine.registerHook !== 'function') return; |
| 107 | + |
| 108 | + // Remove any prior installation so we can safely re-install on hot reload. |
| 109 | + if (typeof engine.unregisterHooksByPackage === 'function') { |
| 110 | + engine.unregisterHooksByPackage(packageId); |
| 111 | + } |
| 112 | + |
| 113 | + /** |
| 114 | + * beforeUpdate / beforeDelete: capture "previous" snapshot via api.sudo() |
| 115 | + * so we can compute the diff in the afterXxx hook. We attach the snapshot |
| 116 | + * to the context (`(ctx as any).__previous`) since `HookContext.previous` |
| 117 | + * is officially typed but not always populated by the engine itself. |
| 118 | + */ |
| 119 | + const captureBefore = async (ctx: HookContext) => { |
| 120 | + if (SKIP_OBJECTS.has(ctx.object)) return; |
| 121 | + const id = (ctx.input as any)?.id; |
| 122 | + if (!id) return; // bulk update/delete — too costly to snapshot every row here |
| 123 | + const api: any = (ctx as any).api; |
| 124 | + if (!api?.sudo) return; |
| 125 | + try { |
| 126 | + const prev = await api.sudo().object(ctx.object).findOne({ where: { id } }); |
| 127 | + if (prev) (ctx as any).__previous = prev; |
| 128 | + } catch { |
| 129 | + /* ignore — best-effort */ |
| 130 | + } |
| 131 | + }; |
| 132 | + |
| 133 | + engine.registerHook('beforeUpdate', captureBefore, { packageId }); |
| 134 | + engine.registerHook('beforeDelete', captureBefore, { packageId }); |
| 135 | + |
| 136 | + /** |
| 137 | + * afterInsert / afterUpdate / afterDelete: write audit_log + activity rows. |
| 138 | + * Errors are swallowed (logged) so user-facing CRUD is never broken by |
| 139 | + * audit failures. |
| 140 | + */ |
| 141 | + const writeAudit = async (ctx: HookContext) => { |
| 142 | + if (SKIP_OBJECTS.has(ctx.object)) return; |
| 143 | + const action = actionFor(ctx.event); |
| 144 | + if (!action) return; |
| 145 | + |
| 146 | + const api: any = (ctx as any).api; |
| 147 | + if (!api?.sudo) return; |
| 148 | + |
| 149 | + const after: any = ctx.result; |
| 150 | + const before: any = (ctx as any).__previous ?? (ctx as any).previous ?? null; |
| 151 | + |
| 152 | + // Resolve record id from after (insert/update) or before (delete) or input. |
| 153 | + let recordId: string | undefined = |
| 154 | + (typeof after === 'object' && after?.id) || |
| 155 | + (typeof before === 'object' && before?.id) || |
| 156 | + ((ctx.input as any)?.id); |
| 157 | + if (recordId !== undefined) recordId = String(recordId); |
| 158 | + |
| 159 | + const sess: any = (ctx as any).session ?? {}; |
| 160 | + const userId: string | undefined = sess.userId; |
| 161 | + const tenantId: string | undefined = sess.tenantId; |
| 162 | + |
| 163 | + let oldValue: Record<string, any> | null = null; |
| 164 | + let newValue: Record<string, any> | null = null; |
| 165 | + if (action === 'create') { |
| 166 | + newValue = (after && typeof after === 'object') ? { ...after } : null; |
| 167 | + } else if (action === 'update') { |
| 168 | + const d = diff(before || {}, after || {}); |
| 169 | + oldValue = d.old; |
| 170 | + newValue = d.next; |
| 171 | + // If nothing meaningfully changed, skip the audit row to avoid noise. |
| 172 | + if (Object.keys(newValue).length === 0) return; |
| 173 | + } else if (action === 'delete') { |
| 174 | + oldValue = before && typeof before === 'object' ? { ...before } : null; |
| 175 | + } |
| 176 | + |
| 177 | + const auditRow: Record<string, any> = { |
| 178 | + action, |
| 179 | + user_id: userId ?? null, |
| 180 | + object_name: ctx.object, |
| 181 | + record_id: recordId ?? null, |
| 182 | + old_value: oldValue ? safeStringify(oldValue) : null, |
| 183 | + new_value: newValue ? safeStringify(newValue) : null, |
| 184 | + tenant_id: tenantId ?? null, |
| 185 | + }; |
| 186 | + |
| 187 | + const label = recordLabel(after ?? before, recordId ?? ''); |
| 188 | + const summary = |
| 189 | + action === 'create' ? `Created ${ctx.object} "${label}"` : |
| 190 | + action === 'update' ? `Updated ${ctx.object} "${label}"` : |
| 191 | + `Deleted ${ctx.object} "${label}"`; |
| 192 | + |
| 193 | + const activityRow: Record<string, any> = { |
| 194 | + type: activityTypeFor(action), |
| 195 | + summary, |
| 196 | + actor_id: userId ?? null, |
| 197 | + object_name: ctx.object, |
| 198 | + record_id: recordId ?? null, |
| 199 | + record_label: label, |
| 200 | + metadata: newValue || oldValue ? safeStringify({ old: oldValue, new: newValue }) : null, |
| 201 | + }; |
| 202 | + |
| 203 | + try { |
| 204 | + const sys = api.sudo(); |
| 205 | + await sys.object('sys_audit_log').create(auditRow); |
| 206 | + await sys.object('sys_activity').create(activityRow); |
| 207 | + } catch (err) { |
| 208 | + // Log via engine logger if available, but never throw. |
| 209 | + try { (engine as any).logger?.warn?.('Audit write failed', { object: ctx.object, action, err: String((err as any)?.message ?? err) }); } catch {} |
| 210 | + } |
| 211 | + }; |
| 212 | + |
| 213 | + engine.registerHook('afterInsert', writeAudit, { packageId }); |
| 214 | + engine.registerHook('afterUpdate', writeAudit, { packageId }); |
| 215 | + engine.registerHook('afterDelete', writeAudit, { packageId }); |
| 216 | +} |
| 217 | + |
| 218 | +// Re-export for convenience. |
| 219 | +export type { IDataEngine }; |
0 commit comments