Skip to content
Open
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
561 changes: 561 additions & 0 deletions README.md

Large diffs are not rendered by default.

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.
5 changes: 5 additions & 0 deletions jest.env-setup.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Runs in each worker before any module is imported.
// Sets the minimum required env vars so env.ts doesn't call process.exit(1).
process.env.JWT_SECRET = process.env.JWT_SECRET || "test-jwt-secret";
process.env.ADMIN_API_KEY = process.env.ADMIN_API_KEY || "test-admin-key";
process.env.METRICS_API_KEY = process.env.METRICS_API_KEY || "test-metrics-key";
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
-- Migration: namespace idempotency keys per authenticated scope
-- destructive-approved: #1273
--
-- Keys were previously unique globally (`idempotency_key` PRIMARY KEY), so two
-- users choosing the same key collided: the second saw
Expand Down
85 changes: 85 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
{
"name": "callora-backend",
"version": "0.0.1",
"type": "module",
"scripts": {
"build": "tsc",
"prebuild": "npm run error-codes:check && npm run validate:openapi",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts",
"lint": "eslint .",
"db:generate": "drizzle-kit generate:sqlite",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"seed:dev": "tsx scripts/seed-dev.ts",
"typecheck": "tsc --noEmit",
"validate:issue-9": "node scripts/validate-issue-9.mjs",
"validate:openapi": "node scripts/validate-openapi-contract.mjs",
"db:check-migrations": "npx tsx scripts/check-migrations.ts",
"error-codes:generate": "node scripts/generate-error-codes.mjs",
"error-codes:check": "node scripts/generate-error-codes.mjs --check",
"pretest": "npm run error-codes:check",
"test": "jest --forceExit",
"test:serial": "jest --runInBand --forceExit",
"test:unit": "jest --runInBand --forceExit --testPathIgnorePatterns tests/integration",
"test:integration": "jest --runInBand --forceExit tests/integration",
"test:coverage": "jest --runInBand --coverage --forceExit --testPathIgnorePatterns tests/integration"
},
"dependencies": {
"@opentelemetry/api": "^1.9.1",
"@prisma/adapter-pg": "^7.4.1",
"@prisma/client": "^7.5.0",
"@stellar/stellar-sdk": "^14.5.0",
"axios": "^1.13.5",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^9.2.2",
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"drizzle-orm": "^0.29.0",
"express": "^4.18.2",
"express-openapi-validator": "^5.6.2",
"helmet": "^8.1.0",
"ip-range-check": "^0.2.0",
"jsonwebtoken": "^9.0.3",
"pg": "^8.18.0",
"pino": "^10.3.1",
"prisma": "^7.4.1",
"prom-client": "^15.1.0",
"uuid": "^13.0.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/axios": "^0.9.36",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.8",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.21",
"@types/helmet": "^0.0.48",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20.10.0",
"@types/pg": "^8.16.0",
"@types/supertest": "^6.0.3",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"@useoptic/optic": "^1.0.9",
"drizzle-kit": "^0.20.7",
"eslint": "^10.0.2",
"fast-check": "^3.22.0",
"globals": "^17.3.0",
"jest": "^29.7.0",
"openapi-types": "^12.1.3",
"pg-mem": "^3.0.13",
"picomatch": "^2.3.1",
"supertest": "^7.2.2",
"testcontainers": "^10.10.4",
"ts-jest": "^29.4.6",
"tsx": "^4.7.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.1"
},
"overrides": {
"ajv": "8.17.1"
}
}
55 changes: 55 additions & 0 deletions src/middleware/adminAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { timingSafeEqual } from 'crypto';
import type { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { InternalServerError, UnauthorizedError } from '../errors/index.js';

interface AdminJwtPayload {
role: string;
[key: string]: unknown;
}

/**
* Constant-time string comparison to prevent timing-based key enumeration.
* Returns false immediately if lengths differ (length is not secret here —
* the configured key length is not sensitive information).
*/
function timingSafeStringEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
}

export function adminAuth(req: Request, res: Response, next: NextFunction): void {
// Path 1: API key header — use timing-safe comparison to prevent key enumeration
const apiKey = req.header('x-admin-api-key');
const configuredKey = process.env.ADMIN_API_KEY;
if (apiKey && configuredKey && timingSafeStringEqual(apiKey, configuredKey)) {
res.locals.adminActor = 'admin-api-key';
next();
return;
}

// Path 2: Bearer JWT with admin role
const authHeader = req.header('Authorization');
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.slice(7);
const secret = process.env.JWT_SECRET;

if (!secret) {
next(new InternalServerError('JWT_SECRET not configured'));
return;
}

try {
const payload = jwt.verify(token, secret) as AdminJwtPayload;
if (payload.role === 'admin') {
res.locals.adminActor = (payload.sub as string) || (payload.email as string) || 'admin-jwt';
next();
return;
}
} catch {
// Fall through to 401
}
}

next(new UnauthorizedError('Unauthorized: admin access required'));
}
Loading
Loading