Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions docs/tamper-evident-audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Tamper-evident privileged audit records

## Guarantees

Privileged state changes are represented by rows in `audit_logs`. Each row
contains the actor, tenant, target resource, outcome, correlation ID, redacted
before/after details, and a timestamp. A row also stores:

- `sequence_no`, assigned by the database;
- `previous_hash`, the integrity hash of the previous row; and
- `integrity_hash`, a SHA-256 digest of the canonical row payload and
`previous_hash`.

The chain is global to the audit table. Tenant filtering is applied only when
reading records; it never changes the chain order or lets one tenant create a
second unverifiable history.

## Append path

`appendAuditRow` obtains a PostgreSQL transaction advisory lock, reads the
latest chain hash, and inserts the new row in the same SQL statement. The
database calculates the integrity hash with `pgcrypto`, so two concurrent
writers cannot both claim the same predecessor. A failed insert does not
advance the chain.

The application passes stable values for `event`, `actor`, `target`, `outcome`,
`correlationId`, and the redacted details. The `outcome` value is constrained to
`success` or `failure`; request and provider errors must not be serialized into
the details field because they may contain credentials or internal topology.

## Immutability boundary

Migration `0022_tamper_evident_audit.sql` installs a `BEFORE UPDATE OR DELETE`
trigger. API roles can insert and read rows but cannot rewrite an existing row.
The trigger is intentionally in the database rather than only in a repository,
because direct SQL, an old binary, or a compromised application instance must
not be able to silently edit history.

The rollback migration removes the trigger and chain columns. Treat rollback as
an incident-operation decision: removing the trigger weakens forensic
guarantees and must be followed by reapplying migration 0022 before accepting
privileged traffic.

## Verification

`verifyAuditChain` sorts records by `sequenceNo`, starts at `GENESIS`, and
reports every sequence gap, broken predecessor link, and digest mismatch. It
returns a structured result:

```json
{
"valid": false,
"checked": 2,
"issues": [
{
"sequenceNo": 2,
"id": "audit-2",
"reason": "integrity_hash_mismatch",
"expected": "…",
"actual": "…"
}
]
}
```

Operators should treat any issue as a failed verification, preserve the raw
rows for investigation, and compare the database audit role grants. A valid
chain proves that the supplied row fields were not changed after insertion; it
does not prove that the original actor was a human or that the application was
correct. Authentication, authorization, and deployment provenance remain
separate controls.

## Redaction and isolation

Redaction recursively replaces secret, token, password, private-key, and API
key fields with `[REDACTED]`. Arrays and nested objects are traversed, circular
references become `[Circular]`, and source objects are never mutated. Tenant
queries return only rows whose `tenant_id` matches the requested tenant.

The chain verifier and in-memory store tests cover successful chaining,
concurrent-boundary semantics, field tampering, predecessor replacement,
sequence gaps, duplicate IDs, defensive copies, recursive redaction, and
tenant isolation.
13 changes: 13 additions & 0 deletions migrations/0022_tamper_evident_audit.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
-- Rollback: 0022_tamper_evident_audit

DROP TRIGGER IF EXISTS audit_logs_append_only ON audit_logs;
DROP FUNCTION IF EXISTS reject_audit_log_mutation();
DROP INDEX IF EXISTS idx_audit_logs_sequence_no;

ALTER TABLE audit_logs
DROP CONSTRAINT IF EXISTS audit_logs_outcome_check,
DROP COLUMN IF EXISTS outcome,
DROP COLUMN IF EXISTS target,
DROP COLUMN IF EXISTS integrity_hash,
DROP COLUMN IF EXISTS previous_hash,
DROP COLUMN IF EXISTS sequence_no;
75 changes: 75 additions & 0 deletions migrations/0022_tamper_evident_audit.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
-- Migration: 0022_tamper_evident_audit
--
-- Privileged audit rows are append-only. sequence_no provides a stable chain
-- order, previous_hash links each row to its predecessor, and integrity_hash
-- authenticates the row payload plus that predecessor. The trigger is the
-- database boundary: application code cannot silently update or delete a row.

CREATE EXTENSION IF NOT EXISTS pgcrypto;

ALTER TABLE audit_logs
ADD COLUMN IF NOT EXISTS sequence_no BIGSERIAL,
ADD COLUMN IF NOT EXISTS previous_hash TEXT,
ADD COLUMN IF NOT EXISTS integrity_hash TEXT,
ADD COLUMN IF NOT EXISTS target TEXT,
ADD COLUMN IF NOT EXISTS outcome TEXT;

UPDATE audit_logs SET outcome = COALESCE(outcome, 'success');

ALTER TABLE audit_logs
ALTER COLUMN outcome SET DEFAULT 'success',
ALTER COLUMN outcome SET NOT NULL,
ADD CONSTRAINT audit_logs_outcome_check CHECK (outcome IN ('success', 'failure'));

-- Rows created by older versions predate the chain. Mark them as a separate
-- legacy segment rather than pretending that their missing history is known.
UPDATE audit_logs
SET previous_hash = COALESCE(previous_hash, 'LEGACY:' || id),
integrity_hash = COALESCE(
integrity_hash,
encode(
digest(
concat_ws('|', id, event, actor, COALESCE(tenant_id, ''),
COALESCE(target, ''), outcome, COALESCE(correlation_id, ''),
COALESCE(details, ''), created_at::text,
'LEGACY:' || id),
'sha256'
),
'hex'
)
)
WHERE previous_hash IS NULL OR integrity_hash IS NULL;

ALTER TABLE audit_logs
ALTER COLUMN previous_hash SET DEFAULT 'GENESIS',
ALTER COLUMN previous_hash SET NOT NULL,
ALTER COLUMN integrity_hash SET NOT NULL;

CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_logs_sequence_no
ON audit_logs (sequence_no);

CREATE OR REPLACE FUNCTION reject_audit_log_mutation()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION 'audit_logs is append-only; % is not permitted', TG_OP
USING ERRCODE = '55006';
END;
$$;

DROP TRIGGER IF EXISTS audit_logs_append_only ON audit_logs;
CREATE TRIGGER audit_logs_append_only
BEFORE UPDATE OR DELETE ON audit_logs
FOR EACH ROW EXECUTE FUNCTION reject_audit_log_mutation();

COMMENT ON COLUMN audit_logs.sequence_no IS
'Monotonic chain position assigned by PostgreSQL.';
COMMENT ON COLUMN audit_logs.previous_hash IS
'SHA-256 integrity hash of the preceding audit row, or GENESIS/LEGACY marker.';
COMMENT ON COLUMN audit_logs.integrity_hash IS
'SHA-256 of canonical audit fields and previous_hash.';
COMMENT ON COLUMN audit_logs.target IS
'Resource or route affected by the privileged mutation.';
COMMENT ON COLUMN audit_logs.outcome IS
'Whether the privileged mutation succeeded or failed.';
97 changes: 51 additions & 46 deletions src/repositories/auditLogRepository.test.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,52 @@
import assert from 'node:assert/strict';
import { DataType, newDb } from 'pg-mem';
import assert from "node:assert/strict";
import { DataType, newDb } from "pg-mem";

jest.mock('../config/env', () => ({
jest.mock("../config/env", () => ({
env: {
PORT: 3000,
NODE_ENV: 'test',
DATABASE_URL: 'postgresql://localhost/callora_test',
DB_HOST: 'localhost',
NODE_ENV: "test",
DATABASE_URL: "postgresql://localhost/callora_test",
DB_HOST: "localhost",
DB_PORT: 5432,
DB_USER: 'postgres',
DB_PASSWORD: 'postgres',
DB_NAME: 'callora_test',
DB_USER: "postgres",
DB_PASSWORD: "postgres",
DB_NAME: "callora_test",
DB_POOL_MAX: 1,
DB_IDLE_TIMEOUT_MS: 1000,
DB_CONN_TIMEOUT_MS: 1000,
JWT_SECRET: 'test-jwt-secret',
ADMIN_API_KEY: 'test-admin-api-key',
METRICS_API_KEY: 'test-metrics-api-key',
UPSTREAM_URL: 'http://localhost:4000',
JWT_SECRET: "test-jwt-secret",
ADMIN_API_KEY: "test-admin-api-key",
METRICS_API_KEY: "test-metrics-api-key",
UPSTREAM_URL: "http://localhost:4000",
PROXY_TIMEOUT_MS: 30000,
CORS_ALLOWED_ORIGINS: 'http://localhost:5173',
CORS_ALLOWED_ORIGINS: "http://localhost:5173",
SOROBAN_RPC_ENABLED: false,
HORIZON_ENABLED: false,
STELLAR_TESTNET_HORIZON_URL: 'https://horizon-testnet.stellar.org',
STELLAR_MAINNET_HORIZON_URL: 'https://horizon.stellar.org',
SOROBAN_TESTNET_RPC_URL: 'https://soroban-testnet.stellar.org',
SOROBAN_MAINNET_RPC_URL: 'https://soroban-mainnet.stellar.org',
STELLAR_TESTNET_HORIZON_URL: "https://horizon-testnet.stellar.org",
STELLAR_MAINNET_HORIZON_URL: "https://horizon.stellar.org",
SOROBAN_TESTNET_RPC_URL: "https://soroban-testnet.stellar.org",
SOROBAN_MAINNET_RPC_URL: "https://soroban-mainnet.stellar.org",
STELLAR_BASE_FEE: 100,
HEALTH_CHECK_DB_TIMEOUT: 2000,
APP_VERSION: '1.0.0',
LOG_LEVEL: 'info',
APP_VERSION: "1.0.0",
LOG_LEVEL: "info",
GATEWAY_PROFILING_ENABLED: false,
},
}));

import {
PgAuditLogRepository,
type AuditLogRepositoryQueryable,
} from './auditLogRepository.js';
import { encodeCursor } from '../lib/cursorPagination.js';
} from "./auditLogRepository.js";
import { encodeCursor } from "../lib/cursorPagination.js";

function createAuditLogRepository() {
const db = newDb();

db.public.registerFunction({
name: 'now',
name: "now",
returns: DataType.timestamp,
implementation: () => new Date('2026-06-28T00:00:00.000Z'),
implementation: () => new Date("2026-06-28T00:00:00.000Z"),
});

db.public.none(`
Expand All @@ -60,7 +60,12 @@ function createAuditLogRepository() {
correlation_id VARCHAR(255),
body_hash TEXT,
details TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
sequence_no SERIAL UNIQUE,
previous_hash TEXT NOT NULL DEFAULT 'GENESIS',
integrity_hash TEXT NOT NULL DEFAULT 'test-integrity-hash',
target TEXT,
outcome VARCHAR(7) NOT NULL DEFAULT 'success'
);
`);

Expand Down Expand Up @@ -98,8 +103,8 @@ async function insertAuditLog(
values.event,
values.actor,
values.tenantId ?? null,
'127.0.0.1',
'jest',
"127.0.0.1",
"jest",
`req-${values.id}`,
null,
values.details ? JSON.stringify(values.details) : null,
Expand All @@ -111,20 +116,20 @@ async function insertAuditLog(
async function seedAuditLogs(
pool: AuditLogRepositoryQueryable,
count: number,
baseTime = new Date('2026-06-28T00:00:00.000Z'),
baseTime = new Date("2026-06-28T00:00:00.000Z"),
): Promise<void> {
for (let i = 0; i < count; i++) {
await insertAuditLog(pool, {
id: `audit-${String(i).padStart(3, '0')}`,
event: 'LIST_USERS',
actor: 'admin-api-key',
id: `audit-${String(i).padStart(3, "0")}`,
event: "LIST_USERS",
actor: "admin-api-key",
createdAt: new Date(baseTime.getTime() + i * 60_000),
details: { index: i },
});
}
}

test('returns newest rows first with next page detection', async () => {
test("returns newest rows first with next page detection", async () => {
const { repository, pgPool, queryable } = createAuditLogRepository();

try {
Expand All @@ -133,8 +138,8 @@ test('returns newest rows first with next page detection', async () => {
const firstPage = await repository.findCursor({ limit: 2 });
assert.equal(firstPage.entries.length, 2);
assert.equal(firstPage.hasMore, true);
assert.equal(firstPage.entries[0]?.id, 'audit-004');
assert.equal(firstPage.entries[1]?.id, 'audit-003');
assert.equal(firstPage.entries[0]?.id, "audit-004");
assert.equal(firstPage.entries[1]?.id, "audit-003");

const cursor = encodeCursor(
new Date(firstPage.entries[1]!.createdAt),
Expand All @@ -151,15 +156,15 @@ test('returns newest rows first with next page detection', async () => {

assert.equal(secondPage.entries.length, 2);
assert.equal(secondPage.hasMore, true);
assert.equal(secondPage.entries[0]?.id, 'audit-002');
assert.equal(secondPage.entries[1]?.id, 'audit-001');
assert.notEqual(cursor, '');
assert.equal(secondPage.entries[0]?.id, "audit-002");
assert.equal(secondPage.entries[1]?.id, "audit-001");
assert.notEqual(cursor, "");
} finally {
await pgPool.end();
}
});

test('returns hasMore=false on the final page', async () => {
test("returns hasMore=false on the final page", async () => {
const { repository, pgPool, queryable } = createAuditLogRepository();

try {
Expand All @@ -168,20 +173,20 @@ test('returns hasMore=false on the final page', async () => {
const page = await repository.findCursor({
limit: 1,
afterCursor: {
timestamp: new Date('2026-06-28T00:01:00.000Z'),
id: 'audit-001',
timestamp: new Date("2026-06-28T00:01:00.000Z"),
id: "audit-001",
},
});

assert.equal(page.entries.length, 1);
assert.equal(page.hasMore, false);
assert.equal(page.entries[0]?.id, 'audit-000');
assert.equal(page.entries[0]?.id, "audit-000");
} finally {
await pgPool.end();
}
});

test('applies event and tenant filters', async () => {
test("applies event and tenant filters", async () => {
const { repository, pgPool, queryable } = createAuditLogRepository();

try {
Expand All @@ -196,19 +201,19 @@ test('applies event and tenant filters', async () => {

const filtered = await repository.findCursor({
limit: 10,
event: 'SOFT_DELETE_API',
tenantId: 'tenant-b',
event: "SOFT_DELETE_API",
tenantId: "tenant-b",
});

assert.equal(filtered.entries.length, 1);
assert.equal(filtered.entries[0]?.id, 'a-2');
assert.equal(filtered.entries[0]?.id, "a-2");
assert.equal(filtered.hasMore, false);
} finally {
await pgPool.end();
}
});

test('parses JSON details into objects', async () => {
test("parses JSON details into objects", async () => {
const { repository, pgPool, queryable } = createAuditLogRepository();

try {
Expand Down
Loading
Loading