feat(core-shared): AuditEntry type with closed action enum + required tenant

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-11 16:03:19 +02:00
parent ac8dfcc5d4
commit 1247e1804a
2 changed files with 118 additions and 0 deletions

View File

@@ -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<AuditAction>().toEqualTypeOf<
"VIEW" | "CREATE" | "UPDATE" | "DELETE" | "EXPORT" | "PERMISSION_CHANGE"
>();
});
});
describe("AuditFrom", () => {
it("requires ipTruncated and userAgent", () => {
expectTypeOf<AuditFrom>().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<AuditEntry>();
});
it("makes optional fields actually optional", () => {
type Entry = AuditEntry;
type OptionalKeys = "changedFields" | "reason" | "correlationId" | "requestId" | "piiCategories" | "errorCode";
expectTypeOf<Pick<Entry, OptionalKeys>>().toEqualTypeOf<Partial<Pick<Entry, OptionalKeys>>>();
});
});

View File

@@ -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;
};