Forensic
Who did what, when, and from where: the forensic reconstruction you reach for during an incident or a dispute, once something has already gone wrong and you need the truth.
The policy over your audit log: which privileged actions earn an immutable row, what goes inside, who may read it, and how it survives a GDPR erasure request.
In the organizations chapter you shipped the machinery: the auditLogs table, row-level-security policies that deny UPDATE and DELETE so a row can never change once written, and logAudit(tx, event), which takes a database transaction first so a row only lands alongside a real mutation. The member flows already call it: change a teammate’s role, and a row is written in the same transaction.
The how is solved; the policy is not, and no helper can write it for you. The table stores anything you hand it, so four decisions decide whether the log helps or hurts: which actions earn a row, which must never get one, what goes inside, and who may read it. Log too much and the one event that mattered drowns under ten thousand that didn’t; log carelessly and you write a customer’s email into a record you legally can’t scrub. This lesson turns each decision into a rule you can apply at code review, and builds the catalog the next chapter audits against.
Every inclusion rule falls out of what the log is for, and it does three jobs at once, for three different readers.
Forensic
Who did what, when, and from where: the forensic reconstruction you reach for during an incident or a dispute, once something has already gone wrong and you need the truth.
Compliance
SOC 2 and GDPR both want an immutable identity-and-access record. An auditor reads it by date range: can you prove who could see this customer’s data, and when access changed?
Product trust
A customer-facing “Activity” feed reads the same table, so a customer can answer “who removed Bob from our team?” without a support ticket. Part of the audit log is a product feature.
Keep the phrase one source, three audiences: what to include, what shape to store it in, and who may read it all trace back to serving these three readers without serving any of them badly.
Don’t confuse this with the pino logs from one chapter ago. Pino logs are operator-only, ephemeral, and run through a redactor that strips secrets. The audit log is the opposite on every axis: durable (the legal record), partly customer-readable (the Activity feed), and the thing an auditor will subpoena. Audit writes go through logAudit, to a different table and a different audience.
Start from a test, not a list: a test applies to a new action, while a list goes stale the moment you write it.
An action earns an audit row when it is (a) attributable to a human, (b) security- or trust-relevant, and (c) a state change, not a read. All three must hold.
Six categories cover nearly every web app, with the concrete events under each.
Identity
auth.signed-in, auth.signed-out, auth.signed-up, password.changed, password.reset, mfa.enrolled, mfa.removed, session.revoked
Membership & RBAC
member.invited, member.joined, member.removed, member.role-changed, org.ownership-transferred, invitation.revoked
Billing
subscription.created, subscription.canceled, plan.changed, payment-method.added, payment-method.removed, refund.issued
Privileged data access
export.started, export.completed, admin.tenant-data-viewed, records.bulk-deleted, account.deletion-requested, account.deletion-completed
Configuration
api-key.created, api-key.revoked, webhook-endpoint.added, sso.settings-changed, security-setting.changed
Tenant lifecycle
org.created, org.deleted, org.transferred
Every name follows one convention: entity.verb-pasttense, so member.role-changed, not member.role.changed or changeRole. The verb is past tense because the row records what already happened; the single dot splits entity from verb, enough structure to group and filter. You’ve already written three: member.role-changed, member.removed, and org.ownership-transferred. The catalog is those names, your code’s own vocabulary, in one place.
Keeping it in one place keeps it honest: every new class of privileged action means a new row here, so the catalog stays the single grep-able source of truth. A verb that fits none of the six categories is a deliberate decision, not a case to route around quietly. Drift is how an audit log slowly stops meaning anything.
Now run the test yourself. A few actions sit deliberately on the edge, the subject of the next section, so trust it and notice where it pulls against your instinct.
Run the inclusion test — attributable to a human, security- or trust-relevant, a state change not a read — and sort each action. Drag each item into the bucket it belongs to, then press Check.
If the last three felt uncomfortable, good: a failed login feels security-relevant, and a background deletion feels like a state change. The next section resolves that.
Beginners fail in one direction: they over-log. “Audit everything” sounds responsible, but it buries the one event you need and bloats a record meant to be a clean, legal account of privileged action.
Three classes never belong in the audit log. For each, name where it goes instead, or someone wires it back in later.
Reads of resources the user may access
Every list view, every detail page the user is allowed to open. Goes to pino / Sentry structured logs, operator-only. The exception: a privileged read, such as an admin impersonating a tenant or a data export, is audited, because there the access itself is the security event.
Failed authorization and failed auth
A rejected sign-in, a cross-tenant attempt, a request refused for too low a role. Goes to Sentry, tagged with a code like cross_tenant_attempt or unauthorized. A failed attempt is a security signal, not a trust record for the customer.
Internal jobs nobody triggered
A nightly retention sweep, a queue retry. Goes to job history or your observability stack, not the audit table: there’s no human to attribute it to. A job acting on a user’s behalf, such as the on-request deletion job in the next lesson, does write rows, with actorUserId: null and a system.* action.
The boundary, in one line: user-attributable and security-relevant, or it does not belong.
Each claim is about what does and doesn't earn an audit row. Mark each statement True or False.
A sign-in attempt that fails on a wrong password should be written to the audit log.
unauthorized. Log it and an attacker can flood the customer’s Activity feed by typing the wrong password a thousand times.Opening an invoice detail page should be audited.
pino / Sentry), never the audit table.An admin impersonating a tenant to view their data should be audited.
admin.tenant-data-viewed.The nightly retention job’s deletions should write audit rows attributed to the users whose data was deleted.
actorUserId: null and a system.* action.You’ve decided which actions earn a row; now decide what each column is for. You built this table back in the organizations chapter, so the question isn’t how the columns work but why the policy wants each one.
type AuditEvent = { action: string; subjectType?: string; subjectId?: string; payload?: Record<string, unknown>;};| Column | Type | What it’s for (policy lens) |
|---|---|---|
id | uuid | the row’s own identity |
organizationId | uuid | the tenant the event belongs to, the boundary RLS enforces |
actorUserId | uuid, nullable | the human who acted; null means a system actor (FK onDelete: 'set null') |
actorIp | text | where the action came from, the forensic “from where” |
actorUserAgent | text (truncated 512) | the client used, forensic context |
action | text | the canonical catalog name |
subjectType | text | what kind of thing was acted on (member, invoice) |
subjectId | text | which specific one |
payload | jsonb | the forensic detail, shaped, not dumped (next section) |
createdAt | timestamptz | server now(), never client-supplied |
AuditEvent is those four fields and nothing else. The actor, IP, user agent, and timestamp aren’t the caller’s to set; logAudit derives them from the request and the clock. That’s an integrity property, not a convenience, and two cases are worth stating precisely.
Server time is the only time. createdAt is the server’s now(), never the client’s. A client-supplied timestamp could backdate an action to before the attacker had access, or future-date it past the window an investigator is searching, forging the timeline from inside the request.
Derive the actor, never trust it. The caller can’t pass actorUserId, so it can’t claim to be someone else; logAudit reads it from the authenticated session. There is no actorType column: actorUserId itself encodes the split, non-null for a human and null for the system, alongside the action prefix. A system.* row carries actorUserId: null and records its provenance in the payload.
Now tie the contract to a row. This is the member.role-changed flow from the organizations chapter; watch what each piece does for the policy.
await withTenant(orgId, async (tx) => { await tx .update(orgMembers) .set({ role: nextRole }) .where(eq(orgMembers.id, memberId));
await logAudit(tx, { action: 'member.role-changed', subjectType: 'member', subjectId: memberId, payload: { before: currentRole, after: nextRole }, });});withTenant opens the transaction and hands it in as tx. Passing that same tx to logAudit makes the mutation and the audit row commit or roll back together, with no path where one lands without the other. The type signature enforces it.
await withTenant(orgId, async (tx) => { await tx .update(orgMembers) .set({ role: nextRole }) .where(eq(orgMembers.id, memberId));
await logAudit(tx, { action: 'member.role-changed', subjectType: 'member', subjectId: memberId, payload: { before: currentRole, after: nextRole }, });});The action is a name straight from the catalog. If it isn’t in the catalog it shouldn’t be here, and if it’s here it must be in the catalog.
await withTenant(orgId, async (tx) => { await tx .update(orgMembers) .set({ role: nextRole }) .where(eq(orgMembers.id, memberId));
await logAudit(tx, { action: 'member.role-changed', subjectType: 'member', subjectId: memberId, payload: { before: currentRole, after: nextRole }, });});What was acted on: the specific member row. subjectType and subjectId answer “on what” without dumping the record.
await withTenant(orgId, async (tx) => { await tx .update(orgMembers) .set({ role: nextRole }) .where(eq(orgMembers.id, memberId));
await logAudit(tx, { action: 'member.role-changed', subjectType: 'member', subjectId: memberId, payload: { before: currentRole, after: nextRole }, });});The forensic diff: only the field that changed, before and after. Not the request, not the row, just the change. The next section covers this in full.
The payload is where good intentions write secrets and personal data into a row you can never edit, so get it right.
The payload answers one question in human-readable terms: what changed, or what were this operation’s arguments? That gives three cases:
{ before, after } of only the fields that changed. A role change is { before: 'member', after: 'admin' }, not the whole member row.member.invited is { email, role }, because the email is the event.password.changed is {}: that it happened, by whom, and when is the entire forensic content.It is a forensic diff, not a request log. The wrong version is the one that feels easiest:
await logAudit(tx, { action: 'member.role-changed', subjectType: 'member', subjectId: memberId, payload: rawFormData,});Dumps the whole request into a permanent record. It couples the log to the incidental shape of a form submission and imports whatever PII and secrets the request carried into a row you can never scrub.
await logAudit(tx, { action: 'member.role-changed', subjectType: 'member', subjectId: memberId, payload: { before: currentRole, after: nextRole },});Stores exactly the forensic facts. Minimal, stable, decoupled from the request shape: the only thing inside is the change itself.
The audit payload is not byte-redacted. No scrubber runs over logAudit stripping password keys. Confidentiality comes from three separate artifacts, each with its own protection:
Minimize at write time
You put in only the forensic facts, so PII that isn’t part of the event never enters the payload. account.deletion-requested records the tables being deleted, never the data itself. It is a decision about what to write, made at the call site.
Access control at read time
The org-isolation RLS policy plus a closely-held read surface keep the row from the wrong eyes. The control is who can see the row, not what it contains.
The redactor is a different artifact
The pino / Sentry redactor from the error-discipline chapter strips password, token, secret, and PII keys from the operator log stream. It does not wrap logAudit; the audit payload never passes through it. Two streams, two mechanisms.
Given a role change, which payload is the right one?
A member.role-changed event fires: someone was promoted from member to admin. Which payload should the logAudit call carry?
payload: rawFormDatapayload: fullMemberRowpayload: { before: 'member', after: 'admin' }payload: {}password.changed), but here the two role values are the forensic point, so omitting them throws away the only detail worth keeping.You built this defense in the organizations chapter; here you confirm it through the audit lens. A record anyone could edit is worthless as a legal account, so the table is engineered so nobody, not even the application, can change a row once it’s written.
The guarantee stands on three independent layers:
updatedAt, no deletedAt, no column whose existence invites a mutation. The schema offers nothing to change.USING (false) deny UPDATE and DELETE to the application’s database role. The database refuses even if the application asks.logAudit ever inserts; nothing in the codebase issues an UPDATE or DELETE against auditLogs. The application never asks in the first place.The mantra still holds: the database refuses; the application never asks. Two layers are belt and suspenders on purpose: if discipline slips and some code tries to edit a row, the database still says no.
The third layer is a grep, and it’s your contribution to the chapter’s audit deliverable: any reference to auditLogs outside logAudit, the migrations, and read paths is a finding. Every hit is either a legitimate read or a bug.
There is exactly one sanctioned exception: a privileged owner-role connection, a separate and more powerful credential than the everyday application role. It runs outside the USING (false) policy because it connects as a different role, it is never reachable from a Server Action, and it exists for two jobs only, legal retraction and the retention work next lesson. That reconciles “append-only” with “audit rows get anonymized on deletion”: anonymization is a deliberate exception running as a privileged role, not a hole in the wall.
only logAudit() inserts the application never asks USING (false) denies UPDATE / DELETE the database refuses no updatedAt / deletedAt nothing to mutate One table serves three readers, each with its own query scope and rendering. This is policy, not implementation: the Activity-page query lives in the organizations chapter.
Customer admin
Reads their own org’s Activity page. The query is tenant-scoped, and the org-isolation RLS policy already draws that boundary, so a customer physically cannot read another tenant’s rows. They see a rendered feed, never raw rows.
Platform operator
Reads cross-tenant during an incident, gated by the superadmin role. The twist: this read is itself audited, writing an admin.audit-log-queried row. Reading the most sensitive table in the system is a privileged action, so it earns its own entry.
Compliance officer
Exports a date range for a SOC 2 review, writing an audit.exported event. Built elsewhere, but the export is a recorded action too.
The operator card holds the idea worth keeping: the most sensitive table in your system records who looked at it, your own operators included. No one reads it invisibly.
One rule spans all three readers: the UI renders from a formatter, never from raw payload. A formatAuditEvent(event) helper turns each structured row into a human sentence, “Alice promoted Bob from member to admin,” built from the { before, after } diff. Render straight from payload and the UI couples to the row’s schema, so reshaping a payload silently breaks the Activity page. The formatter is the stable seam between the stored shape and the shown string.
The last decision is where the audit log collides with privacy law. This is the rule and the reason; the next lesson builds the job that carries it out.
GDPR’s right to erasure says you delete a person’s personal data on request. Append-only says audit rows are immutable and never deleted. Both are non-negotiable, and they point in opposite directions.
They aren’t asking for the same thing, though. Audit entries are anonymized, not deleted. The record of what happened has to survive: you cannot prove compliance with a record you destroyed, and that a role changed is the organization’s record, not only the individual’s. What gets removed is the link to the person.
Against the schema you shipped, that happens in two moves:
actorUserId is onDelete: 'set null', so deleting the user row nulls the actor on every audit row that referenced them. You shipped this in the organizations chapter without calling it anonymization, but that is what it is.member.invited event, through the privileged owner-role path, never the application. Because you minimized at write time, there is almost nothing to scrub.The row stays (“a role was changed from member to admin at 14:03”), the actor is null or a stable hash, and the PII is gone. Anonymize, don’t delete: keep the record, sever the identity.
The lesson’s artifact is the audit-log event catalog: a grep-able table with one row per event class, with columns for category, action name, subject type, payload shape, and retention class. Every privileged action in the code should map to a catalog row, and every catalog row should map to real code. The retention column belongs to the next lesson, which owns those timers; it is here so the catalog is complete.
| Category | Action | Subject | Payload shape | Retention |
|---|---|---|---|---|
| Identity | auth.signed-in | user | {} | 2y |
| Identity | password.changed | user | {} | 2y |
| Membership | member.invited | member | { email, role } | 2y |
| Membership | member.role-changed | member | { before, after } | 2y |
| Membership | member.removed | member | { previousRole } | 2y |
| Membership | org.ownership-transferred | org | { from, to, demotedTo } | 2y |
| Billing | subscription.created | subscription | { plan } | 7y |
| Billing | refund.issued | payment | { amount, reason } | 7y |
| Privileged access | admin.tenant-data-viewed | org | { reason } | 7y |
| Privileged access | account.deletion-requested | user | { tables } | 7y |
| Privileged access | account.deletion-completed | user | { tablesPurged, externalsPurged, durationMs } | 7y |
| Configuration | api-key.created | api-key | { name, scopes } | 7y |
Read the catalog in both directions: a privileged action with no catalog row is a missing audit; a catalog row with no code behind it is dead policy. Both are findings. The catalog is not documentation that drifts from the code; it is the contract the code is checked against.
Run this checklist as the final pass.
entity.verb-pasttense name.auditLogs is referenced only by logAudit, the migrations, and read paths, with no UPDATE / DELETE from the app.superadmin and write an admin.audit-log-queried row.formatAuditEvent, never from raw payload.What security events to record and — just as important — what never to log.
The legal text behind anonymize-don't-delete: when erasure applies and the carve-outs that let a record persist.
The Trust Services controls (CC6.1, CC7.2/7.3, CC8.1) that hang on the audit log, mapped to what each one expects you to record.