diff --git a/docs/tamper-evident-audit.md b/docs/tamper-evident-audit.md new file mode 100644 index 00000000..aa7507a2 --- /dev/null +++ b/docs/tamper-evident-audit.md @@ -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. diff --git a/migrations/0022_tamper_evident_audit.down.sql b/migrations/0022_tamper_evident_audit.down.sql new file mode 100644 index 00000000..2152cb3f --- /dev/null +++ b/migrations/0022_tamper_evident_audit.down.sql @@ -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; diff --git a/migrations/0022_tamper_evident_audit.sql b/migrations/0022_tamper_evident_audit.sql new file mode 100644 index 00000000..9c1266bf --- /dev/null +++ b/migrations/0022_tamper_evident_audit.sql @@ -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.'; diff --git a/src/repositories/auditLogRepository.test.ts b/src/repositories/auditLogRepository.test.ts index 9765340e..d6be69f1 100644 --- a/src/repositories/auditLogRepository.test.ts +++ b/src/repositories/auditLogRepository.test.ts @@ -1,35 +1,35 @@ -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, }, })); @@ -37,16 +37,16 @@ jest.mock('../config/env', () => ({ 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(` @@ -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' ); `); @@ -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, @@ -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 { 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 { @@ -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), @@ -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 { @@ -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 { @@ -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 { diff --git a/src/repositories/auditLogRepository.ts b/src/repositories/auditLogRepository.ts index 0cf94f7e..538c98f9 100644 --- a/src/repositories/auditLogRepository.ts +++ b/src/repositories/auditLogRepository.ts @@ -1,5 +1,5 @@ -import type { CursorPayload } from '../lib/cursorPagination.js'; -import { readQuery } from '../db.js'; +import type { CursorPayload } from "../lib/cursorPagination.js"; +import { readQuery } from "../db.js"; export interface AuditLogEntry { id: string; @@ -12,6 +12,11 @@ export interface AuditLogEntry { bodyHash: string | null; details: Record | null; createdAt: string; + sequenceNo: number; + previousHash: string; + integrityHash: string; + target: string | null; + outcome: "success" | "failure"; } export interface AuditLogCursorFilters { @@ -33,7 +38,9 @@ export interface FindAuditLogsCursorResult { } export interface AuditLogRepository { - findCursor(params: FindAuditLogsCursorParams): Promise; + findCursor( + params: FindAuditLogsCursorParams, + ): Promise; findById(id: string): Promise; } @@ -52,6 +59,11 @@ interface AuditLogRow { body_hash: string | null; details: string | null; created_at: Date | string; + sequence_no: number; + previous_hash: string; + integrity_hash: string; + target: string | null; + outcome: "success" | "failure"; } const parseDetails = (raw: string | null): Record | null => { @@ -61,7 +73,9 @@ const parseDetails = (raw: string | null): Record | null => { try { const parsed: unknown = JSON.parse(raw); - return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + return typeof parsed === "object" && + parsed !== null && + !Array.isArray(parsed) ? (parsed as Record) : null; } catch { @@ -79,15 +93,23 @@ const mapAuditLogRow = (row: AuditLogRow): AuditLogEntry => ({ correlationId: row.correlation_id, bodyHash: row.body_hash, details: parseDetails(row.details), - createdAt: row.created_at instanceof Date - ? row.created_at.toISOString() - : new Date(row.created_at).toISOString(), + createdAt: + row.created_at instanceof Date + ? row.created_at.toISOString() + : new Date(row.created_at).toISOString(), + sequenceNo: row.sequence_no, + previousHash: row.previous_hash, + integrityHash: row.integrity_hash, + target: row.target, + outcome: row.outcome, }); export class PgAuditLogRepository implements AuditLogRepository { constructor(private readonly db?: AuditLogRepositoryQueryable) {} - async findCursor(params: FindAuditLogsCursorParams): Promise { + async findCursor( + params: FindAuditLogsCursorParams, + ): Promise { const fetchLimit = Math.max(1, params.limit) + 1; const sqlParams: unknown[] = []; const whereClauses: string[] = []; @@ -131,7 +153,8 @@ export class PgAuditLogRepository implements AuditLogRepository { sqlParams.push(fetchLimit); - const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : ''; + const whereSql = + whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : ""; const result = await this.read( ` @@ -145,7 +168,12 @@ export class PgAuditLogRepository implements AuditLogRepository { correlation_id, body_hash, details, - created_at + created_at, + sequence_no, + previous_hash, + integrity_hash, + target, + outcome FROM audit_logs ${whereSql} ORDER BY created_at DESC, id DESC @@ -173,7 +201,12 @@ export class PgAuditLogRepository implements AuditLogRepository { correlation_id, body_hash, details, - created_at + created_at, + sequence_no, + previous_hash, + integrity_hash, + target, + outcome FROM audit_logs WHERE id = $1 LIMIT 1 diff --git a/src/services/auditService.test.ts b/src/services/auditService.test.ts index 80e8c780..30d24c05 100644 --- a/src/services/auditService.test.ts +++ b/src/services/auditService.test.ts @@ -1,11 +1,11 @@ -import assert from 'node:assert/strict'; +import assert from "node:assert/strict"; -jest.mock('../db.js', () => ({ +jest.mock("../db.js", () => ({ writeQuery: jest.fn(), })); -import { writeQuery } from '../db.js'; -import { appendAuditRow, type AuditRowInput } from './auditService.js'; +import { writeQuery } from "../db.js"; +import { appendAuditRow, type AuditRowInput } from "./auditService.js"; const mockWriteQuery = writeQuery as jest.MockedFunction; @@ -17,44 +17,48 @@ afterEach(() => { jest.clearAllMocks(); }); -describe('appendAuditRow', () => { +describe("appendAuditRow", () => { const baseInput: AuditRowInput = { - actor: 'dev-123', - action: 'WEBHOOK_REGISTERED', + actor: "dev-123", + action: "WEBHOOK_REGISTERED", before: null, - after: { developerId: 'dev-123', url: 'https://example.com', events: ['new_api_call'] }, + after: { + developerId: "dev-123", + url: "https://example.com", + events: ["new_api_call"], + }, }; - it('inserts a row with all required fields', async () => { + it("inserts a row with all required fields", async () => { const result = await appendAuditRow(baseInput); assert.ok(result.id); - assert.equal(result.actor, 'dev-123'); - assert.equal(result.action, 'WEBHOOK_REGISTERED'); + assert.equal(result.actor, "dev-123"); + assert.equal(result.action, "WEBHOOK_REGISTERED"); assert.ok(result.createdAt); }); - it('calls writeQuery with correct SQL and params', async () => { + it("calls writeQuery with correct SQL and params", async () => { await appendAuditRow(baseInput); assert.equal(mockWriteQuery.mock.calls.length, 1); const call = mockWriteQuery.mock.calls[0]!; const sql = call[0] as string; const params = call![1]; - assert.ok(sql.includes('INSERT INTO audit_logs')); - assert.equal(params[1]!, 'WEBHOOK_REGISTERED'); - assert.equal(params[2]!, 'dev-123'); + assert.ok(sql.includes("INSERT INTO audit_logs")); + assert.equal(params[1]!, "WEBHOOK_REGISTERED"); + assert.equal(params[2]!, "dev-123"); }); - it('includes tenantId when provided', async () => { - await appendAuditRow({ ...baseInput, tenantId: 'tenant-abc' }); + it("includes tenantId when provided", async () => { + await appendAuditRow({ ...baseInput, tenantId: "tenant-abc" }); const call = mockWriteQuery.mock.calls[0]!; const params = call[1]!; - assert.equal(params[3], 'tenant-abc'); + assert.equal(params[3], "tenant-abc"); }); - it('passes null for tenantId when not provided', async () => { + it("passes null for tenantId when not provided", async () => { await appendAuditRow(baseInput); const call = mockWriteQuery.mock.calls[0]!; @@ -62,106 +66,116 @@ describe('appendAuditRow', () => { assert.equal(params[3], null); }); - it('includes correlationId when provided', async () => { - await appendAuditRow({ ...baseInput, correlationId: 'req-xyz' }); + it("includes correlationId when provided", async () => { + await appendAuditRow({ ...baseInput, correlationId: "req-xyz" }); const call = mockWriteQuery.mock.calls[0]!; const params = call[1]!; - assert.equal(params[6], 'req-xyz'); + assert.equal(params[6], "req-xyz"); }); - it('includes clientIp when provided', async () => { - await appendAuditRow({ ...baseInput, clientIp: '192.168.1.1' }); + it("includes clientIp when provided", async () => { + await appendAuditRow({ ...baseInput, clientIp: "192.168.1.1" }); const call = mockWriteQuery.mock.calls[0]!; const params = call[1]!; - assert.equal(params[4], '192.168.1.1'); + assert.equal(params[4], "192.168.1.1"); }); - it('includes userAgent when provided', async () => { - await appendAuditRow({ ...baseInput, userAgent: 'Mozilla/5.0' }); + it("includes userAgent when provided", async () => { + await appendAuditRow({ ...baseInput, userAgent: "Mozilla/5.0" }); const call = mockWriteQuery.mock.calls[0]!; const params = call[1]!; - assert.equal(params[5], 'Mozilla/5.0'); + assert.equal(params[5], "Mozilla/5.0"); }); - it('includes bodyHash when provided', async () => { - await appendAuditRow({ ...baseInput, bodyHash: 'abc123' }); + it("includes bodyHash when provided", async () => { + await appendAuditRow({ ...baseInput, bodyHash: "abc123" }); const call = mockWriteQuery.mock.calls[0]!; const params = call[1]!; - assert.equal(params[7], 'abc123'); + assert.equal(params[7], "abc123"); }); - it('serializes details JSON with before and after', async () => { + it("serializes details JSON with before and after", async () => { await appendAuditRow(baseInput); const call = mockWriteQuery.mock.calls[0]!; const params = call[1]!; const details = JSON.parse(params[8] as string); - assert.ok(details.before); + assert.equal(details.before, undefined); assert.ok(details.after); - assert.equal(details.after.developerId, 'dev-123'); + assert.equal(details.after.developerId, "dev-123"); }); - it('masks secret_current in before/after details', async () => { + it("masks secret_current in before/after details", async () => { await appendAuditRow({ ...baseInput, - before: { secret_current: 'sk_live_abc123def456', url: 'https://example.com' }, - after: { secret_current: 'sk_live_xyz789abc012', url: 'https://example.com' }, + before: { + secret_current: "sk_live_abc123def456", + url: "https://example.com", + }, + after: { + secret_current: "sk_live_xyz789abc012", + url: "https://example.com", + }, }); const call = mockWriteQuery.mock.calls[0]!; const params = call[1]!; const details = JSON.parse(params[8] as string); - assert.equal(details.before.secret_current, 'sk_l****c012'); - assert.equal(details.after.secret_current, 'sk_l****c012'); + assert.equal(details.before.secret_current, "[REDACTED]"); + assert.equal(details.after.secret_current, "[REDACTED]"); }); - it('masks secret in before/after details', async () => { + it("masks secret in before/after details", async () => { await appendAuditRow({ ...baseInput, - before: { secret: 'sk_live_abc123def456' }, - after: { secret: 'sk_live_xyz789abc012' }, + before: { secret: "sk_live_abc123def456" }, + after: { secret: "sk_live_xyz789abc012" }, }); const call = mockWriteQuery.mock.calls[0]!; const params = call[1]!; const details = JSON.parse(params[8] as string); - assert.equal(details.before.secret, 'sk_l****f456'); - assert.equal(details.after.secret, 'sk_l****c012'); + assert.equal(details.before.secret, "[REDACTED]"); + assert.equal(details.after.secret, "[REDACTED]"); }); - it('handles short secrets by masking entirely', async () => { + it("handles short secrets by masking entirely", async () => { await appendAuditRow({ ...baseInput, - before: { secret_current: 'short' }, - after: { secret_current: 'short' }, + before: { secret_current: "short" }, + after: { secret_current: "short" }, }); const call = mockWriteQuery.mock.calls[0]!; const params = call[1]!; const details = JSON.parse(params[8] as string); - assert.equal(details.before.secret_current, '****'); - assert.equal(details.after.secret_current, '****'); + assert.equal(details.before.secret_current, "[REDACTED]"); + assert.equal(details.after.secret_current, "[REDACTED]"); }); - it('returns the row with id, createdAt, and all input fields', async () => { + it("returns the row with id, createdAt, and all input fields", async () => { const result = await appendAuditRow(baseInput); assert.ok(result.id); assert.ok(result.createdAt); - assert.equal(result.actor, 'dev-123'); - assert.equal(result.action, 'WEBHOOK_REGISTERED'); + assert.equal(result.actor, "dev-123"); + assert.equal(result.action, "WEBHOOK_REGISTERED"); assert.equal(result.before, null); - assert.deepEqual(result.after, { developerId: 'dev-123', url: 'https://example.com', events: ['new_api_call'] }); + assert.deepEqual(result.after, { + developerId: "dev-123", + url: "https://example.com", + events: ["new_api_call"], + }); }); - it('includes null fields when before and after are both null', async () => { + it("includes null fields when before and after are both null", async () => { await appendAuditRow({ - actor: 'dev-123', - action: 'WEBHOOK_DELETED', + actor: "dev-123", + action: "WEBHOOK_DELETED", before: null, after: null, }); @@ -172,4 +186,4 @@ describe('appendAuditRow', () => { assert.equal(details.before, undefined); assert.equal(details.after, undefined); }); -}); \ No newline at end of file +}); diff --git a/src/services/auditService.ts b/src/services/auditService.ts index 282fadd1..8e05b48e 100644 --- a/src/services/auditService.ts +++ b/src/services/auditService.ts @@ -1,5 +1,9 @@ -import { v4 as uuidv4 } from 'uuid'; -import { writeQuery } from '../db.js'; +import { v4 as uuidv4 } from "uuid"; +import { writeQuery } from "../db.js"; +import { + computeAuditIntegrityHash, + redactPrivilegedValue, +} from "./tamperEvidentAudit.js"; export interface AuditRowInput { actor: string; @@ -11,6 +15,8 @@ export interface AuditRowInput { clientIp?: string | null; userAgent?: string | null; bodyHash?: string | null; + target?: string | null; + outcome?: "success" | "failure"; } export interface AuditRow extends AuditRowInput { @@ -18,26 +24,10 @@ export interface AuditRow extends AuditRowInput { createdAt: string; } -function maskSecret(value: string | undefined): string | undefined { - if (!value) return undefined; - if (value.length <= 8) return '****'; - return value.slice(0, 4) + '****' + value.slice(-4); -} - -function sanitizeWebhookConfig(config: Record): Record { - const sanitized: Record = {}; - for (const [key, value] of Object.entries(config)) { - if (key === 'secret' || key === 'secret_current' || key === 'secret_previous') { - sanitized[key] = maskSecret(typeof value === 'string' ? value : undefined); - } else if (key === 'previous_expires_at' && value instanceof Date) { - sanitized[key] = value.toISOString(); - } else if (typeof value === 'function') { - sanitized[key] = '[Function]'; - } else { - sanitized[key] = value; - } - } - return sanitized; +function sanitizeWebhookConfig( + config: Record, +): Record { + return redactPrivilegedValue(config) as Record; } export async function appendAuditRow(input: AuditRowInput): Promise { @@ -74,10 +64,39 @@ export async function appendAuditRow(input: AuditRowInput): Promise { details.correlationId = input.correlationId; } - await writeQuery( + const target = input.target ?? null; + const outcome = input.outcome ?? "success"; + + const result = await writeQuery<{ + sequence_no: number; + previous_hash: string; + integrity_hash: string; + }>( ` - INSERT INTO audit_logs (id, event, actor, tenant_id, client_ip, user_agent, correlation_id, body_hash, details, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + WITH audit_lock AS ( + SELECT pg_advisory_xact_lock(hashtext('callora:audit_logs')) + ), previous AS ( + SELECT COALESCE( + (SELECT integrity_hash FROM audit_logs ORDER BY sequence_no DESC LIMIT 1), + 'GENESIS' + ) AS previous_hash + FROM audit_lock + ), inserted AS ( + INSERT INTO audit_logs ( + id, event, actor, tenant_id, client_ip, user_agent, correlation_id, + body_hash, details, created_at, target, outcome, previous_hash, + integrity_hash + ) + SELECT + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, + previous.previous_hash, + encode(digest(concat_ws('|', $1, $2, $3, COALESCE($4, ''), + COALESCE($11, ''), $12, COALESCE($7, ''), COALESCE($9, ''), + $10, previous.previous_hash), 'sha256'), 'hex') + FROM previous + RETURNING sequence_no, previous_hash, integrity_hash + ) + SELECT sequence_no, previous_hash, integrity_hash FROM inserted `, [ id, @@ -90,9 +109,31 @@ export async function appendAuditRow(input: AuditRowInput): Promise { input.bodyHash ?? null, JSON.stringify(details), now, + target, + outcome, ], ); + const inserted = result.rows[0]; + const previousHash = inserted?.previous_hash ?? "GENESIS"; + const sequenceNo = inserted?.sequence_no ?? 0; + const integrityHash = + inserted?.integrity_hash ?? + computeAuditIntegrityHash( + { + id, + event: input.action, + actor: input.actor, + tenantId: input.tenantId ?? null, + target, + outcome, + correlationId: input.correlationId ?? null, + details, + createdAt: now, + }, + previousHash, + ); + return { id, createdAt: now, @@ -105,5 +146,10 @@ export async function appendAuditRow(input: AuditRowInput): Promise { clientIp: input.clientIp ?? null, userAgent: input.userAgent ?? null, bodyHash: input.bodyHash ?? null, + target, + outcome, + sequenceNo, + previousHash, + integrityHash, }; } diff --git a/src/services/tamperEvidentAudit.test.ts b/src/services/tamperEvidentAudit.test.ts new file mode 100644 index 00000000..123691f7 --- /dev/null +++ b/src/services/tamperEvidentAudit.test.ts @@ -0,0 +1,240 @@ +import assert from "node:assert/strict"; +import { + InMemoryAuditRecordStore, + canonicalAuditPayload, + computeAuditIntegrityHash, + redactPrivilegedValue, + verifyAuditChain, + type PrivilegedAuditRecord, +} from "./tamperEvidentAudit.js"; + +const makeRecord = ( + sequenceNo: number, + previousHash: string, + overrides: Partial = {}, +): PrivilegedAuditRecord => { + const base = { + id: `audit-${sequenceNo}`, + sequenceNo, + event: "ADMIN_API_UPDATE", + actor: "operator-1", + tenantId: "tenant-a", + target: "/api/admin/apis/api-1", + outcome: "success" as const, + correlationId: `request-${sequenceNo}`, + details: { before: { enabled: false }, after: { enabled: true } }, + createdAt: `2026-08-27T10:0${sequenceNo}:00.000Z`, + previousHash, + integrityHash: "", + }; + const record = { ...base, ...overrides }; + return { + ...record, + integrityHash: + overrides.integrityHash ?? + computeAuditIntegrityHash(record, previousHash), + }; +}; + +describe("tamper-evident audit chain", () => { + it("uses an explicit, deterministic canonical payload", () => { + const record = makeRecord(1, "GENESIS"); + assert.equal( + canonicalAuditPayload(record, "GENESIS"), + [ + "audit-1", + "ADMIN_API_UPDATE", + "operator-1", + "tenant-a", + "/api/admin/apis/api-1", + "success", + "request-1", + JSON.stringify(record.details), + "2026-08-27T10:01:00.000Z", + "GENESIS", + ].join("|"), + ); + }); + + it("produces a stable SHA-256 digest for the same record", () => { + const first = makeRecord(1, "GENESIS"); + const second = makeRecord(1, "GENESIS"); + assert.match(first.integrityHash, /^[a-f0-9]{64}$/); + assert.equal(first.integrityHash, second.integrityHash); + }); + + it("changes the digest when actor, outcome, target, or details change", () => { + const original = makeRecord(1, "GENESIS"); + for (const change of [ + { actor: "operator-2" }, + { outcome: "failure" as const }, + { target: "/api/admin/apis/api-2" }, + { details: { before: { enabled: true }, after: { enabled: false } } }, + ]) { + const changed = { ...original, ...change }; + assert.notEqual( + computeAuditIntegrityHash(changed, "GENESIS"), + original.integrityHash, + ); + } + }); + + it("accepts a valid multi-row chain", () => { + const first = makeRecord(1, "GENESIS"); + const second = makeRecord(2, first.integrityHash); + const third = makeRecord(3, second.integrityHash); + + assert.deepEqual(verifyAuditChain([third, first, second]), { + valid: true, + checked: 3, + issues: [], + }); + }); + + it("detects a changed field even when the previous link is intact", () => { + const first = makeRecord(1, "GENESIS"); + const second = makeRecord(2, first.integrityHash); + const tampered = { ...second, actor: "attacker" }; + const result = verifyAuditChain([first, tampered]); + + assert.equal(result.valid, false); + assert.equal(result.checked, 2); + assert.equal(result.issues[0]?.reason, "integrity_hash_mismatch"); + assert.equal(result.issues[0]?.id, second.id); + }); + + it("detects a replaced predecessor through the next row link", () => { + const first = makeRecord(1, "GENESIS"); + const second = makeRecord(2, first.integrityHash); + const replacement = { ...first, actor: "attacker" }; + const result = verifyAuditChain([replacement, second]); + + assert.equal(result.valid, false); + assert.ok( + result.issues.some((issue) => issue.reason === "integrity_hash_mismatch"), + ); + }); + + it("detects missing sequence positions", () => { + const first = makeRecord(1, "GENESIS"); + const third = makeRecord(3, first.integrityHash); + const result = verifyAuditChain([first, third]); + + assert.equal(result.valid, false); + assert.deepEqual(result.issues[0], { + sequenceNo: 3, + id: "audit-3", + reason: "sequence_gap", + expected: "2", + actual: "3", + }); + }); + + it("reports an empty chain as valid without fabricating records", () => { + assert.deepEqual(verifyAuditChain([]), { + valid: true, + checked: 0, + issues: [], + }); + }); +}); + +describe("privileged audit redaction", () => { + it("redacts secrets recursively while preserving safe context", () => { + const input = { + route: "/api/admin/keys", + credentials: { + apiKey: "live-key", + nested: { refresh_token: "refresh-secret", label: "primary" }, + }, + before: [{ password: "old", enabled: false }], + }; + assert.deepEqual(redactPrivilegedValue(input), { + route: "/api/admin/keys", + credentials: { + apiKey: "[REDACTED]", + nested: { refresh_token: "[REDACTED]", label: "primary" }, + }, + before: [{ password: "[REDACTED]", enabled: false }], + }); + }); + + it("does not mutate the source object during redaction", () => { + const source = { after: { secret: "do-not-change", enabled: true } }; + const redacted = redactPrivilegedValue(source); + assert.equal(source.after.secret, "do-not-change"); + assert.equal( + (redacted as { after: { secret: string } }).after.secret, + "[REDACTED]", + ); + }); + + it("handles circular operator details without throwing", () => { + const source: { self?: unknown; token: string } = { token: "secret" }; + source.self = source; + assert.deepEqual(redactPrivilegedValue(source), { + token: "[REDACTED]", + self: "[Circular]", + }); + }); +}); + +describe("append-only in-memory store", () => { + it("accepts the first record at the genesis boundary", async () => { + const store = new InMemoryAuditRecordStore(); + const first = makeRecord(1, "GENESIS"); + await store.append(first); + assert.deepEqual(await store.list(), [first]); + }); + + it("requires every appended row to extend the chain", async () => { + const store = new InMemoryAuditRecordStore(); + const first = makeRecord(1, "GENESIS"); + await store.append(first); + await assert.rejects( + store.append(makeRecord(3, first.integrityHash)), + /does not extend/, + ); + await assert.rejects( + store.append(makeRecord(2, "wrong-predecessor")), + /does not extend/, + ); + }); + + it("rejects duplicate IDs", async () => { + const store = new InMemoryAuditRecordStore(); + const first = makeRecord(1, "GENESIS"); + await store.append(first); + await assert.rejects(store.append(first), /id already exists/); + }); + + it("returns defensive copies so callers cannot rewrite stored rows", async () => { + const store = new InMemoryAuditRecordStore(); + const first = makeRecord(1, "GENESIS"); + await store.append(first); + const result = await store.list(); + result[0]!.details!.after = { enabled: false }; + const reread = await store.list(); + assert.deepEqual(reread[0]!.details!.after, { enabled: true }); + }); + + it("filters records by tenant without leaking another tenant", async () => { + const store = new InMemoryAuditRecordStore(); + const first = makeRecord(1, "GENESIS", { tenantId: "tenant-a" }); + const second = makeRecord(2, first.integrityHash, { tenantId: "tenant-b" }); + await store.append(first); + await store.append(second); + assert.deepEqual( + (await store.list("tenant-a")).map((row) => row.id), + ["audit-1"], + ); + assert.deepEqual( + (await store.list("tenant-b")).map((row) => row.id), + ["audit-2"], + ); + assert.deepEqual( + (await store.list("tenant-c")).map((row) => row.id), + [], + ); + }); +}); diff --git a/src/services/tamperEvidentAudit.ts b/src/services/tamperEvidentAudit.ts new file mode 100644 index 00000000..56a1c544 --- /dev/null +++ b/src/services/tamperEvidentAudit.ts @@ -0,0 +1,203 @@ +import { createHash } from "node:crypto"; + +/** The fields that must be present on every privileged audit record. */ +export interface PrivilegedAuditRecord { + id: string; + sequenceNo: number; + event: string; + actor: string; + tenantId: string | null; + target: string | null; + outcome: "success" | "failure"; + correlationId: string | null; + details: Record | null; + createdAt: string; + previousHash: string; + integrityHash: string; +} + +export interface AuditChainIssue { + sequenceNo: number; + id: string; + reason: "sequence_gap" | "previous_hash_mismatch" | "integrity_hash_mismatch"; + expected: string; + actual: string; +} + +export interface AuditChainVerification { + valid: boolean; + checked: number; + issues: AuditChainIssue[]; +} + +/** + * Keep the hash input deliberately explicit and stable. Changing field order + * or encoding is a chain-format migration, not a harmless implementation + * detail; the verifier and SQL writer must use the same representation. + */ +export function canonicalAuditPayload( + record: Pick< + PrivilegedAuditRecord, + | "id" + | "event" + | "actor" + | "tenantId" + | "target" + | "outcome" + | "correlationId" + | "details" + | "createdAt" + >, + previousHash: string, +): string { + return [ + record.id, + record.event, + record.actor, + record.tenantId ?? "", + record.target ?? "", + record.outcome, + record.correlationId ?? "", + record.details === null ? "" : JSON.stringify(record.details), + record.createdAt, + previousHash, + ].join("|"); +} + +export function computeAuditIntegrityHash( + record: Pick< + PrivilegedAuditRecord, + | "id" + | "event" + | "actor" + | "tenantId" + | "target" + | "outcome" + | "correlationId" + | "details" + | "createdAt" + >, + previousHash: string, +): string { + return createHash("sha256") + .update(canonicalAuditPayload(record, previousHash), "utf8") + .digest("hex"); +} + +/** Verify both links in the chain and return every detected discrepancy. */ +export function verifyAuditChain( + records: readonly PrivilegedAuditRecord[], +): AuditChainVerification { + const issues: AuditChainIssue[] = []; + const ordered = [...records].sort( + (left, right) => left.sequenceNo - right.sequenceNo, + ); + let previousHash = "GENESIS"; + let expectedSequence = ordered[0]?.sequenceNo ?? 1; + + for (const record of ordered) { + if (record.sequenceNo !== expectedSequence) { + issues.push({ + sequenceNo: record.sequenceNo, + id: record.id, + reason: "sequence_gap", + expected: String(expectedSequence), + actual: String(record.sequenceNo), + }); + } + + if (record.previousHash !== previousHash) { + issues.push({ + sequenceNo: record.sequenceNo, + id: record.id, + reason: "previous_hash_mismatch", + expected: previousHash, + actual: record.previousHash, + }); + } + + const expectedHash = computeAuditIntegrityHash(record, record.previousHash); + if (record.integrityHash !== expectedHash) { + issues.push({ + sequenceNo: record.sequenceNo, + id: record.id, + reason: "integrity_hash_mismatch", + expected: expectedHash, + actual: record.integrityHash, + }); + } + + previousHash = record.integrityHash; + expectedSequence = record.sequenceNo + 1; + } + + return { valid: issues.length === 0, checked: ordered.length, issues }; +} + +const SECRET_KEY = + /(?:^|_)(?:secret|token|password|private[_-]?key|api[_-]?key)(?:$|_)/i; + +/** Redact nested operator input before it enters details or logs. */ +export function redactPrivilegedValue( + value: unknown, + seen = new WeakSet(), +): unknown { + if (value === null || value === undefined || typeof value !== "object") + return value; + if (seen.has(value)) return "[Circular]"; + seen.add(value); + + if (Array.isArray(value)) + return value.map((entry) => redactPrivilegedValue(entry, seen)); + + const output: Record = {}; + for (const [key, entry] of Object.entries(value)) { + const normalizedKey = key + .replace(/([a-z])([A-Z])/g, "$1_$2") + .replace(/[^a-zA-Z0-9_]/g, "_"); + if (SECRET_KEY.test(normalizedKey)) { + output[key] = "[REDACTED]"; + } else { + output[key] = redactPrivilegedValue(entry, seen); + } + } + return output; +} + +export interface AuditRecordStore { + append(record: PrivilegedAuditRecord): Promise; + list(tenantId?: string | null): Promise; +} + +/** Deterministic append-only store used by unit tests and local development. */ +export class InMemoryAuditRecordStore implements AuditRecordStore { + private readonly records: PrivilegedAuditRecord[] = []; + + private clone(record: PrivilegedAuditRecord): PrivilegedAuditRecord { + return JSON.parse(JSON.stringify(record)) as PrivilegedAuditRecord; + } + + async append(record: PrivilegedAuditRecord): Promise { + if (this.records.some((existing) => existing.id === record.id)) { + throw new Error("audit record id already exists"); + } + if (this.records.length > 0) { + const last = this.records[this.records.length - 1]!; + if ( + record.sequenceNo !== last.sequenceNo + 1 || + record.previousHash !== last.integrityHash + ) { + throw new Error("audit record does not extend the current chain"); + } + } + this.records.push(this.clone(record)); + } + + async list(tenantId?: string | null): Promise { + return this.records + .filter( + (record) => tenantId === undefined || record.tenantId === tenantId, + ) + .map((record) => this.clone(record)); + } +}