diff --git a/src/auditLog.test.ts b/src/auditLog.test.ts new file mode 100644 index 0000000..0d23d7e --- /dev/null +++ b/src/auditLog.test.ts @@ -0,0 +1,126 @@ +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert"; +import { AuditLog, redactSecrets } from "./auditLog.js"; + +await describe("AuditLog", async () => { + let auditLog: AuditLog; + + await Promise.resolve(beforeEach(() => { + auditLog = new AuditLog(); + })); + + await it("records an entry for a mutation", () => { + const entry = auditLog.append({ + actor: "tenant-1", + action: "charge.create", + targetId: "ch_123", + targetType: "charge", + after: { amount: 100, currency: "USD" }, + }); + assert.match(entry.id, /^audit_/); + assert.strictEqual(entry.actor, "tenant-1"); + assert.strictEqual(entry.action, "charge.create"); + assert.strictEqual(entry.targetId, "ch_123"); + assert.strictEqual(auditLog.size(), 1); + }); + + await it("does not record an entry for a read", () => { + assert.strictEqual(auditLog.size(), 0); + }); + + await it("queries by target id", () => { + auditLog.append({ + actor: "tenant-1", + action: "charge.create", + targetId: "ch_123", + targetType: "charge", + after: { amount: 100 }, + }); + auditLog.append({ + actor: "tenant-1", + action: "charge.create", + targetId: "ch_456", + targetType: "charge", + after: { amount: 200 }, + }); + const entries = auditLog.queryByTarget("ch_123"); + assert.strictEqual(entries.length, 1); + assert.strictEqual(entries[0].targetId, "ch_123"); + }); + + await it("queries by actor", () => { + auditLog.append({ + actor: "tenant-a", + action: "charge.create", + targetId: "ch_1", + targetType: "charge", + after: { amount: 100 }, + }); + auditLog.append({ + actor: "tenant-b", + action: "charge.create", + targetId: "ch_2", + targetType: "charge", + after: { amount: 200 }, + }); + const entries = auditLog.queryByActor("tenant-a"); + assert.strictEqual(entries.length, 1); + assert.strictEqual(entries[0].actor, "tenant-a"); + }); + + await it("supports pagination", () => { + for (let i = 0; i < 5; i++) { + auditLog.append({ + actor: "tenant-1", + action: "charge.create", + targetId: `ch_${i}`, + targetType: "charge", + after: { amount: i }, + }); + } + const entries = auditLog.queryByActor("tenant-1", 2, 1); + assert.strictEqual(entries.length, 2); + assert.strictEqual(entries[0].targetId, "ch_1"); + assert.strictEqual(entries[1].targetId, "ch_2"); + }); + + await it("never redacts non-sensitive fields", () => { + const payload = { amount: 100, currency: "USD" }; + const redacted = redactSecrets(payload); + assert.strictEqual(redacted.amount, 100); + assert.strictEqual(redacted.currency, "USD"); + }); + + await it("redacts secrets from payloads", () => { + const payload = { + amount: 100, + apiToken: "super-secret", + password: "hunter2", + nested: { ssn: "123-45-6789", name: "Alice" }, + }; + const redacted = redactSecrets(payload); + assert.strictEqual(redacted.apiToken, "[REDACTED]"); + assert.strictEqual(redacted.password, "[REDACTED]"); + assert.strictEqual((redacted.nested as Record).ssn, "[REDACTED]"); + assert.strictEqual((redacted.nested as Record).name, "Alice"); + }); + + await it("entries are ordered by timestamp", () => { + auditLog.append({ + actor: "tenant-1", + action: "charge.create", + targetId: "ch_1", + targetType: "charge", + after: { amount: 100 }, + }); + auditLog.append({ + actor: "tenant-1", + action: "charge.create", + targetId: "ch_2", + targetType: "charge", + after: { amount: 200 }, + }); + const entries = auditLog.queryByActor("tenant-1"); + assert.ok(entries[0].timestamp <= entries[1].timestamp); + }); +}); diff --git a/src/auditLog.ts b/src/auditLog.ts new file mode 100644 index 0000000..366e43c --- /dev/null +++ b/src/auditLog.ts @@ -0,0 +1,82 @@ +import { randomUUID } from "node:crypto"; + +export type AuditAction = "charge.create" | "charge.update" | "charge.refund"; + +export interface AuditEntry { + id: string; + actor: string; // tenantId or apiKey prefix + action: AuditAction; + targetId: string; + targetType: string; + before?: Record; + after?: Record; + timestamp: number; + requestId?: string; +} + +/** + * Append-only, immutable audit log for state-changing payments operations. + * Entries are never updated or deleted. + */ +export class AuditLog { + private readonly entries: AuditEntry[] = []; + + append(entry: Omit): AuditEntry { + const fullEntry: AuditEntry = { + ...entry, + id: `audit_${randomUUID()}`, + timestamp: Date.now(), + }; + this.entries.push(fullEntry); + return fullEntry; + } + + /** Query by target id, ordered by timestamp ascending. */ + queryByTarget(targetId: string, limit?: number, offset?: number): AuditEntry[] { + const matches = this.entries.filter((e) => e.targetId === targetId); + return this.paginate(matches, limit, offset); + } + + /** Query by actor, ordered by timestamp ascending. */ + queryByActor(actor: string, limit?: number, offset?: number): AuditEntry[] { + const matches = this.entries.filter((e) => e.actor === actor); + return this.paginate(matches, limit, offset); + } + + /** Total count of entries. */ + size(): number { + return this.entries.length; + } + + /** Clear all entries (test helper only). */ + clear(): void { + this.entries.length = 0; + } + + private paginate(items: AuditEntry[], limit?: number, offset?: number): AuditEntry[] { + const start = offset ?? 0; + const end = limit === undefined ? undefined : start + limit; + return items.slice(start, end); + } +} + +/** + * Redacts sensitive fields from a payload before recording in the audit log. + * Removes any key that looks like a secret, token, password, or PII. + */ +export function redactSecrets( + payload: Record +): Record { + const sensitiveKeys = /token|secret|password|ssn|sin|pin|cvv|authorization|auth/i; + const redacted: Record = {}; + for (const [key, value] of Object.entries(payload)) { + if (sensitiveKeys.test(key)) { + redacted[key] = "[REDACTED]"; + } else if (typeof value === "object" && value !== null && !Array.isArray(value)) { + redacted[key] = redactSecrets(value as Record); + } else { + redacted[key] = value; + } + } + return redacted; +} diff --git a/src/charges.ts b/src/charges.ts index 0573915..f403904 100644 --- a/src/charges.ts +++ b/src/charges.ts @@ -1,4 +1,5 @@ import { createHash, randomUUID } from "node:crypto"; +import { AuditLog, redactSecrets } from "./auditLog.js"; export const CHARGE_KEY_TTL_MS = 24 * 60 * 60 * 1000; export const MAX_IDEMPOTENCY_KEY_LENGTH = 255; @@ -139,6 +140,11 @@ export class InMemoryChargeIdempotencyStore implements ChargeIdempotencyStore { export class InMemoryChargeStore { private readonly charges: Charge[] = []; + private readonly auditLog?: AuditLog; + + constructor(auditLog?: AuditLog) { + this.auditLog = auditLog; + } create(tenantId: string, input: ChargeInput, now = Date.now()): Charge { const charge: Charge = { @@ -149,6 +155,13 @@ export class InMemoryChargeStore { status: "succeeded", }; this.charges.push(charge); + this.auditLog?.append({ + actor: tenantId, + action: "charge.create", + targetId: charge.id, + targetType: "charge", + after: redactSecrets(charge as unknown as Record), + }); return cloneCharge(charge); } diff --git a/src/routes/charges.ts b/src/routes/charges.ts index 801a004..c9fc764 100644 --- a/src/routes/charges.ts +++ b/src/routes/charges.ts @@ -16,17 +16,26 @@ import { paymentValidationError, } from "../errors/domainError.js"; import { paymentErrorHandler } from "../middleware/paymentErrorHandler.js"; +import { parseIntParam } from "../queryParams.js"; +import { AuditLog } from "../auditLog.js"; export type ChargesRouterOptions = { idempotencyStore?: InMemoryChargeIdempotencyStore; chargeStore?: InMemoryChargeStore; + auditLog?: AuditLog; }; +function firstQueryString(value: unknown): string { + const raw = Array.isArray(value) ? value[0] : value; + return typeof raw === "string" ? raw : ""; +} + export function createChargesRouter(options: ChargesRouterOptions = {}): Router { const router = Router(); + const auditLog = options.auditLog ?? new AuditLog(); const idempotencyStore = options.idempotencyStore ?? new InMemoryChargeIdempotencyStore(); - const chargeStore = options.chargeStore ?? new InMemoryChargeStore(); + const chargeStore = options.chargeStore ?? new InMemoryChargeStore(auditLog); router.post("/api/v1/charges", (req: Request, res: Response, next: NextFunction) => { const requestId = paymentRequestId(req); @@ -114,6 +123,38 @@ export function createChargesRouter(options: ChargesRouterOptions = {}): Router } ); + router.get("/api/v1/charges/:chargeId/audit", (req: Request, res: Response) => { + const chargeId = String(req.params.chargeId); + const limit = parseIntParam(req.query.limit, { + defaultValue: 100, + min: 1, + max: 500, + }); + const offset = parseIntParam(req.query.offset, { + defaultValue: 0, + min: 0, + max: 10_000, + }); + const entries = auditLog.queryByTarget(chargeId, limit, offset); + res.status(200).json({ entries, requestId: paymentRequestId(req) }); + }); + + router.get("/api/v1/audit", (req: Request, res: Response) => { + const actor = firstQueryString(req.query.actor); + const limit = parseIntParam(req.query.limit, { + defaultValue: 100, + min: 1, + max: 500, + }); + const offset = parseIntParam(req.query.offset, { + defaultValue: 0, + min: 0, + max: 10_000, + }); + const entries = auditLog.queryByActor(actor, limit, offset); + res.status(200).json({ entries, requestId: paymentRequestId(req) }); + }); + router.use(paymentErrorHandler); return router;