codingstairs
NotesEDULifeContact
⌕Search⌘K
koen

Navigation

  • Intro
  • Blog
  • Life

Get in touch

Send without signing in. Add your email if you'd like a reply.

  • Leave a message anonymously →
  • ✉ warragon112@gmail.com
  • KakaoTalk Open Chat ↗

© 2026 codingstairs

  • Notes
  • EDU
  • Search
  • Life
  • Contact
  • Legal
  • RSS
  • GitHub
Notes›backend

Audit Log — logAdminAction pattern

Published 2026-05-06· Updated 2026-05-18·0 views

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.

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 + pryzeet.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,                    -- 'pryzeet.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 (pryzeet.user · codingstairs.post · da2ari.announcement) makes ILIKE 'pryzeet.%' views convenient per-domain.

3. Helper — fire-and-forget

Blocking the request path on an audit INSERT ties operational traffic to audit write latency. Fire-and-forget, log errors.

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.

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: 'pryzeet.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 'pryzeet.%' under /admin/pryzeet/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
  • resource follows dot-namespacing
  • INSERT failure does not bubble into a 500 (fire-and-forget)
  • 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.

More in backend

All in this category →
  • Wrap public OpenAPIs with your own BFF
  • Email Delivery and OTP — SMTP
  • WebSocket and SSE — real-time communication
  • REST API introduction
  • OpenAPI Specification
  • Crawler ethics and tooling