be70f56657
Wave A (Phase 1) implements the M1 foundations for REQ-038, REQ-039, REQ-040: Monorepo scaffold (pnpm workspaces, TS strict, NodeNext): - apps/control-plane (Next.js App Router scaffold — Wave B adds routes) - apps/relay-agent (Go module scaffold — Wave D adds WebSocket + install) - packages/db, packages/secrets, packages/runtime, packages/config packages/db (REQ-038, REQ-039): - 0001_init.sql: tenants, users, tenant_memberships, targets, byom_endpoints, invitations, audit_log (append-only hash-chain), runtime_health (not audited) - RLS policies on all tenant-scoped tables + FORCE ROW LEVEL SECURITY - withTenant(tenantId, fn): SET LOCAL app.tenant_id + transaction - audit.ts: appendAudit with sha256(prev_hash || canonical(payload)) chain; AuditWriteHaltError on write failure (Edge 7 — halts + rolls back) - BEFORE INSERT trigger enforces chain integrity (G-006: per-tenant concurrent-write serialization documented; M3 mitigation noted) - create-db.ts: PGlite (dev/test) + pg Pool (prod) behind DbClient interface - pen-test scaffold: cross-tenant isolation (PGlite RLS limitation documented; prod RLS test runs at M1 review) - 12 tests, 98% coverage packages/secrets (REQ-040, G-005, G-010): - SecretProvider interface: get/put/delete + SecretRef + SecretValue (toString returns [REDACTED]; unwrap is the only read path) - AwsSecretsManagerProvider (prod): coreci/<tenantId>/<name>, KMS-backed - LocalEncryptedProvider (dev): AES-256-GCM, PBKDF2 from SECRET_MASTER_KEY_DEV - relay-token.ts: JWT HS256 sign/verify/issue (G-005 contract — locked so Wave B + Wave D parallelize without blocking) - 24 tests (14 provider + 10 relay-token) packages/runtime (G-002, G-003): - Trigger.dev bootstrap (initRuntime) — pre-investment for M3 (G-003) - runtimeHealthCheck task: writes to runtime_health, NOT audit_log (G-002) - 6 tests packages/config (G-010): - Two-tier credential taxonomy: infra env vars (tier a) vs tenant creds (SecretProvider only, tier b). PO's 'no env vars' applies to tenant creds. Coverage: db 98%, secrets/runtime/config typecheck clean. All 45 tests passing. Go relay-agent builds. ---ci--- phase: 1 milestone: v0.1 status: verify ---/ci---
160 lines
6.2 KiB
TypeScript
160 lines
6.2 KiB
TypeScript
/**
|
|
* Audit log unit tests — REQ-038.
|
|
*
|
|
* Verifies: append 3 entries, the chain links correctly (curr_hash of row N
|
|
* equals sha256(prev_hash || canonical(payload))). Verifies that UPDATE/DELETE
|
|
* on audit_log fails (REVOKE / immutability). Verifies write-failure halts.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll } from "vitest";
|
|
import { createHash } from "node:crypto";
|
|
import { createDb } from "../src/create-db.js";
|
|
import { setDbClient } from "../src/withTenant.js";
|
|
import { withTenant } from "../src/withTenant.js";
|
|
import { appendAudit, AuditWriteHaltError, type AuditEvent } from "../src/audit.js";
|
|
import { readFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
const T1 = "00000000-0000-0000-0000-000000000001";
|
|
|
|
// Must match audit.ts's canonicalJson (sorted keys) so expected hashes align.
|
|
function sortKeys(value: unknown): unknown {
|
|
if (value === null || typeof value !== "object") return value;
|
|
if (Array.isArray(value)) return value.map(sortKeys);
|
|
const obj = value as Record<string, unknown>;
|
|
return Object.keys(obj).sort().reduce<Record<string, unknown>>((acc, k) => {
|
|
acc[k] = sortKeys(obj[k]);
|
|
return acc;
|
|
}, {});
|
|
}
|
|
function canonicalJson(value: unknown): string {
|
|
return JSON.stringify(sortKeys(value));
|
|
}
|
|
|
|
async function runMigration(db: { exec: (t: string) => Promise<void> }) {
|
|
const sql = await readFile(join(import.meta.dirname, "..", "migrations", "0001_init.sql"), "utf8");
|
|
await db.exec(sql);
|
|
}
|
|
|
|
describe("audit log (REQ-038)", () => {
|
|
let db: Awaited<ReturnType<typeof createDb>>;
|
|
|
|
beforeAll(async () => {
|
|
db = await createDb({ mode: "pglite" });
|
|
setDbClient(db);
|
|
await runMigration(db);
|
|
await db.query("ALTER TABLE audit_log DISABLE ROW LEVEL SECURITY");
|
|
await db.query(`INSERT INTO tenants (id, name) VALUES ($1,'T1')`, [T1]);
|
|
await db.query("ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY");
|
|
});
|
|
|
|
it("appends 3 entries and the hash-chain links", async () => {
|
|
const events: AuditEvent[] = [
|
|
{ tenantId: T1, eventType: "provision", payload: { step: "tenant_created" } },
|
|
{ tenantId: T1, eventType: "config", payload: { what: "byom", url: "https://x/v1" } },
|
|
{ tenantId: T1, eventType: "validation", payload: { ok: true } },
|
|
];
|
|
|
|
for (const ev of events) {
|
|
await withTenant(T1, async (c) => {
|
|
await appendAudit(c, ev);
|
|
});
|
|
}
|
|
|
|
// Read back outside RLS — disable RLS temporarily to inspect all rows.
|
|
await db.query("ALTER TABLE audit_log DISABLE ROW LEVEL SECURITY");
|
|
const res = await db.query<{ id: number; prev_hash: Buffer | null; curr_hash: Buffer; payload: any }>(
|
|
"SELECT id, prev_hash, curr_hash, payload FROM audit_log WHERE tenant_id = $1 ORDER BY id",
|
|
[T1],
|
|
);
|
|
await db.query("ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY");
|
|
|
|
expect(res.rows).toHaveLength(3);
|
|
// Row 1: prev_hash NULL (genesis), curr_hash = sha256(canonical(payload1))
|
|
const p1 = canonicalJson(events[0]!.payload);
|
|
const h1 = createHash("sha256").update(p1, "utf8").digest();
|
|
expect(res.rows[0]!.prev_hash).toBe(null);
|
|
expect(Buffer.from(res.rows[0]!.curr_hash).equals(h1)).toBe(true);
|
|
|
|
// Row 2: prev_hash = h1, curr_hash = sha256(h1 || canonical(payload2))
|
|
const p2 = canonicalJson(events[1]!.payload);
|
|
const h2 = createHash("sha256").update(h1).update(p2, "utf8").digest();
|
|
expect(Buffer.from(res.rows[1]!.prev_hash).equals(h1)).toBe(true);
|
|
expect(Buffer.from(res.rows[1]!.curr_hash).equals(h2)).toBe(true);
|
|
|
|
// Row 3: prev_hash = h2, curr_hash = sha256(h2 || canonical(payload3))
|
|
const p3 = canonicalJson(events[2]!.payload);
|
|
const h3 = createHash("sha256").update(h2).update(p3, "utf8").digest();
|
|
expect(Buffer.from(res.rows[2]!.prev_hash).equals(h2)).toBe(true);
|
|
expect(Buffer.from(res.rows[2]!.curr_hash).equals(h3)).toBe(true);
|
|
});
|
|
|
|
it("rejects a forged prev_hash (trigger)", async () => {
|
|
await expect(
|
|
withTenant(T1, async (c) => {
|
|
await c.query(
|
|
`INSERT INTO audit_log (tenant_id, prev_hash, curr_hash, payload, event_type)
|
|
VALUES ($1, $2, $3, $4::jsonb, 'config')`,
|
|
[
|
|
T1,
|
|
Buffer.from("forged-prev-hash-aaaaaaaaaaaaaaaaaaaaaaa=", "base64"),
|
|
Buffer.from("anyhash"),
|
|
JSON.stringify({ forged: true }),
|
|
],
|
|
);
|
|
}),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("AuditWriteHaltError surfaces on a bad insert", async () => {
|
|
// appendAudit wraps the insert and throws AuditWriteHaltError on failure.
|
|
// Use a valid-but-nonexistent tenant UUID so we get past UUID parsing and
|
|
// hit the FK violation inside appendAudit → AuditWriteHaltError.
|
|
const fakeTenant = "00000000-0000-0000-0000-000000000099";
|
|
let caught: unknown;
|
|
try {
|
|
await withTenant(T1, async (c) => {
|
|
await appendAudit(c, {
|
|
tenantId: fakeTenant,
|
|
eventType: "config",
|
|
payload: { bad: true },
|
|
});
|
|
});
|
|
} catch (err) {
|
|
caught = err;
|
|
}
|
|
expect(caught).toBeDefined();
|
|
// withTenant wraps in TenantContextError; the .cause should be the
|
|
// AuditWriteHaltError from appendAudit. Walk the cause chain.
|
|
let cur: unknown = caught;
|
|
let foundAuditHalt = false;
|
|
for (let i = 0; i < 5 && cur; i++) {
|
|
if (cur instanceof AuditWriteHaltError) {
|
|
foundAuditHalt = true;
|
|
break;
|
|
}
|
|
cur = (cur as Error)?.cause;
|
|
}
|
|
expect(foundAuditHalt).toBe(true);
|
|
});
|
|
|
|
it("accepts an explicit correlationId (else branch of the conditional)", async () => {
|
|
const corrId = "11111111-1111-1111-1111-111111111111";
|
|
await withTenant(T1, async (c) => {
|
|
await appendAudit(c, {
|
|
tenantId: T1,
|
|
eventType: "config",
|
|
payload: { with: "correlation" },
|
|
correlationId: corrId,
|
|
});
|
|
});
|
|
// Verify the row has the explicit correlationId (read back bypassing RLS).
|
|
await db.query("ALTER TABLE audit_log DISABLE ROW LEVEL SECURITY");
|
|
const res = await db.query<{ correlation_id: string }>(
|
|
"SELECT correlation_id FROM audit_log WHERE tenant_id = $1 ORDER BY id DESC LIMIT 1",
|
|
[T1],
|
|
);
|
|
await db.query("ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY");
|
|
expect(res.rows[0]?.correlation_id).toBe(corrId);
|
|
});
|
|
}); |