Initial commit
This commit is contained in:
28
packages/core-audit/AGENTS.md
Normal file
28
packages/core-audit/AGENTS.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# @repo/core-audit
|
||||
|
||||
Optional core package providing DPA-compliant audit logging. Scaffold via `pnpm turbo gen core-package audit`.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/
|
||||
audit-log.interface.ts # IAuditLog extends AuditLogProtocol
|
||||
audit-logs-collection.ts # Payload collection (append-only)
|
||||
noop-audit-log.ts # NoopAuditLog
|
||||
payload-audit-log.ts # PayloadAuditLog (local cache impl)
|
||||
stdout-json-audit-log.ts # StdoutJsonAuditLog (log-shipper sink)
|
||||
multi-sink-audit-log.ts # MultiSinkAuditLog (fan-out wrapper)
|
||||
trace-id-enriching-audit-log.ts # OTel correlation decorator
|
||||
pseudonymize.ts # sha256-with-salt for GDPR pseudonymization
|
||||
di/bind-audit.ts # bindAudit binder
|
||||
integrations/api/router.ts # admin tRPC procedure
|
||||
hooks/ # Payload hook factories
|
||||
```
|
||||
|
||||
## Compliance posture
|
||||
|
||||
- `AuditEntry` type (in `@repo/core-shared/audit`) has no `payload`/`body`/`oldValue`/`newValue` fields — type system enforces DPA "what NOT to log".
|
||||
- Append-only Payload collection (`update: () => false`); erasure uses `overrideAccess: true` for the privileged path.
|
||||
- `AUDIT_PSEUDONYM_SALT` env REQUIRED in production. Validated at bind time.
|
||||
|
||||
See `docs/guides/audit-and-compliance.md` for the full guide.
|
||||
3
packages/core-audit/eslint.config.js
Normal file
3
packages/core-audit/eslint.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "@repo/core-eslint/base";
|
||||
|
||||
export default baseConfig;
|
||||
46
packages/core-audit/package.json
Normal file
46
packages/core-audit/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@repo/core-audit",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./collection": "./src/audit-logs-collection.ts",
|
||||
"./di": "./src/di/bind-audit.ts",
|
||||
"./hooks": "./src/hooks/index.ts",
|
||||
"./api": "./src/integrations/api/router.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/core-shared": "workspace:*",
|
||||
"@trpc/server": "^11.0.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"payload": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"payload": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/api-logs": "^0.55.0",
|
||||
"@opentelemetry/context-async-hooks": "^1.28.0",
|
||||
"@opentelemetry/sdk-trace-base": "^1.27.0",
|
||||
"@repo/core-eslint": "workspace:*",
|
||||
"@repo/core-testing": "workspace:*",
|
||||
"@repo/core-typescript": "workspace:*",
|
||||
"inversify": "^6.2.0",
|
||||
"payload": "^3.14.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
22
packages/core-audit/src/audit-log.interface.ts
Normal file
22
packages/core-audit/src/audit-log.interface.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { AuditLogProtocol } from "@repo/core-shared/di/bind-protocols";
|
||||
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||
|
||||
/**
|
||||
* Full audit log interface. Extends the minimal `AuditLogProtocol` from
|
||||
* core-shared with the privileged `eraseSubject` op for GDPR erasure.
|
||||
*
|
||||
* Feature binders that receive `ctx.auditLog` see only `AuditLogProtocol`
|
||||
* (record). Admin-path code that needs erasure imports this full interface.
|
||||
*
|
||||
* The `extends` link forces typecheck failure if either side narrows below
|
||||
* the protocol surface — same safety net as IEventBus, IRealtimeBroadcaster,
|
||||
* IRealtimeHandlerRegistry, IMetrics.
|
||||
*/
|
||||
export interface IAuditLog extends AuditLogProtocol {
|
||||
// record(entry: AuditEntry): Promise<void> — inherited from protocol
|
||||
eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void>;
|
||||
}
|
||||
|
||||
// Re-export AuditEntry for convenience (so consumers don't always need
|
||||
// to dual-import from @repo/core-shared/audit).
|
||||
export type { AuditEntry };
|
||||
47
packages/core-audit/src/audit-logs-collection.test.ts
Normal file
47
packages/core-audit/src/audit-logs-collection.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { auditLogsCollection } from "./audit-logs-collection";
|
||||
|
||||
describe("auditLogsCollection", () => {
|
||||
it("uses slug 'audit-logs'", () => {
|
||||
expect(auditLogsCollection.slug).toBe("audit-logs");
|
||||
});
|
||||
|
||||
it("is append-only (update: () => false)", () => {
|
||||
const access = auditLogsCollection.access as Record<string, (() => boolean) | undefined>;
|
||||
expect(access["update"]?.()).toBe(false);
|
||||
});
|
||||
|
||||
it("has the required fields", () => {
|
||||
const fieldNames = (auditLogsCollection.fields as Array<{ name: string }>).map((f) => f.name);
|
||||
// WHO
|
||||
expect(fieldNames).toContain("actorId");
|
||||
expect(fieldNames).toContain("actorType");
|
||||
expect(fieldNames).toContain("actorRoles");
|
||||
// WHAT
|
||||
expect(fieldNames).toContain("action");
|
||||
expect(fieldNames).toContain("resourceType");
|
||||
expect(fieldNames).toContain("resourceId");
|
||||
expect(fieldNames).toContain("changedFields");
|
||||
// SCOPE
|
||||
expect(fieldNames).toContain("scopeFeature");
|
||||
expect(fieldNames).toContain("scopeEnvironment");
|
||||
expect(fieldNames).toContain("scopeTenant");
|
||||
// WHY
|
||||
expect(fieldNames).toContain("reason");
|
||||
expect(fieldNames).toContain("correlationId");
|
||||
expect(fieldNames).toContain("requestId");
|
||||
// FROM
|
||||
expect(fieldNames).toContain("ipTruncated");
|
||||
expect(fieldNames).toContain("userAgent");
|
||||
// PII
|
||||
expect(fieldNames).toContain("containsPii");
|
||||
expect(fieldNames).toContain("piiCategories");
|
||||
// OUTCOME
|
||||
expect(fieldNames).toContain("outcome");
|
||||
expect(fieldNames).toContain("errorCode");
|
||||
});
|
||||
|
||||
it("enables timestamps so createdAt maps to AuditEntry.at", () => {
|
||||
expect(auditLogsCollection.timestamps).toBe(true);
|
||||
});
|
||||
});
|
||||
82
packages/core-audit/src/audit-logs-collection.ts
Normal file
82
packages/core-audit/src/audit-logs-collection.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
|
||||
/**
|
||||
* Append-only Payload collection for audit entries. Mounted by core-cms
|
||||
* when this package is scaffolded (manual wiring step printed by generator).
|
||||
*
|
||||
* Access rules:
|
||||
* - read: admins only
|
||||
* - create: any authenticated context (filtered upstream by PayloadAuditLog)
|
||||
* - update: NEVER (compliance requires append-only)
|
||||
* - delete: admins only (used by the GDPR erasure path with overrideAccess)
|
||||
*
|
||||
* The `update: () => false` rule is the compliance backbone. The erasure
|
||||
* path uses `overrideAccess: true` to bypass for pseudonymization — that's
|
||||
* Payload's documented escape hatch for privileged operations.
|
||||
*/
|
||||
export const auditLogsCollection: CollectionConfig = {
|
||||
slug: "audit-logs",
|
||||
access: {
|
||||
read: ({ req }) => {
|
||||
const user = req.user as { roles?: string[] } | null | undefined;
|
||||
return Array.isArray(user?.roles) && user.roles.includes("admin");
|
||||
},
|
||||
create: () => true,
|
||||
update: () => false,
|
||||
delete: ({ req }) => {
|
||||
const user = req.user as { roles?: string[] } | null | undefined;
|
||||
return Array.isArray(user?.roles) && user.roles.includes("admin");
|
||||
},
|
||||
},
|
||||
timestamps: true,
|
||||
fields: [
|
||||
// WHO
|
||||
{ name: "actorId", type: "text", required: true, index: true },
|
||||
{
|
||||
name: "actorType",
|
||||
type: "select",
|
||||
options: ["user", "system", "service"],
|
||||
required: true,
|
||||
},
|
||||
{ name: "actorRoles", type: "json", required: true },
|
||||
|
||||
// WHAT
|
||||
{
|
||||
name: "action",
|
||||
type: "select",
|
||||
options: ["VIEW", "CREATE", "UPDATE", "DELETE", "EXPORT", "PERMISSION_CHANGE"],
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{ name: "resourceType", type: "text", required: true, index: true },
|
||||
{ name: "resourceId", type: "text" },
|
||||
{ name: "changedFields", type: "json" },
|
||||
|
||||
// SCOPE
|
||||
{ name: "scopeFeature", type: "text", required: true, index: true },
|
||||
{ name: "scopeEnvironment", type: "text", required: true },
|
||||
{ name: "scopeTenant", type: "text", required: true, index: true },
|
||||
|
||||
// WHY
|
||||
{ name: "reason", type: "text" },
|
||||
{ name: "correlationId", type: "text", index: true },
|
||||
{ name: "requestId", type: "text" },
|
||||
|
||||
// FROM
|
||||
{ name: "ipTruncated", type: "text", required: true },
|
||||
{ name: "userAgent", type: "text", required: true },
|
||||
|
||||
// PII
|
||||
{ name: "containsPii", type: "checkbox", required: true },
|
||||
{ name: "piiCategories", type: "json" },
|
||||
|
||||
// OUTCOME
|
||||
{
|
||||
name: "outcome",
|
||||
type: "select",
|
||||
options: ["success", "denied", "error"],
|
||||
required: true,
|
||||
},
|
||||
{ name: "errorCode", type: "text" },
|
||||
],
|
||||
};
|
||||
58
packages/core-audit/src/di/bind-audit.test.ts
Normal file
58
packages/core-audit/src/di/bind-audit.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import "reflect-metadata";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Container } from "inversify";
|
||||
import { bindAudit } from "./bind-audit";
|
||||
import { AUDIT_SYMBOLS } from "./symbols";
|
||||
import { NoopAuditLog } from "../noop-audit-log";
|
||||
import { StdoutJsonAuditLog } from "../stdout-json-audit-log";
|
||||
import { PayloadAuditLog } from "../payload-audit-log";
|
||||
import { MultiSinkAuditLog } from "../multi-sink-audit-log";
|
||||
import { TraceIdEnrichingAuditLog } from "../trace-id-enriching-audit-log";
|
||||
import type { IAuditLog } from "../audit-log.interface";
|
||||
|
||||
describe("bindAudit", () => {
|
||||
it("defaults to MultiSinkAuditLog([payload, stdout]) when payloadConfig is provided", () => {
|
||||
const container = new Container();
|
||||
bindAudit(container, { payloadConfig: {} as never });
|
||||
const auditLog = container.get<IAuditLog>(AUDIT_SYMBOLS.IAuditLog);
|
||||
expect(auditLog).toBeInstanceOf(TraceIdEnrichingAuditLog);
|
||||
expect((auditLog as unknown as { inner: unknown }).inner).toBeInstanceOf(MultiSinkAuditLog);
|
||||
});
|
||||
|
||||
it("returns StdoutJsonAuditLog alone when payloadConfig omitted + default sinks", () => {
|
||||
const container = new Container();
|
||||
bindAudit(container, {});
|
||||
const auditLog = container.get<IAuditLog>(AUDIT_SYMBOLS.IAuditLog);
|
||||
expect(auditLog).toBeInstanceOf(TraceIdEnrichingAuditLog);
|
||||
expect((auditLog as unknown as { inner: unknown }).inner).toBeInstanceOf(StdoutJsonAuditLog);
|
||||
});
|
||||
|
||||
it("returns NoopAuditLog when sinks=[]", () => {
|
||||
const container = new Container();
|
||||
bindAudit(container, { sinks: [] });
|
||||
const auditLog = container.get<IAuditLog>(AUDIT_SYMBOLS.IAuditLog);
|
||||
expect(auditLog).toBeInstanceOf(TraceIdEnrichingAuditLog);
|
||||
expect((auditLog as unknown as { inner: unknown }).inner).toBeInstanceOf(NoopAuditLog);
|
||||
});
|
||||
|
||||
it("returns PayloadAuditLog when sinks=['payload'] only", () => {
|
||||
const container = new Container();
|
||||
bindAudit(container, { payloadConfig: {} as never, sinks: ["payload"] });
|
||||
const auditLog = container.get<IAuditLog>(AUDIT_SYMBOLS.IAuditLog);
|
||||
expect(auditLog).toBeInstanceOf(TraceIdEnrichingAuditLog);
|
||||
expect((auditLog as unknown as { inner: unknown }).inner).toBeInstanceOf(PayloadAuditLog);
|
||||
});
|
||||
|
||||
it("validates AUDIT_PSEUDONYM_SALT in production", () => {
|
||||
const env = process.env as Record<string, string | undefined>;
|
||||
const oldEnv = env["NODE_ENV"];
|
||||
const oldSalt = env["AUDIT_PSEUDONYM_SALT"];
|
||||
env["NODE_ENV"] = "production";
|
||||
delete env["AUDIT_PSEUDONYM_SALT"];
|
||||
expect(() => bindAudit(new Container(), { sinks: ["stdout"] })).toThrow(
|
||||
/AUDIT_PSEUDONYM_SALT/,
|
||||
);
|
||||
env["NODE_ENV"] = oldEnv;
|
||||
if (oldSalt) env["AUDIT_PSEUDONYM_SALT"] = oldSalt;
|
||||
});
|
||||
});
|
||||
71
packages/core-audit/src/di/bind-audit.ts
Normal file
71
packages/core-audit/src/di/bind-audit.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import "reflect-metadata";
|
||||
import type { Container } from "inversify";
|
||||
import { getPayload as _getPayload, type SanitizedConfig } from "payload";
|
||||
import { NoopAuditLog } from "../noop-audit-log";
|
||||
import { PayloadAuditLog } from "../payload-audit-log";
|
||||
import { StdoutJsonAuditLog } from "../stdout-json-audit-log";
|
||||
import { MultiSinkAuditLog } from "../multi-sink-audit-log";
|
||||
import type { IAuditLog } from "../audit-log.interface";
|
||||
import { AUDIT_SYMBOLS } from "./symbols";
|
||||
import { TraceIdEnrichingAuditLog } from "../trace-id-enriching-audit-log";
|
||||
|
||||
export type BindAuditOpts = {
|
||||
/** Payload config; required if "payload" is in sinks. */
|
||||
payloadConfig?: SanitizedConfig;
|
||||
/** Sink selection. Default ["payload", "stdout"]. */
|
||||
sinks?: ("payload" | "stdout")[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Binds an `IAuditLog` impl to the container under `AUDIT_SYMBOLS.IAuditLog`.
|
||||
*
|
||||
* Default sink set: ["payload", "stdout"] — Payload local cache + structured
|
||||
* JSON to stdout (operator wires a log shipper to the centralized aggregator).
|
||||
*
|
||||
* In production, AUDIT_PSEUDONYM_SALT env var MUST be set. Boot fails fast
|
||||
* if not — better to refuse to start than to ship audit data with a dev-fallback
|
||||
* salt that an attacker could reverse.
|
||||
*
|
||||
* The returned auditLog is wrapped in TraceIdEnrichingAuditLog
|
||||
* so all sinks receive AuditEntry.correlationId auto-populated from the
|
||||
* active OTel span. The inner sink/fan-out is accessible via `.inner`.
|
||||
*/
|
||||
export function bindAudit(
|
||||
container: Container,
|
||||
opts: BindAuditOpts = {},
|
||||
): { auditLog: IAuditLog } {
|
||||
if (
|
||||
process.env.NODE_ENV === "production" &&
|
||||
!process.env.AUDIT_PSEUDONYM_SALT
|
||||
) {
|
||||
throw new Error(
|
||||
"AUDIT_PSEUDONYM_SALT environment variable is required in production. " +
|
||||
"Generate via `openssl rand -hex 32` and store in your secrets manager.",
|
||||
);
|
||||
}
|
||||
|
||||
const sinkList = opts.sinks ?? ["payload", "stdout"];
|
||||
const sinks: IAuditLog[] = [];
|
||||
if (sinkList.includes("payload") && opts.payloadConfig) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
sinks.push(new PayloadAuditLog(opts.payloadConfig, _getPayload as any));
|
||||
}
|
||||
if (sinkList.includes("stdout")) {
|
||||
sinks.push(new StdoutJsonAuditLog());
|
||||
}
|
||||
|
||||
const inner: IAuditLog =
|
||||
sinks.length > 1
|
||||
? new MultiSinkAuditLog(sinks)
|
||||
: sinks.length === 1
|
||||
? sinks[0]!
|
||||
: new NoopAuditLog();
|
||||
const auditLog: IAuditLog = new TraceIdEnrichingAuditLog(inner);
|
||||
|
||||
if (container.isBound(AUDIT_SYMBOLS.IAuditLog)) {
|
||||
container.unbind(AUDIT_SYMBOLS.IAuditLog);
|
||||
}
|
||||
container.bind<IAuditLog>(AUDIT_SYMBOLS.IAuditLog).toConstantValue(auditLog);
|
||||
|
||||
return { auditLog };
|
||||
}
|
||||
3
packages/core-audit/src/di/symbols.ts
Normal file
3
packages/core-audit/src/di/symbols.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const AUDIT_SYMBOLS = {
|
||||
IAuditLog: Symbol.for("core-audit:IAuditLog"),
|
||||
} as const;
|
||||
103
packages/core-audit/src/hooks/audit-after-read-hook.test.ts
Normal file
103
packages/core-audit/src/hooks/audit-after-read-hook.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createAuditAfterReadHook } from "./audit-after-read-hook";
|
||||
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||
import type { IAuditLog } from "../audit-log.interface";
|
||||
|
||||
function makeAuditLog(): IAuditLog & { recorded: AuditEntry[] } {
|
||||
const recorded: AuditEntry[] = [];
|
||||
return {
|
||||
recorded,
|
||||
async record(e) { recorded.push(e); },
|
||||
eraseSubject: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function baseOpts(auditLog: IAuditLog) {
|
||||
return {
|
||||
auditLog,
|
||||
resourceType: "users",
|
||||
feature: "auth",
|
||||
environment: "test",
|
||||
resolveTenant: () => "default",
|
||||
containsPii: true,
|
||||
piiCategories: ["email"],
|
||||
};
|
||||
}
|
||||
|
||||
describe("createAuditAfterReadHook", () => {
|
||||
it("emits a VIEW entry with the resource type + feature + tenant", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const hook = createAuditAfterReadHook(baseOpts(auditLog));
|
||||
|
||||
const doc = { id: "abc", email: "x@y.com" };
|
||||
const req = { user: { id: "user_1", roles: ["user"] }, headers: { "user-agent": "Mozilla" }, ip: "10.0.0.5" };
|
||||
await hook({ doc, req } as never);
|
||||
|
||||
// Wait one tick for fire-and-forget to flush
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
expect(auditLog.recorded).toHaveLength(1);
|
||||
const e = auditLog.recorded[0]!;
|
||||
expect(e.action).toBe("VIEW");
|
||||
expect(e.resource.type).toBe("users");
|
||||
expect(e.resource.id).toBe("abc");
|
||||
expect(e.actorId).toBe("user_1");
|
||||
expect(e.actorRoles).toEqual(["user"]);
|
||||
expect(e.scope.feature).toBe("auth");
|
||||
expect(e.scope.tenant).toBe("default");
|
||||
expect(e.containsPii).toBe(true);
|
||||
expect(e.piiCategories).toEqual(["email"]);
|
||||
expect(e.outcome).toBe("success");
|
||||
expect(e.from.ipTruncated).toBe("10.0.0.0"); // /24 truncation applied
|
||||
});
|
||||
|
||||
it("uses 'system' actor when req.user is null", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const hook = createAuditAfterReadHook(baseOpts(auditLog));
|
||||
await hook({ doc: { id: "abc" }, req: { user: null, headers: {} } } as never);
|
||||
await new Promise((r) => setImmediate(r));
|
||||
expect(auditLog.recorded[0]!.actorId).toBe("system");
|
||||
expect(auditLog.recorded[0]!.actorType).toBe("system");
|
||||
});
|
||||
|
||||
it("falls back to 'internal' / 'payload-internal' sentinels when no IP/UA", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const hook = createAuditAfterReadHook(baseOpts(auditLog));
|
||||
await hook({ doc: { id: "abc" }, req: { user: null, headers: {} } } as never);
|
||||
await new Promise((r) => setImmediate(r));
|
||||
expect(auditLog.recorded[0]!.from.ipTruncated).toBe("internal");
|
||||
expect(auditLog.recorded[0]!.from.userAgent).toBe("payload-internal");
|
||||
});
|
||||
|
||||
it("shouldSkip predicate prevents emission", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const hook = createAuditAfterReadHook({ ...baseOpts(auditLog), shouldSkip: () => true });
|
||||
await hook({ doc: { id: "abc" }, req: { user: null, headers: {} } } as never);
|
||||
await new Promise((r) => setImmediate(r));
|
||||
expect(auditLog.recorded).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns the doc unchanged (afterRead hook contract)", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const hook = createAuditAfterReadHook(baseOpts(auditLog));
|
||||
const doc = { id: "abc", title: "Hello" };
|
||||
const result = await hook({ doc, req: { user: null, headers: {} } } as never);
|
||||
expect(result).toBe(doc);
|
||||
});
|
||||
|
||||
it("audit-sink failures do not propagate (fire-and-forget)", async () => {
|
||||
const auditLog: IAuditLog = {
|
||||
record: async () => { throw new Error("sink-failed"); },
|
||||
eraseSubject: vi.fn(),
|
||||
};
|
||||
const errSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
const hook = createAuditAfterReadHook(baseOpts(auditLog));
|
||||
await expect(
|
||||
hook({ doc: { id: "abc" }, req: { user: null, headers: {} } } as never),
|
||||
).resolves.toBeDefined();
|
||||
// Give the microtask queue a moment to flush the catch handler
|
||||
await new Promise((r) => setImmediate(r));
|
||||
expect(errSpy).toHaveBeenCalled();
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
105
packages/core-audit/src/hooks/audit-after-read-hook.ts
Normal file
105
packages/core-audit/src/hooks/audit-after-read-hook.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { CollectionAfterReadHook } from "payload";
|
||||
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||
import { truncateIp } from "@repo/core-shared/audit";
|
||||
import type { IAuditLog } from "../audit-log.interface";
|
||||
|
||||
export type AuditAfterReadHookOpts = {
|
||||
auditLog: IAuditLog;
|
||||
/** Resource type for AuditEntry.resource.type (e.g., "users"). */
|
||||
resourceType: string;
|
||||
/** Feature attribution for AuditEntry.scope.feature. */
|
||||
feature: string;
|
||||
/** Deployment environment. */
|
||||
environment: string;
|
||||
/** Tenant resolver — single-tenant projects return "default". */
|
||||
resolveTenant: (req: { user?: { id: string; tenantId?: string } | null }) => string;
|
||||
/** Whether this collection contains PII. Propagates to every entry. */
|
||||
containsPii: boolean;
|
||||
/** Optional PII categories applicable to all entries from this collection. */
|
||||
piiCategories?: string[];
|
||||
/** Optional predicate; return true to skip emitting an entry. */
|
||||
shouldSkip?: (args: { req: unknown; doc: { id: string | number } }) => boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Payload afterRead hook factory. Emits a VIEW AuditEntry per document read.
|
||||
* Per-collection opt-in: install via `hooks.afterRead: [createAuditAfterReadHook(...)]`
|
||||
* on the collection config.
|
||||
*
|
||||
* Fire-and-forget: a failing audit sink does NOT propagate up to break the
|
||||
* user-facing read. Failures emit a structured error to stderr (visible to
|
||||
* the same log shipper as audit entries themselves).
|
||||
*
|
||||
* Combine with use-case-level record() calls for app-facing reads; this hook
|
||||
* covers direct CMS/admin/programmatic reads. The use-case path captures
|
||||
* "why" (reason); this hook captures "the system saw this doc".
|
||||
*/
|
||||
export function createAuditAfterReadHook(
|
||||
opts: AuditAfterReadHookOpts,
|
||||
): CollectionAfterReadHook {
|
||||
return async ({ doc, req }) => {
|
||||
if (opts.shouldSkip?.({ req, doc: doc as { id: string | number } })) {
|
||||
return doc;
|
||||
}
|
||||
|
||||
const actor = (req as { user?: { id: string; roles?: string[]; tenantId?: string } | null }).user;
|
||||
const entry: AuditEntry = {
|
||||
actorId: actor?.id ?? "system",
|
||||
actorType: actor ? "user" : "system",
|
||||
actorRoles: actor?.roles ?? [],
|
||||
action: "VIEW",
|
||||
resource: {
|
||||
type: opts.resourceType,
|
||||
id: typeof doc.id === "string" || typeof doc.id === "number" ? String(doc.id) : undefined,
|
||||
},
|
||||
at: new Date(),
|
||||
scope: {
|
||||
feature: opts.feature,
|
||||
environment: opts.environment,
|
||||
tenant: opts.resolveTenant(req as { user?: { id: string; tenantId?: string } | null }),
|
||||
},
|
||||
reason: "payload-afterRead-hook",
|
||||
from: {
|
||||
ipTruncated: extractIpTruncated(req) ?? "internal",
|
||||
userAgent: extractUserAgent(req) ?? "payload-internal",
|
||||
},
|
||||
containsPii: opts.containsPii,
|
||||
piiCategories: opts.piiCategories,
|
||||
outcome: "success",
|
||||
};
|
||||
|
||||
// Fire-and-forget — never break the read.
|
||||
void opts.auditLog.record(entry).catch((err: unknown) => {
|
||||
process.stderr.write(
|
||||
JSON.stringify({
|
||||
_type: "audit-hook-error",
|
||||
hook: "afterRead",
|
||||
resourceType: opts.resourceType,
|
||||
error: String(err),
|
||||
at: new Date().toISOString(),
|
||||
}) + "\n",
|
||||
);
|
||||
});
|
||||
|
||||
return doc;
|
||||
};
|
||||
}
|
||||
|
||||
function extractIpTruncated(req: unknown): string | undefined {
|
||||
const r = req as { ip?: string; headers?: Record<string, string | string[] | undefined> };
|
||||
const rawIp = r.ip ?? r.headers?.["x-forwarded-for"];
|
||||
if (!rawIp) return undefined;
|
||||
const candidate = Array.isArray(rawIp) ? rawIp[0]! : rawIp.split(",")[0]!.trim();
|
||||
try {
|
||||
return truncateIp(candidate);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function extractUserAgent(req: unknown): string | undefined {
|
||||
const r = req as { headers?: Record<string, string | string[] | undefined> };
|
||||
const ua = r.headers?.["user-agent"];
|
||||
if (!ua) return undefined;
|
||||
return Array.isArray(ua) ? ua[0] : ua;
|
||||
}
|
||||
65
packages/core-audit/src/hooks/audit-erasure-hook.test.ts
Normal file
65
packages/core-audit/src/hooks/audit-erasure-hook.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createAuditErasureHook } from "./audit-erasure-hook";
|
||||
import type { IAuditLog } from "../audit-log.interface";
|
||||
|
||||
function makeAuditLog(): IAuditLog {
|
||||
return {
|
||||
record: vi.fn().mockResolvedValue(undefined),
|
||||
eraseSubject: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
/** Minimal CollectionAfterDeleteHook args shape (only `doc` matters here). */
|
||||
function hookArgs(id: unknown) {
|
||||
return {
|
||||
doc: { id },
|
||||
req: {} as never,
|
||||
id: String(id),
|
||||
collection: {} as never,
|
||||
context: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("createAuditErasureHook", () => {
|
||||
it("defaults to 'pseudonymize' mode", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const hook = createAuditErasureHook({ auditLog });
|
||||
await hook(hookArgs("user_1") as never);
|
||||
expect(auditLog.eraseSubject).toHaveBeenCalledWith("user_1", "pseudonymize");
|
||||
});
|
||||
|
||||
it("respects explicit mode='delete'", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const hook = createAuditErasureHook({ auditLog, mode: "delete" });
|
||||
await hook(hookArgs("user_2") as never);
|
||||
expect(auditLog.eraseSubject).toHaveBeenCalledWith("user_2", "delete");
|
||||
});
|
||||
|
||||
it("coerces numeric id to string", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const hook = createAuditErasureHook({ auditLog });
|
||||
await hook(hookArgs(42) as never);
|
||||
expect(auditLog.eraseSubject).toHaveBeenCalledWith("42", "pseudonymize");
|
||||
});
|
||||
|
||||
it("skips when doc.id is undefined", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const hook = createAuditErasureHook({ auditLog });
|
||||
await hook(hookArgs(undefined) as never);
|
||||
expect(auditLog.eraseSubject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips when doc.id is null", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const hook = createAuditErasureHook({ auditLog });
|
||||
await hook(hookArgs(null) as never);
|
||||
expect(auditLog.eraseSubject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips when doc.id is an object", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const hook = createAuditErasureHook({ auditLog });
|
||||
await hook(hookArgs({ nested: true }) as never);
|
||||
expect(auditLog.eraseSubject).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
38
packages/core-audit/src/hooks/audit-erasure-hook.ts
Normal file
38
packages/core-audit/src/hooks/audit-erasure-hook.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { CollectionAfterDeleteHook } from "payload";
|
||||
import type { IAuditLog } from "../audit-log.interface";
|
||||
|
||||
export type AuditErasureHookOpts = {
|
||||
/** The audit log impl that will perform the erasure. */
|
||||
auditLog: IAuditLog;
|
||||
/**
|
||||
* Erasure mode. Defaults to "pseudonymize" — the softer option that
|
||||
* retains the audit trail shape while removing PII linkage. Use
|
||||
* "delete" only when the data-subject specifically requests hard removal.
|
||||
*/
|
||||
mode?: "pseudonymize" | "delete";
|
||||
};
|
||||
|
||||
/**
|
||||
* Payload `afterDelete` hook factory for GDPR erasure.
|
||||
*
|
||||
* Wire this on any collection whose `id` doubles as an audit subject
|
||||
* (e.g., the users collection). When Payload deletes a document, the
|
||||
* hook calls `auditLog.eraseSubject(String(doc.id), mode)`, removing
|
||||
* or pseudonymizing all audit entries recorded for that actor.
|
||||
*
|
||||
* The hook has no schema-specific knowledge — it works on any collection
|
||||
* that stores the subject identifier as its document `id`.
|
||||
*
|
||||
* Non-string, non-numeric ids are silently skipped (safe guard against
|
||||
* undefined/null that Payload may produce in edge cases).
|
||||
*/
|
||||
export function createAuditErasureHook(
|
||||
opts: AuditErasureHookOpts,
|
||||
): CollectionAfterDeleteHook {
|
||||
const mode = opts.mode ?? "pseudonymize";
|
||||
return async ({ doc }) => {
|
||||
if (typeof doc.id === "string" || typeof doc.id === "number") {
|
||||
await opts.auditLog.eraseSubject(String(doc.id), mode);
|
||||
}
|
||||
};
|
||||
}
|
||||
8
packages/core-audit/src/hooks/index.ts
Normal file
8
packages/core-audit/src/hooks/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
createAuditErasureHook,
|
||||
type AuditErasureHookOpts,
|
||||
} from "./audit-erasure-hook";
|
||||
export {
|
||||
createAuditAfterReadHook,
|
||||
type AuditAfterReadHookOpts,
|
||||
} from "./audit-after-read-hook";
|
||||
32
packages/core-audit/src/index.ts
Normal file
32
packages/core-audit/src/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export type { IAuditLog } from "./audit-log.interface";
|
||||
export type {
|
||||
AuditEntry,
|
||||
AuditAction,
|
||||
AuditFrom,
|
||||
} from "@repo/core-shared/audit";
|
||||
export { NoopAuditLog } from "./noop-audit-log";
|
||||
export { StdoutJsonAuditLog } from "./stdout-json-audit-log";
|
||||
export { PayloadAuditLog } from "./payload-audit-log";
|
||||
export { MultiSinkAuditLog } from "./multi-sink-audit-log";
|
||||
export { auditLogsCollection } from "./audit-logs-collection";
|
||||
export { bindAudit, type BindAuditOpts } from "./di/bind-audit";
|
||||
export { TraceIdEnrichingAuditLog } from "./trace-id-enriching-audit-log";
|
||||
export { AUDIT_SYMBOLS } from "./di/symbols";
|
||||
// GDPR erasure
|
||||
export { pseudonymize } from "./pseudonymize";
|
||||
export {
|
||||
createAuditErasureHook,
|
||||
type AuditErasureHookOpts,
|
||||
} from "./hooks/audit-erasure-hook";
|
||||
// VIEW capture
|
||||
export { createAuditAfterReadHook, type AuditAfterReadHookOpts } from "./hooks";
|
||||
export {
|
||||
createAuditRouter,
|
||||
auditRouter,
|
||||
type AuditRouter,
|
||||
} from "./integrations/api/router";
|
||||
export {
|
||||
auditProcedure,
|
||||
type AdminTrpcUser,
|
||||
} from "./integrations/api/procedures";
|
||||
export { withAudit, type Audited } from "./with-audit";
|
||||
43
packages/core-audit/src/integrations/api/procedures.ts
Normal file
43
packages/core-audit/src/integrations/api/procedures.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { t } from "@repo/core-shared/trpc/init";
|
||||
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
|
||||
|
||||
/**
|
||||
* The minimum user shape that the adminOnly middleware expects to find on `ctx`.
|
||||
* Apps must include this field when creating their tRPC context for requests
|
||||
* that may reach admin procedures. Unauthenticated requests leave `user`
|
||||
* undefined, which the middleware treats as non-admin.
|
||||
*/
|
||||
export type AdminTrpcUser = {
|
||||
roles: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware that blocks non-admin callers.
|
||||
*
|
||||
* Reads `ctx.user?.roles` from the tRPC context. Throws FORBIDDEN if the
|
||||
* user is absent or lacks the "admin" role. Apps that mount the auditRouter
|
||||
* must set `ctx.user` with the authenticated user's roles.
|
||||
*/
|
||||
const adminOnly = t.middleware(({ ctx, next }) => {
|
||||
const user = (ctx as { user?: AdminTrpcUser }).user;
|
||||
if (!user?.roles.includes("admin")) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Admin role required",
|
||||
});
|
||||
}
|
||||
return next({ ctx: { ...ctx, user } });
|
||||
});
|
||||
|
||||
/**
|
||||
* Base procedure for all audit admin routes.
|
||||
*
|
||||
* - `adminOnly` middleware gates every mutation/query.
|
||||
* - `defineErrorMiddleware([])` — no audit-specific domain errors need tRPC
|
||||
* mapping; the FORBIDDEN thrown by `adminOnly` is a plain TRPCError and
|
||||
* propagates unchanged.
|
||||
*/
|
||||
export const auditProcedure = t.procedure
|
||||
.use(adminOnly)
|
||||
.use(defineErrorMiddleware([]));
|
||||
93
packages/core-audit/src/integrations/api/router.test.ts
Normal file
93
packages/core-audit/src/integrations/api/router.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createAuditRouter } from "./router";
|
||||
import type { IAuditLog } from "../../audit-log.interface";
|
||||
import type { AdminTrpcUser } from "./procedures";
|
||||
|
||||
/**
|
||||
* Minimal harness: create a tRPC caller directly from the router so we
|
||||
* don't need a real HTTP layer.
|
||||
*/
|
||||
function makeCallerWithUser(
|
||||
auditLog: IAuditLog,
|
||||
user?: AdminTrpcUser,
|
||||
) {
|
||||
const router = createAuditRouter(auditLog);
|
||||
// Use the tRPC caller factory to invoke mutations directly in tests.
|
||||
return router.createCaller({ user } as Record<string, unknown>);
|
||||
}
|
||||
|
||||
function makeAuditLog(): IAuditLog {
|
||||
return {
|
||||
record: vi.fn().mockResolvedValue(undefined),
|
||||
eraseSubject: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe("auditRouter.eraseSubject", () => {
|
||||
it("throws FORBIDDEN when ctx.user is absent", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const caller = makeCallerWithUser(auditLog, undefined);
|
||||
|
||||
await expect(
|
||||
caller.eraseSubject({ actorId: "user_1", mode: "pseudonymize" }),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
|
||||
expect(auditLog.eraseSubject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws FORBIDDEN when user lacks admin role", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const caller = makeCallerWithUser(auditLog, { roles: ["editor", "viewer"] });
|
||||
|
||||
await expect(
|
||||
caller.eraseSubject({ actorId: "user_1", mode: "pseudonymize" }),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
|
||||
expect(auditLog.eraseSubject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls eraseSubject with pseudonymize mode for an admin user", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const caller = makeCallerWithUser(auditLog, { roles: ["admin"] });
|
||||
|
||||
const result = await caller.eraseSubject({
|
||||
actorId: "user_1",
|
||||
mode: "pseudonymize",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(auditLog.eraseSubject).toHaveBeenCalledWith("user_1", "pseudonymize");
|
||||
});
|
||||
|
||||
it("calls eraseSubject with delete mode for an admin user", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const caller = makeCallerWithUser(auditLog, { roles: ["admin"] });
|
||||
|
||||
const result = await caller.eraseSubject({
|
||||
actorId: "user_2",
|
||||
mode: "delete",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(auditLog.eraseSubject).toHaveBeenCalledWith("user_2", "delete");
|
||||
});
|
||||
|
||||
it("defaults mode to 'pseudonymize' when not provided", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const caller = makeCallerWithUser(auditLog, { roles: ["admin"] });
|
||||
|
||||
// mode has a .default("pseudonymize") in the schema
|
||||
await caller.eraseSubject({ actorId: "user_3", mode: "pseudonymize" });
|
||||
|
||||
expect(auditLog.eraseSubject).toHaveBeenCalledWith("user_3", "pseudonymize");
|
||||
});
|
||||
|
||||
it("rejects empty actorId (schema validation)", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const caller = makeCallerWithUser(auditLog, { roles: ["admin"] });
|
||||
|
||||
await expect(
|
||||
caller.eraseSubject({ actorId: "", mode: "pseudonymize" }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
54
packages/core-audit/src/integrations/api/router.ts
Normal file
54
packages/core-audit/src/integrations/api/router.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { z } from "zod";
|
||||
import { t } from "@repo/core-shared/trpc/init";
|
||||
import type { IAuditLog } from "../../audit-log.interface";
|
||||
import { auditProcedure } from "./procedures";
|
||||
|
||||
/**
|
||||
* Creates the audit admin tRPC router.
|
||||
*
|
||||
* The `auditLog` parameter is captured at router-creation time. Apps that
|
||||
* mount this router must pass the `IAuditLog` impl returned by `bindAudit`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { auditLog } = bindAudit(container, { payloadConfig, sinks: ["payload", "stdout"] });
|
||||
* const appRouter = t.router({ ..., audit: createAuditRouter(auditLog) });
|
||||
* ```
|
||||
*/
|
||||
export function createAuditRouter(auditLog: IAuditLog) {
|
||||
return t.router({
|
||||
eraseSubject: auditProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
actorId: z.string().min(1),
|
||||
mode: z.enum(["pseudonymize", "delete"]).default("pseudonymize"),
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
await auditLog.eraseSubject(input.actorId, input.mode);
|
||||
return { ok: true as const };
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience singleton for projects that have a single audit log instance.
|
||||
* Most callers should use `createAuditRouter` and pass the IAuditLog explicitly.
|
||||
* This export is a stub that throws at call time if auditLog has not been
|
||||
* provided — it exists for type inference purposes (`AuditRouter`).
|
||||
*/
|
||||
export const auditRouter = createAuditRouter(
|
||||
new Proxy({} as IAuditLog, {
|
||||
get(_target, prop) {
|
||||
if (prop === "then") return undefined; // not a Promise
|
||||
throw new Error(
|
||||
`auditRouter singleton used without providing an IAuditLog. ` +
|
||||
`Use createAuditRouter(auditLog) instead.`,
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export type AuditRouter = ReturnType<typeof createAuditRouter>;
|
||||
65
packages/core-audit/src/multi-sink-audit-log.test.ts
Normal file
65
packages/core-audit/src/multi-sink-audit-log.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { MultiSinkAuditLog } from "./multi-sink-audit-log";
|
||||
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||
import type { IAuditLog } from "./audit-log.interface";
|
||||
|
||||
const sample: AuditEntry = {
|
||||
actorId: "user_1",
|
||||
actorType: "user",
|
||||
actorRoles: [],
|
||||
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",
|
||||
};
|
||||
|
||||
function makeRecorder(): IAuditLog & { records: AuditEntry[]; erasures: string[] } {
|
||||
const records: AuditEntry[] = [];
|
||||
const erasures: string[] = [];
|
||||
return {
|
||||
records,
|
||||
erasures,
|
||||
async record(e) { records.push(e); },
|
||||
async eraseSubject(actorId) { erasures.push(actorId); },
|
||||
};
|
||||
}
|
||||
|
||||
describe("MultiSinkAuditLog", () => {
|
||||
it("record() fans out to every sink", async () => {
|
||||
const a = makeRecorder();
|
||||
const b = makeRecorder();
|
||||
const m = new MultiSinkAuditLog([a, b]);
|
||||
await m.record(sample);
|
||||
expect(a.records).toHaveLength(1);
|
||||
expect(b.records).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("settle-all: one sink failing does not skip others", async () => {
|
||||
const errSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
const a: IAuditLog = { record: async () => { throw new Error("a-fail"); }, eraseSubject: async () => {} };
|
||||
const b = makeRecorder();
|
||||
const m = new MultiSinkAuditLog([a, b]);
|
||||
|
||||
await m.record(sample);
|
||||
|
||||
expect(b.records).toHaveLength(1); // b still received the entry
|
||||
expect(errSpy).toHaveBeenCalledOnce();
|
||||
const written = errSpy.mock.calls[0]![0] as string;
|
||||
const parsed = JSON.parse(written.trimEnd());
|
||||
expect(parsed._type).toBe("audit-sink-error");
|
||||
expect(parsed.error).toContain("a-fail");
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("eraseSubject() fans out to every sink", async () => {
|
||||
const a = makeRecorder();
|
||||
const b = makeRecorder();
|
||||
const m = new MultiSinkAuditLog([a, b]);
|
||||
await m.eraseSubject("user_1", "delete");
|
||||
expect(a.erasures).toEqual(["user_1"]);
|
||||
expect(b.erasures).toEqual(["user_1"]);
|
||||
});
|
||||
});
|
||||
45
packages/core-audit/src/multi-sink-audit-log.ts
Normal file
45
packages/core-audit/src/multi-sink-audit-log.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||
import type { IAuditLog } from "./audit-log.interface";
|
||||
|
||||
/**
|
||||
* Fan-out wrapper. Delivers each entry to every inner sink with settle-all
|
||||
* semantics — one failing sink doesn't drop the audit entry from others.
|
||||
*
|
||||
* Failures emit a structured `audit-sink-error` JSON line to stderr.
|
||||
* Stderr (not via OTel/Sentry) avoids recursion: if Sentry is one of the
|
||||
* sinks failing and we routed the error back through Sentry's reporter,
|
||||
* we'd loop. Stderr is consumed by the same log shipper as audit entries
|
||||
* themselves, so the operator sees the failure in their aggregator.
|
||||
*/
|
||||
export class MultiSinkAuditLog implements IAuditLog {
|
||||
constructor(private readonly sinks: IAuditLog[]) {}
|
||||
|
||||
async record(entry: AuditEntry): Promise<void> {
|
||||
const results = await Promise.allSettled(this.sinks.map((s) => s.record(entry)));
|
||||
for (const r of results) {
|
||||
if (r.status === "rejected") {
|
||||
this.reportSinkError(r.reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void> {
|
||||
const results = await Promise.allSettled(
|
||||
this.sinks.map((s) => s.eraseSubject(actorId, mode)),
|
||||
);
|
||||
for (const r of results) {
|
||||
if (r.status === "rejected") {
|
||||
this.reportSinkError(r.reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private reportSinkError(reason: unknown): void {
|
||||
const line = JSON.stringify({
|
||||
_type: "audit-sink-error",
|
||||
error: String(reason),
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
process.stderr.write(line + "\n");
|
||||
}
|
||||
}
|
||||
29
packages/core-audit/src/noop-audit-log.test.ts
Normal file
29
packages/core-audit/src/noop-audit-log.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { NoopAuditLog } from "./noop-audit-log";
|
||||
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||
|
||||
describe("NoopAuditLog", () => {
|
||||
const sample: AuditEntry = {
|
||||
actorId: "user_1",
|
||||
actorType: "user",
|
||||
actorRoles: [],
|
||||
action: "VIEW",
|
||||
resource: { type: "articles", id: "1" },
|
||||
at: new Date(),
|
||||
scope: { feature: "blog", environment: "test", tenant: "default" },
|
||||
from: { ipTruncated: "10.0.0.0", userAgent: "test" },
|
||||
containsPii: false,
|
||||
outcome: "success",
|
||||
};
|
||||
|
||||
it("record() is a no-op that does not throw", async () => {
|
||||
const log = new NoopAuditLog();
|
||||
await expect(log.record(sample)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("eraseSubject() is a no-op that does not throw", async () => {
|
||||
const log = new NoopAuditLog();
|
||||
await expect(log.eraseSubject("user_1", "pseudonymize")).resolves.toBeUndefined();
|
||||
await expect(log.eraseSubject("user_1", "delete")).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
11
packages/core-audit/src/noop-audit-log.ts
Normal file
11
packages/core-audit/src/noop-audit-log.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||
import type { IAuditLog } from "./audit-log.interface";
|
||||
|
||||
export class NoopAuditLog implements IAuditLog {
|
||||
async record(_entry: AuditEntry): Promise<void> {
|
||||
// intentional no-op
|
||||
}
|
||||
async eraseSubject(_actorId: string, _mode: "pseudonymize" | "delete"): Promise<void> {
|
||||
// intentional no-op
|
||||
}
|
||||
}
|
||||
128
packages/core-audit/src/payload-audit-log.test.ts
Normal file
128
packages/core-audit/src/payload-audit-log.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { PayloadAuditLog } from "./payload-audit-log";
|
||||
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||
|
||||
const sample: AuditEntry = {
|
||||
actorId: "user_1",
|
||||
actorType: "user",
|
||||
actorRoles: ["admin"],
|
||||
action: "UPDATE",
|
||||
resource: { type: "articles", id: "abc" },
|
||||
changedFields: ["title", "body"],
|
||||
at: new Date("2026-05-11T10:00:00.000Z"),
|
||||
scope: { feature: "blog", environment: "production", tenant: "default" },
|
||||
from: { ipTruncated: "10.0.0.0", userAgent: "Mozilla/5.0" },
|
||||
containsPii: false,
|
||||
outcome: "success",
|
||||
};
|
||||
|
||||
describe("PayloadAuditLog.record", () => {
|
||||
it("maps AuditEntry → flat collection doc + calls payload.create", async () => {
|
||||
const mockCreate = vi.fn().mockResolvedValue({ id: "doc_1" });
|
||||
const mockGetPayload = vi.fn().mockResolvedValue({ create: mockCreate });
|
||||
const log = new PayloadAuditLog({} as never, mockGetPayload);
|
||||
|
||||
await log.record(sample);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledOnce();
|
||||
const call = mockCreate.mock.calls[0]![0] as { collection: string; data: Record<string, unknown> };
|
||||
expect(call.collection).toBe("audit-logs");
|
||||
expect(call.data.actorId).toBe("user_1");
|
||||
expect(call.data.action).toBe("UPDATE");
|
||||
expect(call.data.resourceType).toBe("articles");
|
||||
expect(call.data.resourceId).toBe("abc");
|
||||
expect(call.data.changedFields).toEqual(["title", "body"]);
|
||||
expect(call.data.scopeFeature).toBe("blog");
|
||||
expect(call.data.scopeTenant).toBe("default");
|
||||
expect(call.data.ipTruncated).toBe("10.0.0.0");
|
||||
expect(call.data.containsPii).toBe(false);
|
||||
expect(call.data.outcome).toBe("success");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PayloadAuditLog.eraseSubject", () => {
|
||||
const originalSalt = process.env["AUDIT_PSEUDONYM_SALT"];
|
||||
|
||||
beforeEach(() => {
|
||||
process.env["AUDIT_PSEUDONYM_SALT"] = "test-salt-erase";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalSalt === undefined) {
|
||||
delete process.env["AUDIT_PSEUDONYM_SALT"];
|
||||
} else {
|
||||
process.env["AUDIT_PSEUDONYM_SALT"] = originalSalt;
|
||||
}
|
||||
});
|
||||
|
||||
it("mode='delete' calls payload.delete with the correct where clause + overrideAccess", async () => {
|
||||
const mockDelete = vi.fn().mockResolvedValue({});
|
||||
const mockGetPayload = vi.fn().mockResolvedValue({ delete: mockDelete });
|
||||
const log = new PayloadAuditLog({} as never, mockGetPayload);
|
||||
|
||||
await log.eraseSubject("user_1", "delete");
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledOnce();
|
||||
const call = mockDelete.mock.calls[0]![0] as {
|
||||
collection: string;
|
||||
where: Record<string, unknown>;
|
||||
overrideAccess: boolean;
|
||||
};
|
||||
expect(call.collection).toBe("audit-logs");
|
||||
expect(call.where).toEqual({ actorId: { equals: "user_1" } });
|
||||
expect(call.overrideAccess).toBe(true);
|
||||
});
|
||||
|
||||
it("mode='pseudonymize' finds matching docs and updates each actorId to the pseudonym", async () => {
|
||||
const mockFind = vi.fn().mockResolvedValue({
|
||||
docs: [{ id: "doc_a" }, { id: "doc_b" }],
|
||||
});
|
||||
const mockUpdate = vi.fn().mockResolvedValue({});
|
||||
const mockGetPayload = vi.fn().mockResolvedValue({
|
||||
find: mockFind,
|
||||
update: mockUpdate,
|
||||
});
|
||||
const log = new PayloadAuditLog({} as never, mockGetPayload);
|
||||
|
||||
await log.eraseSubject("user_1", "pseudonymize");
|
||||
|
||||
// find must use overrideAccess + limit=10_000
|
||||
const findCall = mockFind.mock.calls[0]![0] as {
|
||||
collection: string;
|
||||
where: Record<string, unknown>;
|
||||
limit: number;
|
||||
overrideAccess: boolean;
|
||||
};
|
||||
expect(findCall.collection).toBe("audit-logs");
|
||||
expect(findCall.where).toEqual({ actorId: { equals: "user_1" } });
|
||||
expect(findCall.limit).toBe(10_000);
|
||||
expect(findCall.overrideAccess).toBe(true);
|
||||
|
||||
// update called for each doc
|
||||
expect(mockUpdate).toHaveBeenCalledTimes(2);
|
||||
const updateCalls = mockUpdate.mock.calls as Array<
|
||||
[{ collection: string; id: string; data: Record<string, unknown>; overrideAccess: boolean }]
|
||||
>;
|
||||
expect(updateCalls[0]![0].id).toBe("doc_a");
|
||||
expect(updateCalls[1]![0].id).toBe("doc_b");
|
||||
|
||||
// both updates replace actorId with the same pseudonym
|
||||
const pseudonym = updateCalls[0]![0].data["actorId"] as string;
|
||||
expect(pseudonym).toMatch(/^erased-[0-9a-f]{16}$/);
|
||||
expect(updateCalls[1]![0].data["actorId"]).toBe(pseudonym);
|
||||
|
||||
// overrideAccess bypasses the append-only rule
|
||||
expect(updateCalls[0]![0].overrideAccess).toBe(true);
|
||||
});
|
||||
|
||||
it("mode='pseudonymize' with no matching docs does not call update", async () => {
|
||||
const mockFind = vi.fn().mockResolvedValue({ docs: [] });
|
||||
const mockUpdate = vi.fn();
|
||||
const mockGetPayload = vi.fn().mockResolvedValue({ find: mockFind, update: mockUpdate });
|
||||
const log = new PayloadAuditLog({} as never, mockGetPayload);
|
||||
|
||||
await log.eraseSubject("unknown_user", "pseudonymize");
|
||||
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
103
packages/core-audit/src/payload-audit-log.ts
Normal file
103
packages/core-audit/src/payload-audit-log.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { SanitizedConfig } from "payload";
|
||||
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||
import type { IAuditLog } from "./audit-log.interface";
|
||||
import { pseudonymize } from "./pseudonymize";
|
||||
|
||||
type GetPayload = (args: { config: SanitizedConfig }) => Promise<{
|
||||
create: (args: { collection: string; data: Record<string, unknown> }) => Promise<unknown>;
|
||||
find: (args: {
|
||||
collection: string;
|
||||
where: Record<string, unknown>;
|
||||
limit: number;
|
||||
overrideAccess: true;
|
||||
}) => Promise<{ docs: Array<{ id: string | number }> }>;
|
||||
update: (args: {
|
||||
collection: string;
|
||||
id: string | number;
|
||||
data: Record<string, unknown>;
|
||||
overrideAccess: true;
|
||||
}) => Promise<unknown>;
|
||||
delete: (args: {
|
||||
collection: string;
|
||||
where: Record<string, unknown>;
|
||||
overrideAccess: true;
|
||||
}) => Promise<unknown>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Local-cache audit sink: writes entries to the `audit-logs` Payload
|
||||
* collection. The collection is append-only by access-rule
|
||||
* (`update: () => false`); the eraseSubject path uses `overrideAccess: true`
|
||||
* to bypass for the privileged GDPR pseudonymization op.
|
||||
*
|
||||
* The getPayload param is injectable for tests; production callers pass
|
||||
* the real `getPayload` from `payload`.
|
||||
*/
|
||||
export class PayloadAuditLog implements IAuditLog {
|
||||
constructor(
|
||||
private readonly config: SanitizedConfig,
|
||||
private readonly getPayload: GetPayload,
|
||||
) {}
|
||||
|
||||
async record(entry: AuditEntry): Promise<void> {
|
||||
const payload = await this.getPayload({ config: this.config });
|
||||
await payload.create({
|
||||
collection: "audit-logs",
|
||||
data: {
|
||||
actorId: entry.actorId,
|
||||
actorType: entry.actorType,
|
||||
actorRoles: entry.actorRoles,
|
||||
action: entry.action,
|
||||
resourceType: entry.resource.type,
|
||||
resourceId: entry.resource.id ?? null,
|
||||
changedFields: entry.changedFields ?? null,
|
||||
scopeFeature: entry.scope.feature,
|
||||
scopeEnvironment: entry.scope.environment,
|
||||
scopeTenant: entry.scope.tenant,
|
||||
reason: entry.reason ?? null,
|
||||
correlationId: entry.correlationId ?? null,
|
||||
requestId: entry.requestId ?? null,
|
||||
ipTruncated: entry.from.ipTruncated,
|
||||
userAgent: entry.from.userAgent,
|
||||
containsPii: entry.containsPii,
|
||||
piiCategories: entry.piiCategories ?? null,
|
||||
outcome: entry.outcome,
|
||||
errorCode: entry.errorCode ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void> {
|
||||
const payload = await this.getPayload({ config: this.config });
|
||||
|
||||
if (mode === "delete") {
|
||||
await payload.delete({
|
||||
collection: "audit-logs",
|
||||
where: { actorId: { equals: actorId } },
|
||||
overrideAccess: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// mode === "pseudonymize"
|
||||
// Fetch all matching docs. Limit is 10_000 — a subject with more than
|
||||
// 10k audit entries will not have all entries pseudonymized in one call.
|
||||
// This is an accepted v1 limitation; callers may loop if needed.
|
||||
const { docs } = await payload.find({
|
||||
collection: "audit-logs",
|
||||
where: { actorId: { equals: actorId } },
|
||||
limit: 10_000,
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
const pseudonym = pseudonymize(actorId);
|
||||
for (const doc of docs) {
|
||||
await payload.update({
|
||||
collection: "audit-logs",
|
||||
id: doc.id,
|
||||
data: { actorId: pseudonym },
|
||||
overrideAccess: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
58
packages/core-audit/src/pseudonymize.test.ts
Normal file
58
packages/core-audit/src/pseudonymize.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { pseudonymize } from "./pseudonymize";
|
||||
|
||||
describe("pseudonymize", () => {
|
||||
const originalSalt = process.env["AUDIT_PSEUDONYM_SALT"];
|
||||
|
||||
beforeEach(() => {
|
||||
process.env["AUDIT_PSEUDONYM_SALT"] = "test-salt-1";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalSalt === undefined) {
|
||||
delete process.env["AUDIT_PSEUDONYM_SALT"];
|
||||
} else {
|
||||
process.env["AUDIT_PSEUDONYM_SALT"] = originalSalt;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a string prefixed with 'erased-'", () => {
|
||||
const result = pseudonymize("user_42");
|
||||
expect(result).toMatch(/^erased-/);
|
||||
});
|
||||
|
||||
it("produces exactly 16 hex chars after the prefix", () => {
|
||||
const result = pseudonymize("user_42");
|
||||
const hex = result.slice("erased-".length);
|
||||
expect(hex).toHaveLength(16);
|
||||
expect(hex).toMatch(/^[0-9a-f]+$/);
|
||||
});
|
||||
|
||||
it("is deterministic — same salt + actorId always yields the same token", () => {
|
||||
const a = pseudonymize("user_42");
|
||||
const b = pseudonymize("user_42");
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("differs when actorId differs (same salt)", () => {
|
||||
const a = pseudonymize("user_42");
|
||||
const b = pseudonymize("user_99");
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("differs when the salt changes", () => {
|
||||
const withSalt1 = pseudonymize("user_42");
|
||||
|
||||
process.env["AUDIT_PSEUDONYM_SALT"] = "test-salt-2";
|
||||
const withSalt2 = pseudonymize("user_42");
|
||||
|
||||
expect(withSalt1).not.toBe(withSalt2);
|
||||
});
|
||||
|
||||
it("uses the fallback salt when env var is absent", () => {
|
||||
delete process.env["AUDIT_PSEUDONYM_SALT"];
|
||||
// Should not throw; just use the fallback.
|
||||
const result = pseudonymize("user_1");
|
||||
expect(result).toMatch(/^erased-[0-9a-f]{16}$/);
|
||||
});
|
||||
});
|
||||
22
packages/core-audit/src/pseudonymize.ts
Normal file
22
packages/core-audit/src/pseudonymize.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Produces a stable, irreversible token for a GDPR-erased actorId.
|
||||
*
|
||||
* Format: `erased-<first-16-hex-chars-of-sha256(salt:actorId)>`
|
||||
*
|
||||
* The salt is read from `AUDIT_PSEUDONYM_SALT` env at call time so that
|
||||
* production binding can pre-validate the var at boot (see `bindAudit`)
|
||||
* while tests can override it per-test via `process.env`.
|
||||
*
|
||||
* Fallback salt is intentionally weak and labelled so that any token
|
||||
* produced with it is recognisable as a dev/test artefact.
|
||||
*/
|
||||
export function pseudonymize(actorId: string): string {
|
||||
const salt =
|
||||
process.env["AUDIT_PSEUDONYM_SALT"] ?? "dev-fallback-salt-replace-in-prod";
|
||||
const hash = createHash("sha256")
|
||||
.update(`${salt}:${actorId}`)
|
||||
.digest("hex");
|
||||
return `erased-${hash.slice(0, 16)}`;
|
||||
}
|
||||
49
packages/core-audit/src/stdout-json-audit-log.test.ts
Normal file
49
packages/core-audit/src/stdout-json-audit-log.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { StdoutJsonAuditLog } from "./stdout-json-audit-log";
|
||||
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||
|
||||
const sample: AuditEntry = {
|
||||
actorId: "user_1",
|
||||
actorType: "user",
|
||||
actorRoles: ["admin"],
|
||||
action: "CREATE",
|
||||
resource: { type: "articles", id: "abc" },
|
||||
at: new Date("2026-05-11T10:00:00.000Z"),
|
||||
scope: { feature: "blog", environment: "production", tenant: "default" },
|
||||
from: { ipTruncated: "10.0.0.0", userAgent: "Mozilla/5.0" },
|
||||
containsPii: false,
|
||||
outcome: "success",
|
||||
};
|
||||
|
||||
describe("StdoutJsonAuditLog", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let writeSpy: ReturnType<typeof vi.spyOn<any, any>>;
|
||||
beforeEach(() => {
|
||||
writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
});
|
||||
|
||||
it("record() writes one JSON line per entry to stdout", async () => {
|
||||
const log = new StdoutJsonAuditLog();
|
||||
await log.record(sample);
|
||||
expect(writeSpy).toHaveBeenCalledOnce();
|
||||
const written = writeSpy.mock.calls[0]![0] as string;
|
||||
expect(written.endsWith("\n")).toBe(true);
|
||||
const parsed = JSON.parse(written.trimEnd());
|
||||
expect(parsed._type).toBe("audit");
|
||||
expect(parsed.actorId).toBe("user_1");
|
||||
expect(parsed.action).toBe("CREATE");
|
||||
expect(parsed.at).toBe("2026-05-11T10:00:00.000Z"); // ISO 8601 serialization
|
||||
});
|
||||
|
||||
it("eraseSubject() emits a tombstone with mode + actorId", async () => {
|
||||
const log = new StdoutJsonAuditLog();
|
||||
await log.eraseSubject("user_1", "pseudonymize");
|
||||
expect(writeSpy).toHaveBeenCalledOnce();
|
||||
const written = writeSpy.mock.calls[0]![0] as string;
|
||||
const parsed = JSON.parse(written.trimEnd());
|
||||
expect(parsed._type).toBe("audit-erasure");
|
||||
expect(parsed.actorId).toBe("user_1");
|
||||
expect(parsed.mode).toBe("pseudonymize");
|
||||
expect(typeof parsed.at).toBe("string"); // ISO 8601 timestamp
|
||||
});
|
||||
});
|
||||
35
packages/core-audit/src/stdout-json-audit-log.ts
Normal file
35
packages/core-audit/src/stdout-json-audit-log.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||
import type { IAuditLog } from "./audit-log.interface";
|
||||
|
||||
/**
|
||||
* Writes one structured JSON line per audit entry to stdout. A log shipper
|
||||
* (Vector, Fluent Bit) picks these up and forwards to the centralized
|
||||
* aggregator (Grafana Cloud, Datadog, Loki EU, etc.).
|
||||
*
|
||||
* Lines include a `_type` discriminator so the shipper can route:
|
||||
* "audit" → audit entry
|
||||
* "audit-erasure" → GDPR erasure tombstone
|
||||
*
|
||||
* `eraseSubject` is best-effort: past stdout lines can't be retroactively
|
||||
* removed. The tombstone informs the downstream aggregator to filter/delete.
|
||||
*/
|
||||
export class StdoutJsonAuditLog implements IAuditLog {
|
||||
async record(entry: AuditEntry): Promise<void> {
|
||||
const serialized = JSON.stringify({
|
||||
_type: "audit",
|
||||
...entry,
|
||||
at: entry.at.toISOString(),
|
||||
});
|
||||
process.stdout.write(serialized + "\n");
|
||||
}
|
||||
|
||||
async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void> {
|
||||
const tombstone = {
|
||||
_type: "audit-erasure",
|
||||
actorId,
|
||||
mode,
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
process.stdout.write(JSON.stringify(tombstone) + "\n");
|
||||
}
|
||||
}
|
||||
104
packages/core-audit/src/trace-id-enriching-audit-log.test.ts
Normal file
104
packages/core-audit/src/trace-id-enriching-audit-log.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { context, trace } from "@opentelemetry/api";
|
||||
import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
|
||||
import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
|
||||
import { TraceIdEnrichingAuditLog } from "./trace-id-enriching-audit-log";
|
||||
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||
import type { IAuditLog } from "./audit-log.interface";
|
||||
|
||||
// Register async context manager so startActiveSpan propagates context.
|
||||
const ctxManager = new AsyncLocalStorageContextManager();
|
||||
ctxManager.enable();
|
||||
context.setGlobalContextManager(ctxManager);
|
||||
|
||||
function setupProvider(): { exporter: InMemorySpanExporter; provider: BasicTracerProvider } {
|
||||
const exporter = new InMemorySpanExporter();
|
||||
const provider = new BasicTracerProvider({
|
||||
spanProcessors: [new SimpleSpanProcessor(exporter)],
|
||||
});
|
||||
trace.setGlobalTracerProvider(provider);
|
||||
return { exporter, provider };
|
||||
}
|
||||
|
||||
const sample: AuditEntry = {
|
||||
actorId: "user_1",
|
||||
actorType: "user",
|
||||
actorRoles: [],
|
||||
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",
|
||||
};
|
||||
|
||||
function makeInner(): IAuditLog & { records: AuditEntry[] } {
|
||||
const records: AuditEntry[] = [];
|
||||
return {
|
||||
records,
|
||||
async record(e) {
|
||||
records.push(e);
|
||||
},
|
||||
eraseSubject: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("TraceIdEnrichingAuditLog", () => {
|
||||
let exporter: InMemorySpanExporter;
|
||||
let provider: BasicTracerProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
({ exporter, provider } = setupProvider());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await provider.shutdown();
|
||||
trace.disable();
|
||||
void exporter;
|
||||
});
|
||||
|
||||
it("passes through when no active span", async () => {
|
||||
const inner = makeInner();
|
||||
const wrapper = new TraceIdEnrichingAuditLog(inner);
|
||||
await wrapper.record(sample);
|
||||
expect(inner.records[0]!.correlationId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("auto-populates correlationId from active span", async () => {
|
||||
const inner = makeInner();
|
||||
const wrapper = new TraceIdEnrichingAuditLog(inner);
|
||||
const tracer = trace.getTracer("test");
|
||||
await new Promise<void>((resolve) => {
|
||||
tracer.startActiveSpan("test", async (span) => {
|
||||
await wrapper.record(sample);
|
||||
const expected = span.spanContext().traceId;
|
||||
expect(inner.records[0]!.correlationId).toBe(expected);
|
||||
span.end();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("explicit correlationId wins over auto-populated", async () => {
|
||||
const inner = makeInner();
|
||||
const wrapper = new TraceIdEnrichingAuditLog(inner);
|
||||
const tracer = trace.getTracer("test");
|
||||
await new Promise<void>((resolve) => {
|
||||
tracer.startActiveSpan("test", async (span) => {
|
||||
await wrapper.record({ ...sample, correlationId: "explicit-trace-id" });
|
||||
expect(inner.records[0]!.correlationId).toBe("explicit-trace-id");
|
||||
span.end();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("eraseSubject passes through unchanged", async () => {
|
||||
const eraseSpy = vi.fn();
|
||||
const inner: IAuditLog = { record: vi.fn(), eraseSubject: eraseSpy };
|
||||
const wrapper = new TraceIdEnrichingAuditLog(inner);
|
||||
await wrapper.eraseSubject("user_1", "delete");
|
||||
expect(eraseSpy).toHaveBeenCalledWith("user_1", "delete");
|
||||
});
|
||||
});
|
||||
30
packages/core-audit/src/trace-id-enriching-audit-log.ts
Normal file
30
packages/core-audit/src/trace-id-enriching-audit-log.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||
import { currentTraceId } from "@repo/core-shared/instrumentation";
|
||||
import type { IAuditLog } from "./audit-log.interface";
|
||||
|
||||
/**
|
||||
* Decorates any IAuditLog by auto-populating AuditEntry.correlationId from
|
||||
* the active OTel span (when present and the caller didn't supply a value).
|
||||
* Caller-supplied correlationId always wins — explicit > implicit.
|
||||
*
|
||||
* Applied at bind time by bindAudit so all sinks see entries with
|
||||
* correlationId already set. Single source of truth for the OTel-audit bridge.
|
||||
*/
|
||||
export class TraceIdEnrichingAuditLog implements IAuditLog {
|
||||
constructor(readonly inner: IAuditLog) {}
|
||||
|
||||
async record(entry: AuditEntry): Promise<void> {
|
||||
if (entry.correlationId) {
|
||||
return this.inner.record(entry);
|
||||
}
|
||||
const traceId = currentTraceId();
|
||||
if (!traceId) {
|
||||
return this.inner.record(entry);
|
||||
}
|
||||
return this.inner.record({ ...entry, correlationId: traceId });
|
||||
}
|
||||
|
||||
eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void> {
|
||||
return this.inner.eraseSubject(actorId, mode);
|
||||
}
|
||||
}
|
||||
76
packages/core-audit/src/with-audit.chain.test.ts
Normal file
76
packages/core-audit/src/with-audit.chain.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { withSpan } from "@repo/core-shared/instrumentation";
|
||||
import { withCapture } from "@repo/core-shared/instrumentation";
|
||||
import { isInstrumented, isCaptured, isAudited } from "@repo/core-shared/conformance";
|
||||
import { withAudit } from "@/with-audit";
|
||||
import type { IAuditLog } from "@/audit-log.interface";
|
||||
import type { ITracer, ISpan } from "@repo/core-shared/instrumentation";
|
||||
import type { ILogger } from "@repo/core-shared/instrumentation";
|
||||
|
||||
function makeTracer(): ITracer {
|
||||
return {
|
||||
startSpan: vi.fn(async (_opts, fn) => {
|
||||
const span: ISpan = { setAttribute: () => {}, setStatus: () => {} };
|
||||
return fn(span);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeLogger(): ILogger {
|
||||
return {
|
||||
captureException: vi.fn(),
|
||||
captureMessage: vi.fn(),
|
||||
addBreadcrumb: vi.fn(),
|
||||
setUser: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeAuditLog(): IAuditLog {
|
||||
return {
|
||||
record: vi.fn().mockResolvedValue(undefined),
|
||||
eraseSubject: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe("brand propagation across the full wrapper chain", () => {
|
||||
it("withSpan(withCapture(withAudit(fn))) carries all three brands on the outer wrapper", () => {
|
||||
const tracer = makeTracer();
|
||||
const logger = makeLogger();
|
||||
const auditLog = makeAuditLog();
|
||||
const fn = async (input: { id: string }) => ({ ok: true, id: input.id });
|
||||
|
||||
const wrapped = withSpan(
|
||||
tracer,
|
||||
{ name: "test.chain", op: "use-case" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "test", layer: "use-case" },
|
||||
withAudit(auditLog, fn),
|
||||
),
|
||||
);
|
||||
|
||||
expect(isInstrumented(wrapped)).toBe(true);
|
||||
expect(isCaptured(wrapped)).toBe(true);
|
||||
expect(isAudited(wrapped)).toBe(true);
|
||||
});
|
||||
|
||||
it("the full chain preserves input/output behaviour end-to-end", async () => {
|
||||
const tracer = makeTracer();
|
||||
const logger = makeLogger();
|
||||
const auditLog = makeAuditLog();
|
||||
const fn = async (input: { id: string }) => ({ ok: true, id: input.id });
|
||||
|
||||
const wrapped = withSpan(
|
||||
tracer,
|
||||
{ name: "test.chain", op: "use-case" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "test", layer: "use-case" },
|
||||
withAudit(auditLog, fn),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await wrapped({ id: "abc" });
|
||||
expect(result).toEqual({ ok: true, id: "abc" });
|
||||
});
|
||||
});
|
||||
53
packages/core-audit/src/with-audit.test.ts
Normal file
53
packages/core-audit/src/with-audit.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, expectTypeOf, vi } from "vitest";
|
||||
import { withAudit, type Audited } from "@/with-audit";
|
||||
import type { IAuditLog } from "@/audit-log.interface";
|
||||
import { isAudited } from "@repo/core-shared/conformance";
|
||||
|
||||
function makeAuditLog(): IAuditLog {
|
||||
return {
|
||||
record: vi.fn().mockResolvedValue(undefined),
|
||||
eraseSubject: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe("withAudit", () => {
|
||||
it("returns an Audited<F>", () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const fn = async (_input: { id: string }) => ({ ok: true });
|
||||
const wrapped = withAudit(auditLog, fn);
|
||||
expectTypeOf(wrapped).toMatchTypeOf<Audited<typeof fn>>();
|
||||
});
|
||||
|
||||
it("attaches __audited as a non-enumerable property on the wrapped function", () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const fn = async () => ({ ok: true });
|
||||
const wrapped = withAudit(auditLog, fn);
|
||||
expect(isAudited(wrapped)).toBe(true);
|
||||
expect(Object.keys(wrapped)).not.toContain("__audited");
|
||||
});
|
||||
|
||||
it("does NOT pollute the original input function with the brand", () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const fn = async () => ({ ok: true });
|
||||
const wrapped = withAudit(auditLog, fn);
|
||||
expect(isAudited(fn)).toBe(false);
|
||||
expect(wrapped).not.toBe(fn);
|
||||
});
|
||||
|
||||
it("passes input and output through unchanged", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const fn = async (input: { id: string }) => ({ ok: true, id: input.id });
|
||||
const wrapped = withAudit(auditLog, fn);
|
||||
const result = await wrapped({ id: "abc" });
|
||||
expect(result).toEqual({ ok: true, id: "abc" });
|
||||
});
|
||||
|
||||
it("propagates errors", async () => {
|
||||
const auditLog = makeAuditLog();
|
||||
const err = new Error("boom");
|
||||
const wrapped = withAudit(auditLog, async () => {
|
||||
throw err;
|
||||
});
|
||||
await expect(wrapped()).rejects.toBe(err);
|
||||
});
|
||||
});
|
||||
35
packages/core-audit/src/with-audit.ts
Normal file
35
packages/core-audit/src/with-audit.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { IAuditLog } from "./audit-log.interface";
|
||||
import { attachBrand } from "@repo/core-shared/conformance";
|
||||
|
||||
/**
|
||||
* Phantom-type brand attached at wrap time by `withAudit`. The conformance
|
||||
* system uses this as the type-level seam for mutating use cases that
|
||||
* declare `audits: [...]` in their manifest — without `__audited`, the
|
||||
* binding is not assignable to `ProductionUseCase<I, O, M>` when M demands
|
||||
* it. At runtime the brand is a non-enumerable property attached by
|
||||
* `attachBrand` from `@repo/core-shared/conformance`, so the boot-time
|
||||
* assertion can verify the binding went through the audit-aware path.
|
||||
*/
|
||||
export type Audited<F> = F & { readonly __audited: true };
|
||||
|
||||
/**
|
||||
* Use-case wrapper applied at DI bind time. The wrapper is a thin closure
|
||||
* that forwards to `fn` unchanged and carries the `__audited` brand. The
|
||||
* forward closure (instead of returning `fn` directly) keeps the brand on
|
||||
* a fresh function so the caller's original `fn` is not mutated — important
|
||||
* when the same factory output is used elsewhere unwrapped (dev-seed paths,
|
||||
* tests).
|
||||
*/
|
||||
export function withAudit<Args extends unknown[], R>(
|
||||
// The wrapper attaches the brand and ensures the auditLog dependency is
|
||||
// available at bind time. Actual `auditLog.record()` calls live in the
|
||||
// use case body — only the use case knows which fields to extract from
|
||||
// its input/output for the audit entry.
|
||||
auditLog: IAuditLog,
|
||||
fn: (...args: Args) => Promise<R>,
|
||||
): Audited<(...args: Args) => Promise<R>> {
|
||||
void auditLog;
|
||||
const wrapped: (...args: Args) => Promise<R> = (...args) => fn(...args);
|
||||
attachBrand(wrapped, "__audited");
|
||||
return wrapped as Audited<(...args: Args) => Promise<R>>;
|
||||
}
|
||||
12
packages/core-audit/tsconfig.json
Normal file
12
packages/core-audit/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@repo/core-typescript/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
4
packages/core-audit/turbo.json
Normal file
4
packages/core-audit/turbo.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tags": ["core"]
|
||||
}
|
||||
9
packages/core-audit/vitest.config.ts
Normal file
9
packages/core-audit/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import path from "node:path";
|
||||
import { mergeConfig } from "vitest/config";
|
||||
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
|
||||
|
||||
export default mergeConfig(nodeVitestConfig, {
|
||||
resolve: {
|
||||
alias: { "@": path.resolve(__dirname, "./src") },
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user