Audit log integration
A typed, tamper-evident audit trail of every user action in the grid, in under 50 lines. Pattern works for any backend - your existing audit table, Datadog logs, an immutable ledger (DynamoDB with conditional writes, AWS QLDB, Kafka), or a write-once S3 bucket.
Live in demo 35 (Permissions, audit & history) and demo 49 (Admin dashboard).
What's in scope
Every event the grid surfaces:
| Event | Grid callback | Audit record |
|---|---|---|
| Cell edit | onCellValueChange |
{actor, resource, before, after, ts} |
| Row selection | onRowSelectionChange |
{actor, selectedIds, ts} |
| Filter change | onFiltersChange |
{actor, filters, ts} |
| Sort change | onSortingChange |
{actor, sort, ts} |
| Group change | onGroupingChange |
{actor, groupBy, ts} |
| Bulk row delete | wrap api.removeRows(...) |
{actor, deletedIds, ts} |
| Bulk import | wrap api.addRows(...) after smart-paste |
{actor, addedIds, source: 'paste', ts} |
| Export | wrap api.exportData(...) |
{actor, format, rowCount, columns, ts} |
Minimal implementation
<script lang="ts">
import { SvGrid, type SvGridApi } from 'sv-grid-community'
type AuditRecord = {
actor: string
action: string
resource: string
before?: unknown
after?: unknown
ts: string
}
async function audit(rec: Omit<AuditRecord, 'ts'>) {
await fetch('/api/audit', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ ...rec, ts: new Date().toISOString() }),
})
}
let api = $state<SvGridApi<typeof features, Patient> | null>(null)
</script>
<SvGrid
{data} {columns} features={features}
onCellValueChange={(e) => audit({
actor: currentUser.id,
action: 'cell.edit',
resource: `patient/${e.row.id}/${e.columnId}`,
before: e.oldValue,
after: e.newValue,
})}
onApiReady={(next) => (api = next)}
/>
For the api.removeRows / addRows / exportData actions, wrap each
imperative call site:
async function deleteSelected() {
if (!api) return
const ids = selectedRows.map((r) => r.id)
api.removeRows(ids)
await audit({ actor: currentUser.id, action: 'row.delete', resource: `patient/${ids.join(',')}` })
}
Tamper-evident pattern (SHA-256 chain)
When a "trust but verify" auditor wants to confirm the log wasn't edited in the database, append the SHA-256 of the previous record to each new one. Cheap, no extra service needed:
async function append(rec: AuditRecord) {
const prev = await fetch('/api/audit/latest-hash').then((r) => r.text())
const enc = new TextEncoder()
const payload = JSON.stringify({ ...rec, prevHash: prev })
const hashBuf = await crypto.subtle.digest('SHA-256', enc.encode(payload))
const hash = Array.from(new Uint8Array(hashBuf)).map((b) => b.toString(16).padStart(2, '0')).join('')
await fetch('/api/audit', {
method: 'POST',
body: JSON.stringify({ ...rec, prevHash: prev, hash }),
})
}
Now any audit-log tampering breaks the chain - one Merkle-style verify scan over the log catches it.
Buffer + flush pattern (production-grade)
Fire-and-forget HTTP per edit is fine for low-traffic admin apps; for a heavy editor (spreadsheet-style work) buffer and flush:
const buf: AuditRecord[] = []
let flushTimer: number | null = null
function buffer(rec: Omit<AuditRecord, 'ts'>) {
buf.push({ ...rec, ts: new Date().toISOString() })
if (flushTimer === null) {
flushTimer = window.setTimeout(flush, 2000)
}
}
async function flush() {
flushTimer = null
if (buf.length === 0) return
const batch = buf.splice(0)
try {
await fetch('/api/audit/batch', { method: 'POST', body: JSON.stringify(batch) })
} catch {
buf.unshift(...batch) // retry next tick
}
}
window.addEventListener('beforeunload', flush)
The beforeunload flush is the trick: even if the user closes the
tab mid-session, the buffer ships.
What an auditor wants to see
When demonstrating the audit trail to an external auditor, the four demonstrations:
- End-to-end edit trace. Open a row, edit the salary field, show the audit record in your backend within 5s.
- Tamper detection. SQL-update a row in the audit table to a different value; show that the SHA chain breaks.
- Role gating. Log in as a viewer (no edit rights); attempt to
edit; show the audit record is
{action: 'edit.denied', reason: 'rbac'}. - Retention. Show your retention policy: "audit records older than 7 years are archived to S3 with object-lock; nothing is ever deleted."
See also
- Observability - the broader callback surface
- SOC 2 posture
- HIPAA posture
- Demo 35 - Permissions, audit & history