Audit Log — logAdminAction pattern
In a backend with admin capabilities, capturing who-when-what-why is more than mere tradition. It is how you satisfy privacy law (PIPA · GDPR), investigate incidents, and deter privilege misuse.
Table of contents
In a backend with admin capabilities, capturing who-when-what-why is more than mere tradition. It is how you satisfy privacy law (PIPA · GDPR), investigate incidents, and deter privilege misuse.
1. Four questions an audit log answers
| Axis | Columns | Meaning |
|---|---|---|
| Who | user_id · user_email · ip_address |
Actor. Internal user + (if any) external IP |
| When | created_at TIMESTAMPTZ |
UTC, at least 1 s resolution |
| What | action · resource · resource_id |
DELETE + catalog.user + 12345 |
| Why | details JSONB.reason |
Free text. Destructive or PII-related actions require 30–100 chars |
There is no single standard. Covering all four axes is usually enough for first-pass incident response.
2. Minimal PostgreSQL schema
CREATE TABLE IF NOT EXISTS audit_logs (
id BIGSERIAL PRIMARY KEY,
user_id UUID, -- NULL ok: system / cron
user_email TEXT,
action VARCHAR(40) NOT NULL,
resource VARCHAR(80) NOT NULL, -- 'catalog.user' (dot-namespaced)
resource_id TEXT,
details JSONB NOT NULL DEFAULT '{}'::jsonb,
ip_address INET,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_audit_logs_resource_created
ON audit_logs (resource, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_logs_user_created
ON audit_logs (user_id, created_at DESC);
Prefixing resource with a domain (catalog.user · content.post · profile.announcement) makes ILIKE 'catalog.%' views convenient per-domain.
3. Helper — failure policy by risk
Do not apply one policy to every action. Reads and reversible ordinary changes may isolate audit latency with asynchronous writes. Deletion, permission changes, and initialization must first durably record intent; if that write fails, the mutation does not start.
export function logAdminAction(input: {
action: string;
resource: string;
resourceId?: string;
details?: Record<string, unknown>;
request?: Request;
}): void {
const run = async () => {
const session = await resolveSession(input.request);
const ip = await resolveClientIp(input.request);
await pool.query(
`INSERT INTO audit_logs
(user_id, user_email, action, resource, resource_id, details, ip_address)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[session.userId, session.email, input.action, input.resource,
input.resourceId ?? null, input.details ?? {}, ip]
);
};
run().catch((e) => logger.error('audit_log_failed', e));
}
When request is absent, falling back to Next.js App Router cookies() lets Server Actions still satisfy NOT NULL user_id.
const audited = await logAdminActionDurable({
action: 'DB_INIT_INTENT', resource: 'database', details: { mode }, request,
});
if (!audited) return { status: 503, mutationApplied: false };
If result audit fails, work may already have applied. Return neither generic success nor an ambiguous 500. Expose mutationApplied: true, auditFailed: true, and a warning against blind retries.
4. Why enforce reason
Deletions of personal data, permission changes, manual point adjustments — actions that matter for later forensics — should require a 30–100 char reason. Enforce at the API layer (reject), store under details.reason JSONB key.
if (destructive && (!reason || reason.length < 30)) {
return NextResponse.json({ error: 'reason 30+ chars' }, { status: 400 });
}
logAdminAction({
action: 'DELETE',
resource: 'catalog.user',
resourceId: String(userId),
details: { reason, targetEmail },
});
Empty or two-character reasons make post-hoc analysis impossible; rejecting at the API layer is cheaper than cleaning up later.
5. Viewer UI
- Filters:
resource·action·user_email· date range - Sort:
created_at DESC - Pagination: offset or keyset (keyset wins at scale)
- Per-domain views: fix
resource ILIKE 'catalog.%'under/admin/catalog/audit-log
6. Gotchas
System actions with NULL user_id — cron · backups · webhooks have no human actor. Allow NULL + sentinel user_email = 'system@internal'.
Audit table growth — millions of rows per year. Partition (created_at monthly) or archive.
Schema-less JSONB details — flexibility is a plus, but required keys (reason · previous_value · new_value) are easier to search if enforced in code-level helpers.
Deleting audit records — do not build a "delete audit" feature. For privacy requests, anonymize (user_email = NULL).
Missing actor fallback — Server Actions and scheduled jobs may not have a request object. Cookie-based fallback prevents NOT NULL violations — freeze it as a regression test.
7. Operational checklist
- Every mutation API · Server Action calls
logAdminAction - Destructive / PII actions enforce 30+ char reason
-
resourcefollows dot-namespacing - Ordinary changes isolate audit outages; high-risk actions do not start when intent audit fails
- Result-audit failure exposes possible application and forbids blind retry
- Viewer is behind admin auth
- Archive / partition plan exists
Closing
An audit log is not a tool used only during incidents; it is the first answer to everyday questions like "why did this user receive these points yesterday?". Without a reason field, audits answer when-what but never why. Treat reason as a required column from day one.
Next
- api-handler-pattern
- security/01-jwt-rotation
References: OWASP Logging Cheat Sheet · PostgreSQL JSONB.
Terms in this content
More in backend
All in this category →Related posts
pgvector and RAG
The trend of combining large language model responses with external knowledge has settled under the name RAG (Retrieval-Augmented Generation). At its core sits vector similarity search, and PostgreSQL's pgvector extensi…
Supabase
Supabase emerged as an alternative to Firebase, an open-source backend bundle. PostgreSQL sits at the core, with auth, storage, realtime, and serverless functions stacked around it.
Backup and Restore
Making data is not a backup. Restoring is. The difference only shows up after an incident. Here is a summary of PostgreSQL's tools, policies, and rehearsals.
PostgreSQL First
Choosing a data store is often a burden. NoSQL, distributed SQL, graph DBs, time-series DBs all line up. Yet for many projects, PostgreSQL alone is enough for a long time at the start.