Audit Trails and Forensics

The difference between 'we think they accessed some records' and 'here are the 1,247 records, with timestamps'.

After containment comes the question everyone asks: what did they actually get? The answer is decided entirely by logging decisions made long before.

What an audit trail must answer

  • Who did it — and the real actor, when someone was impersonating.
  • What they did, to which specific object.
  • When, with a timezone, ordered reliably.
  • From where — IP, user agent, client.
  • What the result was, including denials.
  • What changed — before and after, for mutations.

Audit logs are not application logs

Application logs are for debugging: verbose, short retention, freely writable. Audit logs are evidence: structured, long retention, append-only, and stored where the application cannot rewrite them. Keep them separate.

Read logging

Most systems log writes and not reads, which means "what did they access?" is unanswerable. For sensitive data, log reads too — at minimum the identifier, the actor and the time. It is more volume, and it is the difference between a precise disclosure and a worst-case one.

Retention

Long enough to investigate something discovered late. Breaches are frequently found months after they begin, so 90 days of logs against a six-month-old intrusion answers nothing. One to two years is a common landing point; regulated data is often seven.

Make it queryable

An investigation is a series of questions: everything this actor did, everyone who touched this record, everything from this IP. If those take a day each, the investigation takes weeks. Index for them deliberately.

Example

Example · bash
-- The questions an investigation asks. Index for them deliberately.

-- 1. Everything this actor did
CREATE INDEX ON audit_log (actor_id, at DESC);

-- 2. Everyone who touched this record
CREATE INDEX ON audit_log (target_type, target_id, at DESC);

-- 3. Everything from this address
CREATE INDEX ON audit_log (ip, at DESC);

-- 4. Everything in this window, by type
CREATE INDEX ON audit_log (at DESC, event);

-- 5. Everything in this tenant (for a per-customer disclosure)
CREATE INDEX ON audit_log (tenant_id, at DESC);

-- Without these, each question is a sequential scan of a very large table,
-- and an investigation that should take an hour takes a week.

When to use it

  • A disclosure notice names 1,247 specific records instead of assuming the entire database, because reads were logged.
  • An investigation reconstructs a three-month intrusion because audit retention was two years rather than ninety days.
  • A dispute over an unauthorised change is settled by before-and-after values captured in the audit trail.

More examples

A schema built for investigation

Monthly partitioning turns retention enforcement into a DROP TABLE rather than a DELETE over hundreds of millions of rows that never finishes.

Example · bash
CREATE TABLE audit_log (
    id            BIGSERIAL PRIMARY KEY,
    at            TIMESTAMPTZ NOT NULL DEFAULT now(),

    -- WHO
    actor_id        UUID,
    actor_type      TEXT NOT NULL,          -- user | service | system | anonymous
    impersonated_by UUID,                   -- the REAL actor, when relevant
    tenant_id       UUID,

    -- WHAT
    event         TEXT NOT NULL,            -- from a fixed vocabulary
    target_type   TEXT,
    target_id     TEXT,
    outcome       TEXT NOT NULL,            -- success | denied | error

    -- WHERE
    ip            INET,
    user_agent    TEXT,
    request_id    UUID,
    session_tag   TEXT,                     -- a truncated HASH, never the id

    -- WHAT CHANGED (mutations only, and scrubbed of secrets)
    changes       JSONB,                    -- { field: { from, to } }
    metadata      JSONB,

    -- Tamper evidence
    prev_hash     TEXT,
    hash          TEXT NOT NULL
);

-- Append only. The application role may INSERT and SELECT, nothing else.
REVOKE UPDATE, DELETE ON audit_log FROM app_rw;
CREATE RULE audit_no_update AS ON UPDATE TO audit_log DO INSTEAD NOTHING;
CREATE RULE audit_no_delete AS ON DELETE TO audit_log DO INSTEAD NOTHING;

-- Partition by month: retention becomes DROP PARTITION, and queries prune.
CREATE TABLE audit_log_2026_08 PARTITION OF audit_log
  FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');

-- Investigation indexes
CREATE INDEX ON audit_log (actor_id, at DESC);
CREATE INDEX ON audit_log (target_type, target_id, at DESC);
CREATE INDEX ON audit_log (ip, at DESC);
CREATE INDEX ON audit_log (tenant_id, at DESC);
CREATE INDEX ON audit_log (event, at DESC);
CREATE INDEX ON audit_log USING gin (metadata jsonb_path_ops);

-- Capture before-and-after on mutations, so a dispute has an answer.
--   changes: {"role": {"from": "user", "to": "admin"},
--             "email": {"from": "[email protected]", "to": "[email protected]"}}
-- Scrub secrets first: never a password hash, a token, or a key.

The queries an investigation actually runs

Query 8 is the most revealing: the ratio of denials to successes over time shows both that enumeration happened and precisely when it started working.

Example · bash
-- Write these as saved queries BEFORE you need them. During an incident,
-- nobody wants to compose SQL.

-- 1. Full actor timeline
SELECT at, event, target_type, target_id, outcome, ip
  FROM audit_log
 WHERE actor_id = :actor
   AND at BETWEEN :from AND :to
 ORDER BY at;

-- 2. What was actually accessed, summarised (this drives disclosure)
SELECT target_type, count(*) AS accesses, count(DISTINCT target_id) AS distinct_records,
       min(at) AS first, max(at) AS last
  FROM audit_log
 WHERE actor_id = :actor AND outcome = 'success'
   AND at BETWEEN :from AND :to
 GROUP BY target_type ORDER BY distinct_records DESC;

-- 3. The exact record ids — the difference between "some records" and a list
SELECT DISTINCT target_id
  FROM audit_log
 WHERE actor_id = :actor AND target_type = 'invoice' AND outcome = 'success'
   AND at BETWEEN :from AND :to;

-- 4. Which tenants are affected (per-customer notification)
SELECT tenant_id, count(DISTINCT target_id) AS records
  FROM audit_log
 WHERE actor_id = :actor AND outcome = 'success' AND at BETWEEN :from AND :to
 GROUP BY tenant_id ORDER BY records DESC;

-- 5. When did it really start? (usually earlier than the alert)
SELECT min(at)
  FROM audit_log a
 WHERE a.actor_id = :actor
   AND NOT EXISTS (SELECT 1 FROM known_ips k
                    WHERE k.user_id = a.actor_id AND k.ip = a.ip
                      AND k.first_seen < a.at - interval '7 days');

-- 6. Everyone who touched a specific record
SELECT at, actor_id, impersonated_by, event, outcome, ip
  FROM audit_log
 WHERE target_type = 'invoice' AND target_id = :id
 ORDER BY at;

-- 7. Lateral movement: other accounts from the same addresses
SELECT DISTINCT actor_id
  FROM audit_log
 WHERE ip IN (SELECT DISTINCT ip FROM audit_log WHERE actor_id = :actor)
   AND actor_id <> :actor;

-- 8. Denials before success — the shape of enumeration
SELECT date_trunc('minute', at) AS minute,
       count(*) FILTER (WHERE outcome = 'denied')  AS denied,
       count(*) FILTER (WHERE outcome = 'success') AS succeeded
  FROM audit_log
 WHERE actor_id = :actor AND at BETWEEN :from AND :to
 GROUP BY 1 ORDER BY 1;
-- Many denials then a success is the signature of finding a boundary.

-- 9. Verify the chain over the incident window
SELECT * FROM verify_audit_chain(:from_id);

Logging reads without drowning

Capturing the query parameters alongside the returned ids is the detail that turns an audit trail into a root-cause tool rather than only a damage assessment.

Example · javascript
// Logging every read is prohibitive. Logging none makes disclosure a guess.
// Log by data classification and by access pattern.

const READ_AUDIT_POLICY = {
  // Always — regulated or high-risk data
  always: ['patient_record', 'payment_method', 'national_id', 'medical_note'],

  // Bulk only — individual reads are normal, volume is the signal
  bulkOnly: ['invoice', 'customer', 'order'],
  bulkThreshold: 50,

  // Cross-boundary — reading something outside the actor's usual scope
  crossBoundary: true,

  // Privileged actors — admin and support reads are always interesting
  privilegedActors: ['admin', 'support'],

  // Never — public and non-sensitive
  never: ['product', 'plan', 'public_page'],
};

export async function auditRead(req, { type, ids }) {
  const list = [].concat(ids);
  const should =
    READ_AUDIT_POLICY.always.includes(type) ||
    (READ_AUDIT_POLICY.bulkOnly.includes(type) &&
     list.length >= READ_AUDIT_POLICY.bulkThreshold) ||
    READ_AUDIT_POLICY.privilegedActors.includes(req.user?.role) ||
    req.crossedBoundary;

  if (!should) return;

  // For bulk reads, record the SET rather than one row per record — the ids
  // are what matter, and one entry keeps the volume manageable.
  await audit.record({
    event: 'data.read',
    actor: { id: req.user.id, type: 'user', role: req.user.role,
             impersonatedBy: req.sessionData?.impersonatedBy,
             tenantId: req.user.tenant },
    target: { type, count: list.length },
    context: { ip: req.ip, userAgent: req.get('user-agent'), requestId: req.id },
    metadata: {
      // Cap the stored list; the count is always accurate.
      ids: list.slice(0, 1000),
      truncated: list.length > 1000,
      query: req.query,      // scrubbed — how they found these records
    },
  });
}

// Usage
app.get('/api/invoices', auth, async (req, res) => {
  const rows = await listInvoices(req.user, req.query);
  await auditRead(req, { type: 'invoice', ids: rows.map((r) => r.id) });
  res.json(rows.map(InvoiceSerializer.public));
});

// Storing the query alongside the ids is what lets an investigator reconstruct
// HOW the attacker found the records — which is usually how you find the
// vulnerability they used.

Discussion

  • Be the first to comment on this lesson.