@cosmicdrift/kumiko-bundled-features 0.278.0 → 0.280.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/package.json +9 -9
  2. package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +8 -2
  3. package/src/auth-mfa-user-data/__tests__/hooks.integration.test.ts +6 -6
  4. package/src/auth-mfa-user-data/hooks.ts +4 -6
  5. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +10 -3
  6. package/src/custom-fields/wire-user-data-rights.ts +40 -24
  7. package/src/data-retention/resolve-for-tenant.ts +18 -6
  8. package/src/delivery/db/queries/preferences.ts +1 -0
  9. package/src/files-tenant-data/hooks.ts +3 -5
  10. package/src/folders/__tests__/folders.integration.test.ts +4 -3
  11. package/src/folders-user-data/__tests__/hooks.integration.test.ts +2 -1
  12. package/src/folders-user-data/hooks.ts +5 -10
  13. package/src/form-draft-user-data/__tests__/hooks-delete-failure.integration.test.ts +7 -1
  14. package/src/form-draft-user-data/__tests__/hooks.integration.test.ts +16 -5
  15. package/src/form-draft-user-data/hooks.ts +3 -6
  16. package/src/notes-history-user-data/__tests__/hooks.integration.test.ts +9 -3
  17. package/src/notes-history-user-data/hooks.ts +3 -4
  18. package/src/personal-access-tokens/handlers/create.write.ts +1 -0
  19. package/src/template-resolver-user-data/hooks.ts +1 -2
  20. package/src/tenant-lifecycle/feature.ts +2 -0
  21. package/src/tenant-lifecycle/run-tenant-destroy.ts +9 -0
  22. package/src/tenant-lifecycle/stages.ts +26 -2
  23. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +5 -14
  24. package/src/user-data-rights/__tests__/escape-hatch-audit.integration.test.ts +133 -0
  25. package/src/user-data-rights/__tests__/escape-hatch-declaration.integration.test.ts +198 -0
  26. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +15 -3
  27. package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts +10 -2
  28. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -1
  29. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -3
  30. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +8 -2
  31. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +8 -4
  32. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +15 -5
  33. package/src/user-data-rights/feature.ts +4 -0
  34. package/src/user-data-rights/run-export-jobs.ts +16 -1
  35. package/src/user-data-rights/run-forget-cleanup.ts +44 -2
  36. package/src/user-data-rights/run-user-export.ts +43 -3
  37. package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +7 -6
  38. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +64 -18
  39. package/src/user-data-rights-defaults/hooks/api-token.userdata-hook.ts +2 -3
  40. package/src/user-data-rights-defaults/hooks/config-value.userdata-hook.ts +4 -6
  41. package/src/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.ts +1 -2
  42. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +5 -11
  43. package/src/user-data-rights-defaults/hooks/in-app-message.userdata-hook.ts +2 -3
  44. package/src/user-data-rights-defaults/hooks/job-run.userdata-hook.ts +1 -2
  45. package/src/user-data-rights-defaults/hooks/notification-preference.userdata-hook.ts +4 -6
  46. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +8 -10
  47. package/src/user-data-rights-defaults/hooks/user-session.userdata-hook.ts +2 -3
  48. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +9 -10
@@ -30,6 +30,7 @@ export type CreatePatOptions = {
30
30
  ) => Promise<PatMfaVerifyResult>;
31
31
  };
32
32
 
33
+ // @wrapper-known error-helper
33
34
  function reauthFailed() {
34
35
  return writeFailure(
35
36
  new UnprocessableError(PatErrors.reauthRequired, {
@@ -3,7 +3,6 @@
3
3
  // so apps without the user-data-rights pipeline don't pull a hard dependency.
4
4
  // Mirrors notes-history-user-data: export-only, erasure via crypto-shredding.
5
5
 
6
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
7
6
  import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
8
7
  import { userContentEntriesTable } from "../template-resolver";
9
8
 
@@ -11,7 +10,7 @@ import { userContentEntriesTable } from "../template-resolver";
11
10
  // scoping comes from the tenant-scoped ctx.db; a user who belongs to two
12
11
  // tenants gets each tenant's entries from that tenant's forget run.
13
12
  export const userContentExportHook: UserDataExportHook = async (ctx) => {
14
- const rows = await selectMany<Record<string, unknown>>(ctx.db, userContentEntriesTable, {
13
+ const rows = await ctx.db.selectMany(userContentEntriesTable, {
15
14
  ownerId: ctx.userId,
16
15
  });
17
16
  if (rows.length === 0) return null;
@@ -132,6 +132,8 @@ export function createTenantLifecycleFeature(): FeatureDefinition {
132
132
  now: T.Now.instant(),
133
133
  log: (message) => ctx.log?.warn(message),
134
134
  fileProviderResolver: ctx._fileProviderResolver,
135
+ escapeHatchAuditSink: ctx._escapeHatchAuditSink,
136
+ actor: ctx.systemUser.id,
135
137
  });
136
138
  },
137
139
  });
@@ -3,6 +3,7 @@ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
3
  import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
4
4
  import {
5
5
  createSystemUser,
6
+ type EscapeHatchAuditSink,
6
7
  type Registry,
7
8
  type TenantId,
8
9
  } from "@cosmicdrift/kumiko-framework/engine";
@@ -216,6 +217,8 @@ export async function runNextDestructionStage(args: {
216
217
  readonly tenantId: TenantId;
217
218
  readonly log?: (message: string) => void;
218
219
  readonly fileProviderResolver?: FileProviderResolver;
220
+ readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
221
+ readonly actor?: string;
219
222
  }): Promise<{ readonly done: boolean; readonly error?: string; readonly halted?: boolean }> {
220
223
  const events = await loadAggregate(args.db, args.tenantId, args.tenantId);
221
224
  const { completed, abandoned, attemptsByStage } = replayStageState(events);
@@ -238,6 +241,8 @@ export async function runNextDestructionStage(args: {
238
241
  tenantId: args.tenantId,
239
242
  log: args.log,
240
243
  fileProviderResolver: args.fileProviderResolver,
244
+ escapeHatchAuditSink: args.escapeHatchAuditSink,
245
+ actor: args.actor,
241
246
  };
242
247
 
243
248
  let version = lastEventVersion(events);
@@ -304,6 +309,8 @@ export async function runTenantDestructionSweep(args: {
304
309
  readonly now?: Temporal.Instant;
305
310
  readonly log?: (message: string) => void;
306
311
  readonly fileProviderResolver?: FileProviderResolver;
312
+ readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
313
+ readonly actor?: string;
307
314
  }): Promise<{ readonly triggered: number; readonly advanced: number }> {
308
315
  const T = getTemporal();
309
316
  const now = args.now ?? T.Now.instant();
@@ -353,6 +360,8 @@ export async function runTenantDestructionSweep(args: {
353
360
  tenantId: row.id as TenantId,
354
361
  log: args.log,
355
362
  fileProviderResolver: args.fileProviderResolver,
363
+ escapeHatchAuditSink: args.escapeHatchAuditSink,
364
+ actor: args.actor,
356
365
  });
357
366
  if (!result.error && !result.halted) advanced++;
358
367
  } catch (err) {
@@ -7,15 +7,19 @@ import {
7
7
  } from "@cosmicdrift/kumiko-framework/db";
8
8
  import {
9
9
  createSystemUser,
10
+ type EscapeHatchAuditSink,
10
11
  EXT_EXTERNAL_RESOURCE,
11
12
  EXT_INFRA_RESOURCE,
12
13
  EXT_SEARCH_ADAPTER,
13
14
  EXT_STORAGE_PROVIDER,
14
15
  EXT_TENANT_DATA,
16
+ extensionUsageEscapeHatchReason,
15
17
  type Registry,
18
+ type TenantDataHookCtx,
16
19
  type TenantId,
17
20
  } from "@cosmicdrift/kumiko-framework/engine";
18
21
  import type { FileProviderResolver } from "@cosmicdrift/kumiko-framework/files";
22
+ import { createEscapeHatchReporter } from "@cosmicdrift/kumiko-framework/pipeline";
19
23
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
20
24
  import {
21
25
  tenantEntity,
@@ -35,6 +39,11 @@ export type DestructionStageCtx = {
35
39
  // "destroyTenant" hooks) — undefined when no file-provider is wired,
36
40
  // which those hooks must treat as "nothing to clean up", not an error.
37
41
  readonly fileProviderResolver?: FileProviderResolver;
42
+ // fw#2914 — sourced from the owning job's ctx (_escapeHatchAuditSink,
43
+ // systemUser.id); runTenantDataHooks uses them to attribute+audit any
44
+ // EXT_TENANT_DATA usage's declared escapeHatch.
45
+ readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
46
+ readonly actor?: string;
38
47
  };
39
48
 
40
49
  export type DestructionStage = {
@@ -73,10 +82,25 @@ async function runTenantDataHooks(ctx: DestructionStageCtx): Promise<void> {
73
82
  const usages = ctx.registry.getExtensionUsages(EXT_TENANT_DATA);
74
83
  for (const usage of usages) {
75
84
  const destroy = usage.options?.["destroy"] as
76
- | ((ctx: DestructionStageCtx) => Promise<void>)
85
+ | ((hookCtx: TenantDataHookCtx) => Promise<void>)
77
86
  | undefined;
78
87
  if (!destroy) continue;
79
- await destroy(ctx);
88
+ const reason = extensionUsageEscapeHatchReason(usage);
89
+ const report = createEscapeHatchReporter({
90
+ handler: `${EXT_TENANT_DATA}:${usage.entityName}`,
91
+ tenantId: ctx.tenantId,
92
+ actor: ctx.actor ?? "system",
93
+ sink: ctx.escapeHatchAuditSink,
94
+ });
95
+ const hookCtx: TenantDataHookCtx = {
96
+ db: createTenantDb(ctx.db, ctx.tenantId, "tenant", undefined, undefined, undefined, {
97
+ unsafeRaw: reason !== undefined ? { reason } : undefined,
98
+ report,
99
+ }),
100
+ registry: ctx.registry,
101
+ tenantId: ctx.tenantId,
102
+ };
103
+ await destroy(hookCtx);
80
104
  }
81
105
  }
82
106
 
@@ -19,6 +19,7 @@ import { defineUnmanagedTable } from "@cosmicdrift/kumiko-framework/db";
19
19
  import {
20
20
  defineFeature,
21
21
  EXT_USER_DATA,
22
+ SYSTEM_TENANT_ID,
22
23
  type UserDataDeleteHook,
23
24
  type UserDataExportHook,
24
25
  } from "@cosmicdrift/kumiko-framework/engine";
@@ -49,7 +50,7 @@ let stack: TestStack;
49
50
 
50
51
  const TENANT_A = "00000000-0000-4000-8000-00000000000a";
51
52
  const TENANT_B = "00000000-0000-4000-8000-00000000000b";
52
- const TENANT_SYSTEM = "00000000-0000-4000-8000-000000000001";
53
+ const TENANT_SYSTEM = SYSTEM_TENANT_ID;
53
54
 
54
55
  function uuid(suffix: number): string {
55
56
  return `cccccccc-cccc-4ccc-8ccc-${suffix.toString(16).padStart(12, "0")}`;
@@ -77,20 +78,10 @@ const testNotesTable = defineUnmanagedTable({
77
78
  // Stellvertretend fuer App-spezifische Entities (Chat-Message, Blog-Post
78
79
  // etc.), die ueber EXT_USER_DATA sauber in die Pipeline integrieren.
79
80
  const exportNotes: UserDataExportHook = async (ctx) => {
80
- const result = await asRawClient(ctx.db).unsafe(
81
- `
82
- SELECT id, title, body
83
- FROM test_notes
84
- WHERE tenant_id = $1 AND author_id = $2
85
- `,
86
- [ctx.tenantId, ctx.userId],
81
+ const rows = await ctx.db.selectMany<{ id: string; title: string; body: string }>(
82
+ testNotesTable,
83
+ { tenantId: ctx.tenantId, authorId: ctx.userId },
87
84
  );
88
- // biome-ignore lint/suspicious/noExplicitAny: drizzle execute typing
89
- const rows = ((result as any).rows ?? result) as Array<{
90
- id: string;
91
- title: string;
92
- body: string;
93
- }>;
94
85
  if (rows.length === 0) return null;
95
86
  return {
96
87
  entity: "note",
@@ -0,0 +1,133 @@
1
+ // fw#2914 — proves the escape-hatch audit trail is wired end-to-end on the
2
+ // REAL production path: the registered "run-forget-cleanup" cron job (not
3
+ // runForgetCleanup called directly, not a hand-fed sink) forwards its
4
+ // JobContext's `_escapeHatchAuditSink` + `systemUser.id` down through
5
+ // runForgetCleanup → the per-entity `createEscapeHatchReporter` →
6
+ // ctx.db.unsafeRaw(reason) inside a declared-escapeHatch hook, so a
7
+ // declared raw-SQL use is actually attributed and audited instead of
8
+ // landing on the anonymous "<unattributed>" fallback.
9
+
10
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
12
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
13
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
14
+ import {
15
+ createSystemUser,
16
+ defineFeature,
17
+ type EscapeHatchUseEvent,
18
+ EXT_USER_DATA,
19
+ type JobContext,
20
+ type UserDataDeleteHook,
21
+ } from "@cosmicdrift/kumiko-framework/engine";
22
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
23
+ import {
24
+ setupTestStack,
25
+ type TestStack,
26
+ unsafeCreateEntityTable,
27
+ } from "@cosmicdrift/kumiko-framework/stack";
28
+ import { createComplianceProfilesFeature } from "../../compliance-profiles";
29
+ import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
30
+ import { createSessionsFeature } from "../../sessions";
31
+ import { createUserFeature, userEntity } from "../../user";
32
+ import { createUserDataRightsFeature } from "../feature";
33
+ import { createForgetSeeders, READ_TENANT_MEMBERSHIPS_DDL } from "./forget-test-helpers";
34
+
35
+ const FORGET_USER = "ffffffff-ffff-4fff-8fff-000000000001";
36
+ const TENANT_A = "00000000-0000-4000-8000-0000000000f9";
37
+
38
+ const AUDITED_REASON =
39
+ "fw#2914 test: declared escapeHatch on the real run-forget-cleanup job path — must be audited, not <unattributed>";
40
+
41
+ const auditedHook: UserDataDeleteHook = async (ctx) => {
42
+ await asRawClient(ctx.db.unsafeRaw(AUDITED_REASON)).unsafe(
43
+ `DELETE FROM test_audited_entity WHERE tenant_id = $1`,
44
+ [ctx.tenantId],
45
+ );
46
+ };
47
+
48
+ const auditedFeature = defineFeature("test-audited-entity", (r) => {
49
+ r.useExtension(EXT_USER_DATA, "audited-entity", {
50
+ export: async () => null,
51
+ delete: auditedHook,
52
+ escapeHatch: { reason: AUDITED_REASON },
53
+ });
54
+ });
55
+
56
+ const seed = (db: unknown) =>
57
+ // biome-ignore lint/suspicious/noExplicitAny: dummy file-writer; these seeders never write binaries.
58
+ createForgetSeeders(db as any, { write: async () => {} });
59
+
60
+ describe("escape-hatch audit :: real run-forget-cleanup job forwards _escapeHatchAuditSink + systemUser.id", () => {
61
+ let stack: TestStack;
62
+
63
+ beforeAll(async () => {
64
+ stack = await setupTestStack({
65
+ features: [
66
+ createUserFeature(),
67
+ createDataRetentionFeature(),
68
+ createComplianceProfilesFeature(),
69
+ authFoundationFeature,
70
+ createSessionsFeature(),
71
+ createUserDataRightsFeature(),
72
+ auditedFeature,
73
+ ],
74
+ });
75
+ await unsafeCreateEntityTable(stack.db, userEntity);
76
+ await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
77
+ await createEventsTable(stack.db);
78
+ await asRawClient(stack.db).unsafe(READ_TENANT_MEMBERSHIPS_DDL);
79
+ await asRawClient(stack.db).unsafe(`
80
+ CREATE TABLE IF NOT EXISTS test_audited_entity (
81
+ id UUID PRIMARY KEY,
82
+ tenant_id UUID NOT NULL
83
+ )
84
+ `);
85
+ });
86
+
87
+ afterAll(async () => {
88
+ await stack.cleanup();
89
+ });
90
+
91
+ test("declared hook's unsafeRaw use is reported with a qualified handler + the job's systemUser as actor", async () => {
92
+ const job = stack.registry.getJob("user-data-rights:job:run-forget-cleanup");
93
+ expect(job).toBeTruthy();
94
+
95
+ await seed(stack.db).seedForgetUser(FORGET_USER);
96
+ await seed(stack.db).seedMembership(FORGET_USER, TENANT_A);
97
+
98
+ const events: EscapeHatchUseEvent[] = [];
99
+ const systemUser = createSystemUser(TENANT_A);
100
+ const noopLog: JobContext["log"] = {
101
+ info() {},
102
+ warn() {},
103
+ error() {},
104
+ debug() {},
105
+ child(): JobContext["log"] {
106
+ return this;
107
+ },
108
+ };
109
+
110
+ await job?.handler({}, {
111
+ db: createTenantDb(stack.db, TENANT_A, "tenant", undefined, undefined, undefined, {
112
+ unsafeRaw: { reason: "executes overdue Art.17 forget requests across every tenant" },
113
+ }),
114
+ registry: stack.registry,
115
+ systemUser,
116
+ log: noopLog,
117
+ _escapeHatchAuditSink: async (event: EscapeHatchUseEvent) => {
118
+ events.push(event);
119
+ },
120
+ } as unknown as JobContext);
121
+
122
+ const hit = events.find((e) => e.reason === AUDITED_REASON);
123
+ expect(hit).toBeDefined();
124
+ expect(hit?.handler).toBe("userData:audited-entity");
125
+ expect(hit?.kind).toBe("unsafe-raw");
126
+ expect(hit?.tenantId).toBe(TENANT_A);
127
+ // The job's OWN systemUser.id, not "system"/"<unattributed>" — proves
128
+ // ctx.systemUser.id really travels from the JobContext down to the
129
+ // per-hook reporter instead of a hardcoded/default actor.
130
+ expect(hit?.actor).toBe(systemUser.id);
131
+ expect(hit?.actor).not.toBe("<unattributed>");
132
+ });
133
+ });
@@ -0,0 +1,198 @@
1
+ // fw#2914 — proves the escapeHatch declaration gate, not just its type.
2
+ //
3
+ // TenantDataHookCtx/UserDataHookCtx.db is a tenant-filtered TenantDb. A hook
4
+ // that declares `escapeHatch: { reason }` on its `r.useExtension(...)`
5
+ // registration can call `ctx.db.unsafeRaw(reason)` to get a REAL unfiltered
6
+ // DbRunner — the two tests below use it with a deliberately filter-less
7
+ // `DELETE FROM <table>` (no WHERE at all) to prove the grant is genuinely
8
+ // unfiltered, not merely present. A sibling hook that forgot to declare
9
+ // escapeHatch gets `AccessDeniedError` from that same call instead, which
10
+ // `runForgetCleanup` catches into `result.errors` — the row is left
11
+ // untouched rather than silently wiped/leaked across tenants.
12
+ //
13
+ // Pre-fix (TenantDataHookCtx/UserDataHookCtx.db was a raw, unfiltered
14
+ // DbRunner) BOTH hooks below — declared or not — could run this exact
15
+ // unfiltered DELETE with no gate at all. That's the regression this test
16
+ // pins shut.
17
+
18
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
19
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
20
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
21
+ import {
22
+ defineFeature,
23
+ EXT_USER_DATA,
24
+ type UserDataDeleteHook,
25
+ } from "@cosmicdrift/kumiko-framework/engine";
26
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
27
+ import {
28
+ setupTestStack,
29
+ type TestStack,
30
+ unsafeCreateEntityTable,
31
+ } from "@cosmicdrift/kumiko-framework/stack";
32
+ import { createComplianceProfilesFeature } from "../../compliance-profiles";
33
+ import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
34
+ import { createSessionsFeature } from "../../sessions";
35
+ import { createUserFeature, userEntity } from "../../user";
36
+ import { createUserDataRightsFeature } from "../feature";
37
+ import { runForgetCleanup } from "../run-forget-cleanup";
38
+ import {
39
+ createForgetSeeders,
40
+ nowInstant,
41
+ READ_TENANT_MEMBERSHIPS_DDL,
42
+ } from "./forget-test-helpers";
43
+
44
+ const FORGET_USER = "eeeeeeee-eeee-4eee-8eee-000000000001";
45
+ const TENANT_A = "00000000-0000-4000-8000-0000000000e1";
46
+ const TENANT_B_FOREIGN = "00000000-0000-4000-8000-0000000000e2";
47
+
48
+ const seed = (db: unknown) =>
49
+ // biome-ignore lint/suspicious/noExplicitAny: dummy file-writer; these seeders never write binaries.
50
+ createForgetSeeders(db as any, { write: async () => {} });
51
+
52
+ function baseFeatures(): NonNullable<Parameters<typeof setupTestStack>[0]["features"]> {
53
+ return [
54
+ createUserFeature(),
55
+ createDataRetentionFeature(),
56
+ createComplianceProfilesFeature(),
57
+ authFoundationFeature,
58
+ createSessionsFeature(),
59
+ createUserDataRightsFeature(),
60
+ ];
61
+ }
62
+
63
+ async function bootBaseTables(stack: TestStack): Promise<void> {
64
+ await unsafeCreateEntityTable(stack.db, userEntity);
65
+ await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
66
+ await createEventsTable(stack.db);
67
+ await asRawClient(stack.db).unsafe(READ_TENANT_MEMBERSHIPS_DDL);
68
+ }
69
+
70
+ describe("escape-hatch declaration :: declared hook gets a real unfiltered DbRunner", () => {
71
+ const DECLARED_REASON =
72
+ "fw#2914 test: escape-hatch declared on r.useExtension — ctx.db.unsafeRaw(reason) must grant a real unfiltered DbRunner";
73
+
74
+ const declaredLeakHook: UserDataDeleteHook = async (ctx) => {
75
+ // No WHERE clause at all — only possible with a genuinely unfiltered runner.
76
+ await asRawClient(ctx.db.unsafeRaw(DECLARED_REASON)).unsafe(`DELETE FROM test_leaky_declared`);
77
+ };
78
+
79
+ const declaredFeature = defineFeature("test-leaky-declared", (r) => {
80
+ r.useExtension(EXT_USER_DATA, "leaky-declared", {
81
+ export: async () => null,
82
+ delete: declaredLeakHook,
83
+ escapeHatch: { reason: DECLARED_REASON },
84
+ });
85
+ });
86
+
87
+ let stack: TestStack;
88
+
89
+ beforeAll(async () => {
90
+ stack = await setupTestStack({ features: [...baseFeatures(), declaredFeature] });
91
+ await bootBaseTables(stack);
92
+ await asRawClient(stack.db).unsafe(`
93
+ CREATE TABLE IF NOT EXISTS test_leaky_declared (
94
+ id UUID PRIMARY KEY,
95
+ tenant_id UUID NOT NULL,
96
+ secret TEXT NOT NULL
97
+ )
98
+ `);
99
+ await asRawClient(stack.db).unsafe(
100
+ `INSERT INTO test_leaky_declared (id, tenant_id, secret) VALUES
101
+ ('11111111-1111-4111-8111-000000000001', $1, 'tenant-a-secret'),
102
+ ('11111111-1111-4111-8111-000000000002', $2, 'tenant-b-secret')`,
103
+ [TENANT_A, TENANT_B_FOREIGN],
104
+ );
105
+ });
106
+
107
+ afterAll(async () => {
108
+ await stack.cleanup();
109
+ });
110
+
111
+ test("declared unsafeRaw wipes rows across every tenant, not just the forgotten user's own", async () => {
112
+ await seed(stack.db).seedForgetUser(FORGET_USER);
113
+ await seed(stack.db).seedMembership(FORGET_USER, TENANT_A);
114
+
115
+ const result = await runForgetCleanup({
116
+ db: stack.db,
117
+ registry: stack.registry,
118
+ now: nowInstant(),
119
+ });
120
+
121
+ expect(result.errors).toHaveLength(0);
122
+ expect(result.processedUserIds).toContain(FORGET_USER);
123
+
124
+ const remaining = await asRawClient(stack.db).unsafe(`SELECT id FROM test_leaky_declared`);
125
+ // Both Tenant A's own row AND the foreign Tenant B's row are gone —
126
+ // proves ctx.db.unsafeRaw(reason) really is unfiltered, not just granted.
127
+ expect(remaining).toHaveLength(0);
128
+ });
129
+ });
130
+
131
+ describe("escape-hatch declaration :: undeclared hook is denied, not silently unfiltered", () => {
132
+ const UNDECLARED_REASON =
133
+ "fw#2914 test: escape-hatch NOT declared on r.useExtension — ctx.db.unsafeRaw(reason) must be denied";
134
+
135
+ const undeclaredLeakHook: UserDataDeleteHook = async (ctx) => {
136
+ await asRawClient(ctx.db.unsafeRaw(UNDECLARED_REASON)).unsafe(
137
+ `DELETE FROM test_leaky_undeclared`,
138
+ );
139
+ };
140
+
141
+ const undeclaredFeature = defineFeature("test-leaky-undeclared", (r) => {
142
+ r.useExtension(EXT_USER_DATA, "leaky-undeclared", {
143
+ export: async () => null,
144
+ delete: undeclaredLeakHook,
145
+ // fw#2914 — deliberately NOT declared: this is the case under test.
146
+ });
147
+ });
148
+
149
+ let stack: TestStack;
150
+
151
+ beforeAll(async () => {
152
+ stack = await setupTestStack({ features: [...baseFeatures(), undeclaredFeature] });
153
+ await bootBaseTables(stack);
154
+ await asRawClient(stack.db).unsafe(`
155
+ CREATE TABLE IF NOT EXISTS test_leaky_undeclared (
156
+ id UUID PRIMARY KEY,
157
+ tenant_id UUID NOT NULL,
158
+ secret TEXT NOT NULL
159
+ )
160
+ `);
161
+ await asRawClient(stack.db).unsafe(
162
+ `INSERT INTO test_leaky_undeclared (id, tenant_id, secret) VALUES
163
+ ('22222222-2222-4222-8222-000000000001', $1, 'tenant-a-secret'),
164
+ ('22222222-2222-4222-8222-000000000002', $2, 'tenant-b-secret')`,
165
+ [TENANT_A, TENANT_B_FOREIGN],
166
+ );
167
+ });
168
+
169
+ afterAll(async () => {
170
+ await stack.cleanup();
171
+ });
172
+
173
+ test("undeclared unsafeRaw is denied and surfaces via result.errors, rows untouched", async () => {
174
+ await seed(stack.db).seedForgetUser(FORGET_USER);
175
+ await seed(stack.db).seedMembership(FORGET_USER, TENANT_A);
176
+
177
+ const result = await runForgetCleanup({
178
+ db: stack.db,
179
+ registry: stack.registry,
180
+ now: nowInstant(),
181
+ });
182
+
183
+ // The hook throws AccessDeniedError inside processUser's sub-tx — that
184
+ // rolls the sub-tx back, so this user is NOT flipped to Deleted.
185
+ expect(result.processedUserIds).not.toContain(FORGET_USER);
186
+ expect(result.errors).toHaveLength(1);
187
+ expect(result.errors[0]?.entityName).toBe("leaky-undeclared");
188
+ expect(result.errors[0]?.userId).toBe(FORGET_USER);
189
+ expect(result.errors[0]?.message).toContain("escapeHatch");
190
+
191
+ const remaining = await asRawClient(stack.db).unsafe(
192
+ `SELECT tenant_id FROM test_leaky_undeclared ORDER BY tenant_id`,
193
+ );
194
+ // Both rows survive — the denial happened before any SQL executed, no
195
+ // cross-tenant leak (pre-fix, this exact hook body would have wiped both).
196
+ expect(remaining).toHaveLength(2);
197
+ });
198
+ });
@@ -48,18 +48,26 @@ import {
48
48
 
49
49
  const TENANT = "00000000-0000-4000-8000-0000000000aa";
50
50
 
51
+ // fw#2914 — both host entities are managed r.entity tables (EXECUTOR_ONLY-
52
+ // branded), so TenantDb's typed write methods reject them; these hooks need
53
+ // raw SQL, still filtered by inserted_by_id + tenant_id.
54
+ const HOST_DELETE_REASON =
55
+ "fw#2914 test fixture: owner-nulling delete/anonymize against a managed entity table needs raw SQL (TenantDb's typed write API rejects EXECUTOR_ONLY tables); filtered by inserted_by_id + tenant_id";
56
+ const REDACT_REASON =
57
+ "fw#2914 test fixture: jsonb key removal (custom_fields - 'ssn') is a raw SQL expression TenantDb's typed updateMany can't express; filtered by inserted_by_id + tenant_id";
58
+
51
59
  // Owner-nulling host anonymize hook (the canonical "anonymize keeps the row,
52
60
  // clears the owner" pattern — same shape as file-ref/user host hooks).
53
61
  function makeHostDeleteHook(tableName: string): UserDataDeleteHook {
54
62
  return async (ctx, strategy) => {
55
63
  if (strategy === "delete") {
56
- await asRawClient(ctx.db).unsafe(
64
+ await asRawClient(ctx.db.unsafeRaw(HOST_DELETE_REASON)).unsafe(
57
65
  `DELETE FROM ${tableName} WHERE inserted_by_id = $1 AND tenant_id = $2`,
58
66
  [ctx.userId, ctx.tenantId],
59
67
  );
60
68
  return;
61
69
  }
62
- await asRawClient(ctx.db).unsafe(
70
+ await asRawClient(ctx.db.unsafeRaw(HOST_DELETE_REASON)).unsafe(
63
71
  `UPDATE ${tableName} SET inserted_by_id = NULL WHERE inserted_by_id = $1 AND tenant_id = $2`,
64
72
  [ctx.userId, ctx.tenantId],
65
73
  );
@@ -72,7 +80,7 @@ function makeRedactHook(tableName: string): UserDataDeleteHook {
72
80
  return async (ctx, strategy) => {
73
81
  // skip: delete strategy removes rows wholesale — redaction N/A.
74
82
  if (strategy === "delete") return;
75
- await asRawClient(ctx.db).unsafe(
83
+ await asRawClient(ctx.db.unsafeRaw(REDACT_REASON)).unsafe(
76
84
  `UPDATE ${tableName} SET custom_fields = custom_fields - 'ssn'
77
85
  WHERE inserted_by_id = $1 AND tenant_id = $2`,
78
86
  [ctx.userId, ctx.tenantId],
@@ -119,10 +127,12 @@ const cfFirstFeature = defineFeature(CF_FIRST.featureName, (r) => {
119
127
  export: async () => null,
120
128
  delete: makeRedactHook(CF_FIRST.tableName),
121
129
  order: EXT_USER_DATA_ORDER.REDACT_BEFORE_OWNER,
130
+ escapeHatch: { reason: REDACT_REASON },
122
131
  });
123
132
  r.useExtension(EXT_USER_DATA, CF_FIRST.entityName, {
124
133
  export: async () => null,
125
134
  delete: makeHostDeleteHook(CF_FIRST.tableName),
135
+ escapeHatch: { reason: HOST_DELETE_REASON },
126
136
  });
127
137
  });
128
138
 
@@ -131,11 +141,13 @@ const hostFirstFeature = defineFeature(HOST_FIRST.featureName, (r) => {
131
141
  r.useExtension(EXT_USER_DATA, HOST_FIRST.entityName, {
132
142
  export: async () => null,
133
143
  delete: makeHostDeleteHook(HOST_FIRST.tableName),
144
+ escapeHatch: { reason: HOST_DELETE_REASON },
134
145
  });
135
146
  r.useExtension(EXT_USER_DATA, HOST_FIRST.entityName, {
136
147
  export: async () => null,
137
148
  delete: makeRedactHook(HOST_FIRST.tableName),
138
149
  order: EXT_USER_DATA_ORDER.REDACT_BEFORE_OWNER,
150
+ escapeHatch: { reason: REDACT_REASON },
139
151
  });
140
152
  });
141
153
 
@@ -15,6 +15,7 @@ import {
15
15
  createTextField,
16
16
  defineFeature,
17
17
  EXT_USER_DATA,
18
+ SYSTEM_TENANT_ID,
18
19
  type UserDataDeleteHook,
19
20
  } from "@cosmicdrift/kumiko-framework/engine";
20
21
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
@@ -32,7 +33,7 @@ import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../use
32
33
  import { createUserDataRightsFeature } from "../feature";
33
34
  import { runForgetCleanup } from "../run-forget-cleanup";
34
35
 
35
- const TENANT_SYSTEM = "00000000-0000-4000-8000-000000000001";
36
+ const TENANT_SYSTEM = SYSTEM_TENANT_ID;
36
37
  const TENANT_A = "00000000-0000-4000-8000-0000000000f1";
37
38
  const ALICE_ID = "aaaaaaaa-aaaa-4aaa-8aaa-0000000000f1";
38
39
  const NOTE_ID = "bbbbbbbb-bbbb-4bbb-8bbb-0000000000f1";
@@ -53,9 +54,15 @@ const noteEntity = createEntity({
53
54
  });
54
55
  const noteTable = buildEntityTable("note", noteEntity);
55
56
 
57
+ // fw#2914 — "note" is a managed r.entity table (EXECUTOR_ONLY-branded), so
58
+ // TenantDb's typed write methods reject it; this delete is raw SQL, still
59
+ // filtered by author_id + tenant_id.
60
+ const HARD_DELETE_NOTE_REASON =
61
+ "fw#2914 test fixture: hard delete against a managed entity table needs raw SQL (TenantDb's typed write API rejects EXECUTOR_ONLY tables); filtered by author_id + tenant_id";
62
+
56
63
  const hardDeleteNoteHook: UserDataDeleteHook = async (ctx, strategy) => {
57
64
  if (strategy !== "delete") return;
58
- await asRawClient(ctx.db).unsafe(
65
+ await asRawClient(ctx.db.unsafeRaw(HARD_DELETE_NOTE_REASON)).unsafe(
59
66
  `DELETE FROM read_forget_purge_notes WHERE author_id = $1 AND tenant_id = $2`,
60
67
  [ctx.userId, ctx.tenantId],
61
68
  );
@@ -66,6 +73,7 @@ const noteFeature = defineFeature("forget-purge-notes", (r) => {
66
73
  r.useExtension(EXT_USER_DATA, "note", {
67
74
  export: async () => null,
68
75
  delete: hardDeleteNoteHook,
76
+ escapeHatch: { reason: HARD_DELETE_NOTE_REASON },
69
77
  });
70
78
  });
71
79
 
@@ -13,6 +13,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
13
13
  import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
14
14
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
15
15
  import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
16
+ import { createSystemUser, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
16
17
  import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
17
18
  import {
18
19
  setupTestStack,
@@ -35,7 +36,7 @@ import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defa
35
36
  import { createUserDataRightsFeature } from "../feature";
36
37
 
37
38
  const TENANT_A = "00000000-0000-4000-8000-0000000006a1";
38
- const TENANT_SYSTEM = "00000000-0000-4000-8000-000000000001";
39
+ const TENANT_SYSTEM = SYSTEM_TENANT_ID;
39
40
  const USER_ID = "00000000-0000-4000-8000-0000000006b1";
40
41
  const ORIGINAL_EMAIL = "bridge-delete@example.test";
41
42
  const FORGET_JOB = "user-data-rights:job:run-forget-cleanup";
@@ -133,6 +134,7 @@ describe("C6 default mail bridge :: forget cron sends deletion-executed without
133
134
  }),
134
135
  registry: stack.registry,
135
136
  configResolver,
137
+ systemUser: createSystemUser(TENANT_A),
136
138
  log: {
137
139
  info() {},
138
140
  warn() {},
@@ -13,7 +13,11 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
13
13
  import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
14
14
  import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
15
15
  import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
16
- import { type JobContext, SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
16
+ import {
17
+ createSystemUser,
18
+ type JobContext,
19
+ SYSTEM_USER_ID,
20
+ } from "@cosmicdrift/kumiko-framework/engine";
17
21
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
18
22
  import { registerMailTranslations } from "@cosmicdrift/kumiko-framework/i18n";
19
23
  import {
@@ -153,12 +157,13 @@ describe("run-export-jobs cron-context", () => {
153
157
  registry: stack.registry,
154
158
  configResolver,
155
159
  _userId: SYSTEM_USER_ID,
160
+ systemUser: createSystemUser(TENANT),
156
161
  now: getTemporal().Now.instant(),
157
162
  };
158
163
  // Vor dem Fix wirft der Wrapper hier "ctx.config is missing".
159
164
  // jobCtx is a deliberately minimal prod-context replica (missing
160
- // systemUser/log/triggeredBy) — double-cast at this test boundary since
161
- // the handler under test never touches those fields.
165
+ // log/triggeredBy) — double-cast at this test boundary since the
166
+ // handler under test never touches those fields.
162
167
  await job?.handler({}, jobCtx as unknown as JobContext);
163
168
 
164
169
  const [row] = (await selectMany(stack.db, exportJobsTable, { id: jobId })) as Array<{