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:
40
packages/core-shared/src/audit/audit-entry.test.ts
Normal file
40
packages/core-shared/src/audit/audit-entry.test.ts
Normal 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>>>();
|
||||||
|
});
|
||||||
|
});
|
||||||
78
packages/core-shared/src/audit/audit-entry.ts
Normal file
78
packages/core-shared/src/audit/audit-entry.ts
Normal 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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user