From 1247e1804a9e1c21178a6a66616fa7aa31409a02 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Mon, 11 May 2026 16:03:19 +0200 Subject: [PATCH] feat(core-shared): AuditEntry type with closed action enum + required tenant Co-Authored-By: Claude Sonnet 4.6 --- .../core-shared/src/audit/audit-entry.test.ts | 40 ++++++++++ packages/core-shared/src/audit/audit-entry.ts | 78 +++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 packages/core-shared/src/audit/audit-entry.test.ts create mode 100644 packages/core-shared/src/audit/audit-entry.ts diff --git a/packages/core-shared/src/audit/audit-entry.test.ts b/packages/core-shared/src/audit/audit-entry.test.ts new file mode 100644 index 0000000..50cfe25 --- /dev/null +++ b/packages/core-shared/src/audit/audit-entry.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expectTypeOf } from "vitest"; +import type { AuditEntry, AuditAction, AuditFrom } from "./audit-entry"; + +describe("AuditAction", () => { + it("is a closed enum of 6 values", () => { + expectTypeOf().toEqualTypeOf< + "VIEW" | "CREATE" | "UPDATE" | "DELETE" | "EXPORT" | "PERMISSION_CHANGE" + >(); + }); +}); + +describe("AuditFrom", () => { + it("requires ipTruncated and userAgent", () => { + expectTypeOf().toEqualTypeOf<{ ipTruncated: string; userAgent: string }>(); + }); +}); + +describe("AuditEntry", () => { + it("requires the WHO/WHAT/WHEN/SCOPE/FROM/PII/OUTCOME fields", () => { + const entry: AuditEntry = { + actorId: "user_1", + actorType: "user", + actorRoles: ["admin"], + action: "VIEW", + resource: { type: "articles" }, + at: new Date(), + scope: { feature: "blog", environment: "test", tenant: "default" }, + from: { ipTruncated: "10.0.0.0", userAgent: "test" }, + containsPii: false, + outcome: "success", + }; + expectTypeOf(entry).toMatchTypeOf(); + }); + + it("makes optional fields actually optional", () => { + type Entry = AuditEntry; + type OptionalKeys = "changedFields" | "reason" | "correlationId" | "requestId" | "piiCategories" | "errorCode"; + expectTypeOf>().toEqualTypeOf>>(); + }); +}); diff --git a/packages/core-shared/src/audit/audit-entry.ts b/packages/core-shared/src/audit/audit-entry.ts new file mode 100644 index 0000000..a7f7065 --- /dev/null +++ b/packages/core-shared/src/audit/audit-entry.ts @@ -0,0 +1,78 @@ +/** + * Closed enum of audited actions per DPA. New action types require an + * explicit type bump — compliance auditors sample by enum value. + */ +export type AuditAction = + | "VIEW" + | "CREATE" + | "UPDATE" + | "DELETE" + | "EXPORT" + | "PERMISSION_CHANGE"; + +/** + * `from_where` fragment per DPA. IP truncated to /24 (IPv4) or /48 (IPv6) + * before storage; use `truncateIp(rawIp)` to enforce. For non-HTTP contexts, + * sentinels are conventional: `{ ipTruncated: "system", userAgent: "background-job" }`. + */ +export type AuditFrom = { + ipTruncated: string; + userAgent: string; +}; + +/** + * Universal audit entry. By construction, this type has NO `payload`/`body`/ + * `oldValue`/`newValue` fields — the DPA "what NOT to log" exclusion list is + * enforced by the type itself. UPDATE actions capture field NAMES only + * (`changedFields`); per-collection value capture is a separate API (out of + * scope for v1). + */ +export type AuditEntry = { + // WHO + /** User id, or "system"/"service-{name}" for non-user actors. NEVER email or name (R36). */ + actorId: string; + actorType: "user" | "system" | "service"; + /** Snapshot of actor's roles AT TIME OF ACTION — preserves historical state. */ + actorRoles: string[]; + + // WHAT + action: AuditAction; + resource: { type: string; id?: string }; + /** UPDATE only: names of fields that changed (NOT values — PII risk). */ + changedFields?: string[]; + + // WHEN + /** Server time. Sinks serialize as ISO 8601. */ + at: Date; + + // SCOPE (where) + scope: { + feature: string; + environment: string; + /** Required field. Single-tenant projects use "default" as the sentinel. */ + tenant: string; + }; + + // WHY + reason?: string; + /** OTel trace ID. Auto-populated by `TraceIdEnrichingAuditLog` decorator at bind time. */ + correlationId?: string; + requestId?: string; + + // FROM (per DPA) + from: AuditFrom; + + // PII CLASSIFICATION + /** Caller MUST declare. Drives downstream retention/access policies. */ + containsPii: boolean; + /** + * Free-form list. Conventions (suggested, not enforced): "email", "name", + * "phone", "address", "ssn", "financial", "health". Free-form because + * regulatory categories differ by jurisdiction. + */ + piiCategories?: string[]; + + // OUTCOME + outcome: "success" | "denied" | "error"; + errorCode?: string; +};