Skip to content
Chapter 81Lesson 3

What belongs in the audit log

The policy layer over your audit log, deciding which privileged actions earn an immutable row, what goes inside, who may read it, and how it survives a GDPR erasure request.

Back in the organizations chapter you shipped the machinery: the auditLogs table, two row-level-security policies that deny UPDATE and DELETE so a row can never be edited once written, and logAudit(tx, event). That helper takes a database transaction as its first parameter, which means the compiler refuses any call that isn’t committed alongside a real mutation. You already called it from the member-management flows: when someone changes a teammate’s role, a row gets written in the same transaction.

So the how is solved. What you don’t have yet is the policy, and the policy is the senior contribution. The table will happily store anything you hand it, so the question that decides whether your audit log is an asset or a liability is one no helper can answer for you: which actions deserve a row, which must never get one, what goes inside, and who is allowed to read it. Err toward logging too much and you bury the one event that mattered under ten thousand that didn’t. Err toward carelessness and you write a customer’s email address into a permanent, un-editable record you legally can’t scrub. This lesson is the answer: a catalog you can hand to a code reviewer, and the artifact the next chapter audits a real codebase against.

You will not write logAudit here. You will read it twice, to anchor four decisions: the test for what earns a row, the set that’s forbidden, the shape of the payload, and the model for who reads the table. Each one resolves to a single sentence you can apply at a review.

Before deciding what goes in, be clear on what the log is for, because every inclusion rule later falls straight out of the jobs it serves. The audit log does three jobs at once, for three different readers.

Forensic

Who did what, when, and from where: the forensic reconstruction during an incident or a dispute. This is the 3am audience, the record you reach for when 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 and asks: 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 the customer can answer “who removed Bob from our team?” without filing a support ticket. The audit log isn’t only an internal tool: part of it is a product feature.

Hold onto the phrase one source, three audiences, the spine of the whole lesson. Every decision ahead, what to include, what shape to store it in, and who gets to read it, traces back to serving these three readers without serving any of them badly.

It’s worth drawing a hard line against something you built one chapter ago, because the two are easy to confuse. Your pino logs are operator-only, ephemeral, and run through a redactor that strips secrets. The audit log is the opposite on every axis: it’s durable (the legal record), it’s partly customer-readable (the Activity feed), and it’s the thing an auditor will subpoena. The code conventions put it bluntly: audit writes go through logAudit, and they are not pino logs: different table, different audience. Keep those two artifacts in separate mental boxes from the start, because half of this lesson’s mistakes come from collapsing them.

Here is the first framework. Resist the urge to start from a list of events, and start from a test instead, because a test you can apply to a new action is worth more than a list that’s already out of date.

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, not just one. Apply it as a single sentence at review:

That’s the same judgment the table was built around; here it’s the rule you apply by hand. With the test in place, the catalog stops being arbitrary. These are the six categories that come up in every SaaS, with the concrete events under each. This is the deliverable, so read it as the reference it’s meant to be.

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

Look at the shape of those names, because the shape is a convention you should hold to. Every one reads entity.verb-pasttense: a single dot, then a hyphenated, past-tense verb, so member.role-changed, not member.role.changed or changeRole. Past tense, because the row records something that already happened and can never un-happen. One dot, because the entity-then-verb split is all the structure you need to group and filter. You’ve already written three of these: member.role-changed with a payload of { before, after }, member.removed with { previousRole }, and org.ownership-transferred with { from, to, demotedTo }. The catalog isn’t a new vocabulary; it’s the names your code already speaks, written down in one place.

And that “one place” is the rule that keeps the catalog alive: adding a new class of privileged action to the codebase means adding a row to this catalog. The catalog is the single, grep-able source of truth for what your system considers worth recording. A new verb that doesn’t fit any of the six categories isn’t a problem to route around quietly; it’s a decision someone makes on purpose, the same way “six seams is a closed set” worked in the error-discipline chapter. Drift here is how an audit log slowly stops meaning anything.

Now run the test yourself. Sort each action into the bucket the inclusion test puts it in. A few of these sit deliberately on the edge, and those are the ones the next section is about, so trust the test 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.

Audit it Passes all three parts of the test
Don't audit it Fails at least one part
Changed a member’s role from member to admin
Issued a refund to a customer
Created a new API key
Viewed the customer list page
Opened the dashboard
A 404 from a mistyped URL
A sign-in attempt that failed on a wrong password
A nightly retention job deleting expired sessions

If the last three felt uncomfortable, good. A failed login feels security-relevant, and a background deletion feels like a state change. That discomfort is exactly what the next section resolves.

The forbidden set deserves its own framework, because beginners fail in one specific direction: they over-log. “Audit everything” sounds responsible but is exactly wrong. It buries the signal you’ll need at 3am under noise, and it bloats a record that’s supposed to be the clean, legal account of privileged action. The discipline cuts both ways, so knowing what to leave out is as much a senior skill as knowing what to keep.

There are three classes that never belong in the audit log. For each one, the important half is the redirect, where it goes instead, because forbidding a thing without giving it a home just leaves a gap somebody fills back in later.

Reads of resources the user may access

Every list view, every detail page the user is allowed to open. These are far too noisy: log them and the forensic signal drowns. Goes to pino / Sentry structured logs, operator-only. One careful 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. Routine read, no. Cross-tenant or privileged read, yes.

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. The rule is sharp: audit successes, not failures. A failed attempt is a security signal for the operator, not a trust record for the customer. Logging every failed login would also let an attacker flood the customer’s Activity feed by typing the wrong password a thousand times.

Internal jobs nobody triggered

A nightly retention sweep, a queue retry. Goes to job history or your observability stack, not the audit table, because there’s no human to attribute it to. The distinction that matters is attributability, not who’s at the keyboard. A job acting on a user’s behalf (the deletion job you’ll meet next lesson) does write rows, with actorUserId: null and a system.* action. A sweep acting on nobody’s behalf does not.

The boundary, in one line: user-attributable and security-relevant, or it does not belong.

Work through the exact confusions one at a time. Each statement targets one of the lines you just drew.

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.

It’s a failed attempt — a security signal, not a trust record. It goes to Sentry tagged unauthorized. Audit successes, not failures; otherwise an attacker floods the customer’s Activity feed by typing the wrong password a thousand times.

Opening an invoice detail page should be audited.

That’s a read of a resource the user is allowed to see. Routine reads go to operator logs (pino / Sentry), never the audit table — log them and the forensic signal drowns in noise.

An admin impersonating a tenant to view their data should be audited.

A privileged, cross-tenant read is the exception. Here the access itself is the security event, so it earns a row — admin.tenant-data-viewed. Routine read, no; cross-tenant or privileged read, yes.

The nightly retention job’s deletions should write audit rows attributed to the users whose data was deleted.

A sweep nobody triggered is job history, not an audit event — and attributing it to a user is a lie. Note the contrast: the on-request deletion job, acting on a user’s behalf, does write rows, with actorUserId: null and a system.* action.

You’ve decided which actions earn a row. Next is the field-level contract: what each column is for, read through the policy lens rather than the schema lens. You declared this table in the organizations chapter, so this isn’t a re-teach of Drizzle column builders or the RLS policy. It’s a second look at the same shape, asking of each field “why does the policy want this”.

Here’s the type the caller hands in, and the columns that actually land in the row.

src/lib/audit.ts
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 |

Notice what the caller is not allowed to pass. The AuditEvent is four fields, action, subjectType, subjectId, and payload, and that’s all. The actor, the IP, the user agent, and the timestamp are all derived by logAudit itself, from the request and the clock. That isn’t an ergonomic shortcut; it’s an integrity property, and it’s worth being precise about why.

Server time is the only time. createdAt defaults to the server’s now() and is never accepted from the client. Think about what would happen if it were: an attacker could backdate their own action to before they had access, or future-date it past the window an investigator is looking at, forging the timeline of the legal record from inside their own request. A client clock is an attacker-controlled clock, so the server’s is the only one that means anything here.

Derive the actor, never trust it. The same logic governs actorUserId. The caller can’t claim to be someone else, because the caller never supplies it: logAudit reads it from the authenticated session. You’ll sometimes see audit designs reach for four conceptual actor kinds: a human user, the system itself, an API key, a webhook. Those are useful as a taxonomy, but be honest about how this schema actually expresses them. There is no actorType enum column. The split is encoded by actorUserId being nullable, where non-null is a human and null is the system, together with the action prefix (system.* rows carry actorUserId: null and record their provenance in the payload). Promoting actorType to a real column is a deliberate future extension, not the shape you ship in year one. Don’t invent the column, because the nullable actor plus the action prefix already carry the distinction.

Read one real call to tie the contract to a row you’ve already written. This is the member.role-changed flow from the organizations chapter. Walk the steps and notice what each piece is doing for the policy, not the API.

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 together or roll back together. There is no path where one lands without the other, and the type signature is what 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 out of 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 whole 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.

1 / 1

Slow down here: this section is the one beginners get wrong most reliably. The payload is where good intentions write secrets and personal data into a record you can never edit afterward.

Start from what the payload is for. It answers one question and only one, in human-readable terms: what changed, or what were this operation’s arguments? That gives you three clean cases:

  • State changes carry { before, after } of only the fields that changed. A role change is { before: 'member', after: 'admin' }, not the whole member row.
  • Action events carry the operation’s arguments. member.invited is { email, role }, because the email is the event, so it belongs.
  • Events where the fact is the whole record carry an empty payload. password.changed is {}: that it happened, by whom, and when is the entire forensic content. There’s nothing to put inside.

One sentence: the payload is a forensic diff, not a request log.

Here’s the mistake and the fix side by side. The wrong version is the one that feels easiest, and it’s a trap on two separate counts.

await logAudit(tx, {
action: 'member.role-changed',
subjectType: 'member',
subjectId: memberId,
payload: rawFormData,
});

Dumps the whole request into a permanent record. Two failures at once: it couples the log to the incidental shape of a form submission, and it imports whatever PII and secrets the request happened to carry into a row you can never scrub.

Here’s the part that overrides what you may have read elsewhere, and it’s the key insight of this whole lesson. The audit payload is not byte-redacted. There is no scrubber running over logAudit stripping out password keys. Confidentiality of the audit log comes from a completely different model: three artifacts, three different protections, and conflating them is the classic beginner error.

Minimize at write time

You put in only the forensic facts, so PII that isn’t part of the event never enters the payload in the first place. account.deletion-requested records the tables being deleted, never the data itself. member.invited records the invitee email, because there the email genuinely is the event. Minimization is a decision about what to write, made at the call site.

Confidentiality by access control at read time

The row is kept from the wrong eyes by the org-isolation RLS policy plus a closely-held read surface, not by mangling the bytes inside. Who can see the row is the control, not what the row 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, and the audit payload never passes through it. Two streams, two mechanisms, so don’t wire one to the other.

Make the distinction quotable, because you’ll reach for it at reviews: redact the operator log; minimize the audit payload; protect the table with RLS. Three artifacts, three protections, and they don’t substitute for each other.

One question to lock it in. 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: rawFormData
payload: fullMemberRow
payload: { before: 'member', after: 'admin' }
payload: {}

You built this defense in the organizations chapter, so this is a re-anchor, not a re-teach: a look at it through the audit-pass lens, asking “what do I grep for to know it’s still intact.” Append-only is the second pillar of the policy. A record anyone could edit is worthless as a legal account, so the table is engineered so that nobody, not even the application, can change a row once it’s written.

The guarantee stands on three independent layers:

  1. Column shape. There is no updatedAt and no deletedAt, no column whose existence invites a mutation. The schema simply offers nothing to change.
  2. Database policy. Two RLS policies with USING (false) deny UPDATE and DELETE to the application’s database role outright. The database refuses the operation even if the application asks.
  3. Application discipline. Only logAudit ever inserts; nothing in the codebase issues an UPDATE or DELETE against auditLogs. The application never asks in the first place.

The mantra from when you built it still holds: the database refuses; the application never asks. Two of those layers are belt and suspenders on purpose, so that if discipline slips and some code tries to edit a row, the database still says no.

That third layer is also a grep. Here’s the audit-pass move, and it’s your contribution to the chapter’s broader audit deliverable: any reference to auditLogs outside logAudit, the migrations, and read paths is a finding. One grep across the codebase, and every hit is either a legitimate read or a bug. This is the same grep-driven discipline you used to verify the rate-limit seams.

There is exactly one sanctioned exception, and naming it honestly is the bridge to the rest of this chapter. A privileged owner-role connection, a separate and more-powerful database credential rather than the everyday application role, can write to the table. It exists for precisely two jobs: legal retraction, and the retention work that runs next lesson, including the anonymization you’re about to meet. It runs outside the application’s USING (false) policy because it connects as a different role, and it is never reachable from a Server Action. This is how “append-only” and “audit rows get anonymized on deletion” are both true at once: the anonymization is a deliberate, architected exception running as a privileged role, not a hole in the wall.

The diagram makes the structure glanceable: three independent layers wrapping the table, and one deliberate arrow piercing all three from the side.

Three independent layers, and one deliberate exception — the owner-role write is architected, not a gap.

The third pillar is the read model, and it has a twist that’s pure senior judgment. The same table serves three readers, each with a different query scope and a different rendering. This is policy, not implementation: the Activity-page Server Component and its query stay out of scope here (you flagged them “not built” back in the organizations chapter). What matters is the model.

Customer admin

Reads their own org’s Activity page. The query is tenant-scoped: 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 is that this read is itself audited: it writes an admin.audit-log-queried row. Reading the most sensitive table in the system is a privileged action, so it earns its own entry, the same as any other.

Compliance officer

Exports a date range for a SOC 2 review, and that export writes its own audit.exported event. Named here, built elsewhere; the point is that the export is itself a recorded action.

That second card is the surprise worth holding onto. Reading the audit log is a privileged action, so reading the audit log gets audited. The most sensitive table in your system keeps a record of who looked at it, including your own operators. There’s no special class of person who reads it invisibly.

There’s a rendering rule that runs across all three readers. The customer-facing feed never reads raw payload; it goes through a formatAuditEvent(event) helper that turns the structured row into a human sentence, “Alice promoted Bob from member to admin,” built from that { before, after } diff. The rule is the UI renders from a formatter, never from raw payload. The reason is the one that bites teams later: render straight from payload and the UI is now coupled to the row’s schema, so the day you reshape a payload, the customer’s Activity page silently breaks. The formatter is the stable seam between the stored shape and the shown string: one source, many renderings. (Localizing those strings comes later, in the internationalization chapter; for now it’s enough that the rendered string is authored for a human and goes through one helper.)

The diagram below is the whole read model in one picture: one table, three reads, and the self-referential loop where the operator’s read writes back into the table it just queried.

auditLogsCustomer adminPlatform operatorCompliance officer org-scopedrendered feedcross-tenant + superadminraw incident viewdate-rangeCSV export writes admin.audit-log-queried
One table, three audiences, and reading it is itself an event.

The last decision is where the audit log collides with privacy law, and it’s a genuine collision, not a detail. This section is policy only: the rule and the why. The job that performs it, the retention timers, and the different shapes deletion can take are all the next lesson, so where the mechanics come up, treat them as a forward reference rather than something to build here.

Here’s the tension. GDPR’s right to erasure says: when a person asks, delete their personal data. Append-only says: audit rows are immutable and can never be deleted. Both are non-negotiable, and they point in opposite directions.

The senior resolution is to see that the two aren’t actually asking for the same thing. Audit entries are anonymized, not deleted. The legal and forensic record of what happened has to survive: you cannot prove compliance with a record you destroyed, and the fact that a role was changed is the organization’s record, not solely the individual’s. What gets removed is the link to the natural person. The event lives; the identity is severed.

Reconciled with the schema you shipped, that happens in two moves:

  1. The foreign key already does half the job. actorUserId is onDelete: 'set null'. Delete the user row and the FK automatically nulls the actor on every audit row that referenced them. You shipped this layer in the organizations chapter without thinking of it as anonymization, but that’s exactly what it is.
  2. The payload is scrubbed of any PII it legitimately held, such as an invitee email in a member.invited event, by the privileged owner-role path (that one sanctioned write from the append-only section), never by the application. And here’s the payoff of the whole payload-shaping rule: because you minimized at write time, there’s almost nothing to scrub. Minimization at write makes anonymization at deletion cheap. The two sections are one decision, paying off twice.

The result: 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. The forensic fact survives; the person doesn’t. One line: anonymize, don’t delete: keep the record, sever the identity.

The job that actually runs this, along with retention timers, the different deletion shapes, and the calls out to third parties, is the very next lesson. This one gives you the rule and the reason; the next one gives you the how.

Pull the lesson into its artifact, the way every lesson in this chapter ends in something grep-able. The audit-log event catalog is a table with a row per event class and these columns: category, action name, subject type, payload shape, retention class. It’s the thing the next chapter audits a seeded codebase against: every privileged action in the code should map to a catalog row, and every catalog row should map to real code.

Here it is, filled in from everything above. The retention column points forward, since the next lesson owns those timers, but it’s included 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 |

The retention classes (identity 2y, billing 7y, privileged-access 7y) are named here so the catalog is complete; the timer that enforces them is the next lesson’s.

Read the catalog as a two-way contract, because that’s how the next chapter’s audit reads it: 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 isn’t documentation that drifts away from the code; it’s the contract the code is checked against, in both directions.

Keep this pass tickable. It’s the same discipline the next chapter runs against a real codebase.

Every privileged human action (a state change, not a read) maps to a catalog row with a canonical entity.verb-pasttense name.
No reads, failed-auth events, or untriggered background jobs are written to the audit table; they go to Sentry / operator logs.
Every payload is a minimized forensic diff or operation args, never a raw request or full row.
auditLogs is referenced only by logAudit, the migrations, and read paths, with no UPDATE / DELETE from the app.
Cross-tenant reads are gated by superadmin and write an admin.audit-log-queried row.
The customer feed renders through formatAuditEvent, never from raw payload.