Auditing Permission Changes With an Append-Only Log

A SOC 2 auditor asks who granted the billing:refund permission to a contractor six weeks ago — and your UPDATE-in-place user_roles table has no answer because the row was overwritten the moment the grant was revoked. This page is part of the Designing Role-Based Access Control Systems guide, and it solves that exact gap: it shows how to capture every permission and role mutation in an immutable, hash-chained event log that survives revocation, resists tampering, and can be replayed to reconstruct the authorization state at any past instant.

The normalized RBAC schema you store in PostgreSQL is mutable by design — it answers “what can this user do right now.” An audit log answers a different, compliance-critical question: “what changed, who changed it, when, and can you prove the record was not altered after the fact.” These are separate concerns and they want separate stores.

Root Cause: Mutable State Has No Memory

The default RBAC tables — roles, permissions, role_permissions, user_role_assignments — are current-state projections. An UPDATE or DELETE destroys the prior value. Even an updated_at column only tells you when the last change happened, not what it changed from, who triggered it, or whether anything was skipped. Three properties are missing:

  • Attribution. The acting principal (a human admin, a provisioning job, a SCIM sync) is not bound to the change.
  • Immutability. Anyone with UPDATE/DELETE on the audit table — including a compromised app credential — can rewrite history.
  • Verifiability. Even an append-only table proves nothing if a privileged attacker can DELETE a row and renumber the rest. You need cryptographic evidence that the sequence is intact.

The fix is an event-sourced log: instead of mutating state, you append a permanent, ordered record of each intentpermission_granted, role_revoked, role_permission_added. The current-state tables become a derived read model you can rebuild from the log.

The Hash Chain

Tamper-evidence comes from chaining: each event stores a hash computed over its own payload plus the hash of the previous event. Altering or deleting any historical row breaks every hash downstream, so a single periodic verification pass detects tampering anywhere in the chain. This is the same Merkle-style linkage that backs certificate transparency logs and Git commit history.

flowchart LR
    E0["event 0\nseed\nhash h0"]:::store --> E1["event 1\ngrant billing:refund\nhash h1 = H(h0 + p1)"]:::store
    E1 --> E2["event 2\nrevoke billing:refund\nhash h2 = H(h1 + p2)"]:::store
    E2 --> E3["event 3\nadd role admin\nhash h3 = H(h2 + p3)"]:::store
    E3 -.tamper here.-> X["edit p2\nbreaks h2..h3"]:::threat
    classDef store  fill:#fffbec,stroke:#d4840a,stroke-width:2px,color:#1a1614
    classDef threat fill:#fff0ee,stroke:#c0392b,stroke-width:2px,color:#1a1614

Step 1: The Immutable Event Table

Model the log as an append-only table with a monotonic sequence, a structured event payload, the acting principal, and the chain hash. Store it in a dedicated schema (or a separate database) so application credentials never receive write access beyond INSERT.

CREATE SCHEMA IF NOT EXISTS audit;

CREATE TABLE audit.permission_events (
  seq            bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  event_id       uuid        NOT NULL DEFAULT gen_random_uuid(),
  tenant_id      uuid        NOT NULL,
  event_type     text        NOT NULL,          -- permission_granted | role_revoked | ...
  subject_id     uuid        NOT NULL,          -- the user/role being changed
  payload        jsonb       NOT NULL,          -- full before/after detail
  actor_id       uuid        NOT NULL,          -- WHO made the change
  actor_kind     text        NOT NULL,          -- human | service | scim_sync
  actor_ip       inet,
  occurred_at    timestamptz NOT NULL DEFAULT now(),
  prev_hash      bytea       NOT NULL,
  entry_hash     bytea       NOT NULL,
  CONSTRAINT event_type_known CHECK (event_type IN (
    'permission_granted','permission_revoked',
    'role_assigned','role_revoked',
    'role_permission_added','role_permission_removed'
  ))
);

-- Compliance query support
CREATE INDEX idx_pe_subject ON audit.permission_events (tenant_id, subject_id, occurred_at);
CREATE INDEX idx_pe_actor   ON audit.permission_events (tenant_id, actor_id, occurred_at);

Enforce immutability at the database layer, not just in application code. A trigger that rejects UPDATE and DELETE means even a SQL-injection foothold on the app role cannot rewrite history.

CREATE OR REPLACE FUNCTION audit.reject_mutation() RETURNS trigger AS $$
BEGIN
  RAISE EXCEPTION 'audit.permission_events is append-only (%-attempt blocked)', TG_OP;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER no_update BEFORE UPDATE ON audit.permission_events
  FOR EACH ROW EXECUTE FUNCTION audit.reject_mutation();
CREATE TRIGGER no_delete BEFORE DELETE ON audit.permission_events
  FOR EACH ROW EXECUTE FUNCTION audit.reject_mutation();

-- The application role can append but never mutate.
GRANT INSERT, SELECT ON audit.permission_events TO app_writer;
REVOKE UPDATE, DELETE ON audit.permission_events FROM app_writer;

Step 2: Appending Chained Events in TypeScript

Every permission mutation in your service must funnel through a single appendEvent function so nothing bypasses the log. Compute the entry hash over a canonical (stable-key-order) serialization — otherwise re-serialization during verification produces a different digest and false-positive tamper alarms.

import { createHash } from "node:crypto";
import type { Pool } from "pg";

type EventType =
  | "permission_granted" | "permission_revoked"
  | "role_assigned"      | "role_revoked"
  | "role_permission_added" | "role_permission_removed";

interface PermissionEvent {
  tenantId: string;
  eventType: EventType;
  subjectId: string;
  payload: Record<string, unknown>;
  actorId: string;
  actorKind: "human" | "service" | "scim_sync";
  actorIp?: string;
}

// Canonical JSON: sorted keys so the hash is reproducible.
function canonical(value: unknown): string {
  if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
  if (value && typeof value === "object") {
    const obj = value as Record<string, unknown>;
    return `{${Object.keys(obj).sort()
      .map((k) => `${JSON.stringify(k)}:${canonical(obj[k])}`)
      .join(",")}}`;
  }
  return JSON.stringify(value ?? null);
}

const GENESIS = Buffer.alloc(32, 0); // 32 zero bytes seed the chain

export async function appendEvent(pool: Pool, e: PermissionEvent): Promise<void> {
  // SERIALIZABLE prevents two concurrent appends from chaining off the same prev_hash.
  const client = await pool.connect();
  try {
    await client.query("BEGIN ISOLATION LEVEL SERIALIZABLE");

    const { rows } = await client.query<{ entry_hash: Buffer }>(
      "SELECT entry_hash FROM audit.permission_events ORDER BY seq DESC LIMIT 1"
    );
    const prevHash = rows[0]?.entry_hash ?? GENESIS;

    const body = canonical({
      tenantId: e.tenantId, eventType: e.eventType, subjectId: e.subjectId,
      payload: e.payload, actorId: e.actorId, actorKind: e.actorKind,
      occurredAt: new Date().toISOString(),
    });
    const entryHash = createHash("sha256")
      .update(prevHash)
      .update(Buffer.from(body, "utf8"))
      .digest();

    await client.query(
      `INSERT INTO audit.permission_events
         (tenant_id, event_type, subject_id, payload,
          actor_id, actor_kind, actor_ip, prev_hash, entry_hash)
       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
      [e.tenantId, e.eventType, e.subjectId, e.payload,
       e.actorId, e.actorKind, e.actorIp ?? null, prevHash, entryHash]
    );
    await client.query("COMMIT");
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release();
  }
}

Call it from the same transaction boundary as the mutation it records. When you grant a permission, write the current-state row and append the event together; a partial failure must roll back both so the log never disagrees with the read model.

Step 3: Separating the Audit Store

Keeping the log in the same database as your mutable RBAC tables is the convenient default, but it shares a blast radius: a credential that can drop your app schema can also drop the evidence. For regulated workloads, separate the store along one of these axes:

Strategy Tamper resistance Operational cost When to use
Same DB, separate schema + triggers Medium — relies on role grants Low Most SaaS; the baseline above
Dedicated audit database, write-only role High — separate credential boundary Medium SOC 2 / ISO 27001 scope
Append to managed ledger (QLDB, immudb) Very high — vendor-cryptographic proof Higher Strict regulatory or financial audit
Periodic anchor: publish chain head hash externally Very high — detects full-DB rewrite Low Layer on top of any of the above

The cheapest high-assurance upgrade is the last row: every hour, write the latest entry_hash to an append-only external sink (an object-lock S3 bucket, a transparency log, or even a signed Slack/email). An attacker who rewrites the entire table still cannot reproduce a head hash you already published off-system.

Step 4: Verifying the Chain

Verification recomputes each hash from the genesis seed forward and stops at the first mismatch. Run it as a scheduled job and on demand before any compliance export.

import { createHash } from "node:crypto";
import type { Pool } from "pg";

export async function verifyChain(pool: Pool): Promise<
  { ok: true } | { ok: false; brokenAtSeq: string }
> {
  const { rows } = await pool.query(
    `SELECT seq, tenant_id, event_type, subject_id, payload,
            actor_id, actor_kind, occurred_at, prev_hash, entry_hash
       FROM audit.permission_events ORDER BY seq ASC`
  );

  let expectedPrev = Buffer.alloc(32, 0);
  for (const r of rows) {
    const body = canonical({
      tenantId: r.tenant_id, eventType: r.event_type, subjectId: r.subject_id,
      payload: r.payload, actorId: r.actor_id, actorKind: r.actor_kind,
      occurredAt: new Date(r.occurred_at).toISOString(),
    });
    const recomputed = createHash("sha256")
      .update(expectedPrev).update(Buffer.from(body, "utf8")).digest();

    if (!recomputed.equals(r.entry_hash) || !expectedPrev.equals(r.prev_hash)) {
      return { ok: false, brokenAtSeq: String(r.seq) };
    }
    expectedPrev = r.entry_hash;
  }
  return { ok: true };
}

Step 5: Querying for Compliance and Least-Privilege Reviews

Because every change is preserved with its actor, the queries auditors actually ask become trivial SQL — no log-aggregation pipeline required.

-- Who granted/revoked anything for a given user, ever?
SELECT occurred_at, event_type, actor_id, actor_kind, actor_ip,
       payload->>'permission' AS permission
FROM audit.permission_events
WHERE tenant_id = $1 AND subject_id = $2
ORDER BY occurred_at DESC;

-- Quarterly least-privilege review: every grant still in effect that was
-- issued by a now-deactivated admin (a classic privilege-creep finding).
SELECT pe.subject_id, pe.payload->>'permission' AS permission, pe.occurred_at
FROM audit.permission_events pe
WHERE pe.tenant_id = $1
  AND pe.event_type = 'permission_granted'
  AND pe.actor_id = ANY($2::uuid[])      -- deactivated admin ids
  AND NOT EXISTS (
    SELECT 1 FROM audit.permission_events later
    WHERE later.tenant_id = pe.tenant_id
      AND later.subject_id = pe.subject_id
      AND later.event_type = 'permission_revoked'
      AND later.payload->>'permission' = pe.payload->>'permission'
      AND later.seq > pe.seq
  );

This directly supports the SOC 2 CC6.1/CC6.3 evidence of restricting and reviewing access, and the OWASP ASVS V4 requirement for an audit trail of authorization changes.

Step 6: Replaying to Reconstruct State

Because the log is the source of truth, you can fold events forward to rebuild the exact permission set as of any timestamp — for incident forensics (“what could this account do when the breach happened?”) or to rebuild a corrupted read model.

interface AuthzState {
  // subjectId -> set of permission strings
  perms: Map<string, Set<string>>;
}

export function replayUntil(
  events: Array<{ event_type: string; subject_id: string; payload: any }>,
): AuthzState {
  const state: AuthzState = { perms: new Map() };
  const setFor = (id: string) =>
    state.perms.get(id) ?? state.perms.set(id, new Set()).get(id)!;

  for (const ev of events) {
    const perm = ev.payload?.permission as string | undefined;
    switch (ev.event_type) {
      case "permission_granted":
        if (perm) setFor(ev.subject_id).add(perm);
        break;
      case "permission_revoked":
        if (perm) setFor(ev.subject_id).delete(perm);
        break;
      case "role_revoked":
        state.perms.delete(ev.subject_id);
        break;
    }
  }
  return state;
}

Fetch events with WHERE occurred_at <= $asOf ORDER BY seq ASC and the fold yields the historical state deterministically.

Prevention & Monitoring Hooks

  • Scheduled chain verification. Run verifyChain hourly; page on any ok: false and capture brokenAtSeq for forensics.
  • External anchoring. Publish the head entry_hash to an out-of-band, object-locked sink on a fixed cadence.
  • Write-path coverage tests. Assert in CI that every RBAC mutation path calls appendEvent — an unaudited code path is the real failure mode.
  • Sequence-gap alerts. Alert if seq is non-contiguous; combined with the DELETE trigger this surfaces tampering attempts.
  • Actor sanity. Flag actor_kind = 'human' grants of high-risk permissions outside business hours for review, and pair this with middleware that blocks privilege escalation at the API edge.

Frequently Asked Questions

Why a hash chain instead of just an append-only table with revoked write grants?

Revoking UPDATE/DELETE stops the application credential from rewriting history, but a database superuser, a backup restore, or an attacker who escalates to the DB owner can still alter rows. The hash chain adds evidence: any change to a historical payload changes its entry_hash, which invalidates every subsequent hash. Combined with an externally anchored head hash, you can prove tampering occurred even if the attacker had full database access.

Won't computing a chain hash on every write serialize all my permission changes?

Yes — appends are serialized by design, because each entry depends on the previous entry_hash. In practice permission changes are low-volume (admin actions, provisioning syncs), not request-path traffic, so a SERIALIZABLE insert is fine. If you ever need higher throughput, partition the chain per tenant so each tenant’s log is an independent chain, or batch events and chain them in groups.

Do I still need the current-state RBAC tables if I have the log?

Yes. The log is optimized for history and proof, not for the sub-millisecond “can this user do X right now” lookups your authorization middleware needs on every request. Keep the normalized tables as the read model — see how to structure RBAC tables in PostgreSQL — and treat them as a projection you can always rebuild by replaying the log.

How do I record the acting principal for automated changes like a SCIM sync?

Set actor_kind = 'service' or 'scim_sync' and use a stable service-account actor_id. For SCIM, also stash the upstream identity provider request id in payload so you can trace a change back to the directory operation that triggered it. Never leave actor_id null — an unattributed change is an audit finding in itself.

What happens to the log under GDPR right-to-erasure if it contains user identifiers?

Store only pseudonymous identifiers (internal UUIDs) in the chained payload, never raw PII like email or name, and keep the UUID-to-PII mapping in a separate, erasable table. Deleting the mapping satisfies erasure while leaving the hash chain intact — the audit record survives as “subject 7f3a…” with no recoverable personal data, preserving both compliance obligations.