@cosmicdrift/kumiko-bundled-features 0.234.0 → 0.235.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.234.0",
3
+ "version": "0.235.1",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -130,12 +130,12 @@
130
130
  "./workflow-runner": "./src/workflow-runner/index.ts"
131
131
  },
132
132
  "dependencies": {
133
- "@cosmicdrift/kumiko-dispatcher-live": "0.234.0",
134
- "@cosmicdrift/kumiko-framework": "0.234.0",
135
- "@cosmicdrift/kumiko-headless": "0.234.0",
136
- "@cosmicdrift/kumiko-renderer": "0.234.0",
137
- "@cosmicdrift/kumiko-renderer-web": "0.234.0",
138
- "@cosmicdrift/kumiko-types": "0.234.0",
133
+ "@cosmicdrift/kumiko-dispatcher-live": "0.235.1",
134
+ "@cosmicdrift/kumiko-framework": "0.235.1",
135
+ "@cosmicdrift/kumiko-headless": "0.235.1",
136
+ "@cosmicdrift/kumiko-renderer": "0.235.1",
137
+ "@cosmicdrift/kumiko-renderer-web": "0.235.1",
138
+ "@cosmicdrift/kumiko-types": "0.235.1",
139
139
  "@mollie/api-client": "^4.5.0",
140
140
  "@node-rs/argon2": "^2.0.2",
141
141
  "@types/mailparser": "^3.4.6",
@@ -164,7 +164,7 @@
164
164
  "devDependencies": {
165
165
  "@testing-library/user-event": "^14.6.1",
166
166
  "@types/qrcode": "^1.5.5",
167
- "@cosmicdrift/kumiko-locale-de": "0.234.0",
168
- "@cosmicdrift/kumiko-locale-es": "0.234.0"
167
+ "@cosmicdrift/kumiko-locale-de": "0.235.1",
168
+ "@cosmicdrift/kumiko-locale-es": "0.235.1"
169
169
  }
170
170
  }
@@ -1,6 +1,11 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
4
+ import {
5
+ configureBlindIndexKey,
6
+ configurePiiSubjectKms,
7
+ InMemoryKmsAdapter,
8
+ } from "@cosmicdrift/kumiko-framework/crypto";
4
9
  import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
5
10
  import {
6
11
  createTestUser,
@@ -16,6 +21,9 @@ import {
16
21
  expectErrorIncludes,
17
22
  getSetCookieRaw,
18
23
  getSetCookieValue,
24
+ resetBlindIndexKeyForTests,
25
+ resetPiiSubjectKmsForTests,
26
+ updateRows,
19
27
  } from "@cosmicdrift/kumiko-framework/testing";
20
28
  import { createConfigFeature } from "../../config";
21
29
  import { createConfigResolver } from "../../config/resolver";
@@ -215,6 +223,50 @@ describe("scenario 3: login without membership", () => {
215
223
  });
216
224
  });
217
225
 
226
+ // --- Scenario 3b: login resolves the live account when its email is shared
227
+ // with a soft-deleted row (fw#2464 partial bidx index only covers live rows) ---
228
+
229
+ describe("scenario 3b: login with an email shared by a soft-deleted row", () => {
230
+ // The DB-level dedup that lets a soft-deleted row and a live row share an
231
+ // email only exists on the blind-index column (see
232
+ // email-unique-blind-index.integration.test.ts) — without a configured
233
+ // key, email_bidx stays NULL and the plaintext-fallback unique index
234
+ // blocks the second create outright, never reaching the login path.
235
+ beforeAll(() => {
236
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
237
+ configureBlindIndexKey(Buffer.alloc(32, 7).toString("base64"));
238
+ });
239
+
240
+ afterAll(() => {
241
+ resetPiiSubjectKmsForTests();
242
+ resetBlindIndexKeyForTests();
243
+ });
244
+
245
+ test("new account wins the login, not the soft-deleted original", async () => {
246
+ const original = await seedLoginUser({
247
+ email: "reused-login@example.com",
248
+ password: "original-password-123",
249
+ });
250
+ await updateRows(stack.db, userTable, { isDeleted: true }, { id: original.id });
251
+
252
+ const recreated = await seedLoginUser({
253
+ email: "reused-login@example.com",
254
+ password: "new-account-password-123",
255
+ });
256
+ expect(recreated.id).not.toBe(original.id);
257
+
258
+ const res = await stack.http.raw("POST", "/api/auth/login", {
259
+ email: "reused-login@example.com",
260
+ password: "new-account-password-123",
261
+ });
262
+
263
+ expect(res.status).toBe(200);
264
+ const body = await res.json();
265
+ expect(body.isSuccess).toBe(true);
266
+ expect(body.user.id).toBe(recreated.id);
267
+ });
268
+ });
269
+
218
270
  // --- Scenario 4 + 5: change-password flow ---
219
271
 
220
272
  describe("scenario 4: change-password with wrong old password", () => {
@@ -139,11 +139,13 @@ export async function provisionSignupAccount(
139
139
  // create paths (#1478 — this was only wired for tenant before).
140
140
  hooks?: SeedTenantHooks,
141
141
  ): Promise<{ readonly userId: string; readonly tenantId: TenantId }> {
142
- // Create-only-Guard VOR seedTenant: bei bereits registrierter Email hart
143
- // abbrechen, sonst entstünde ein verwaister Tenant und seedUser (idempotent
144
- // add-only) gäbe still die bestehende userId zurück → Confirm mintet eine
145
- // Session für den fremden Account (#365).
146
- const existingUser = await fetchOne(db, userTable, { email: options.email });
142
+ // Create-only guard BEFORE seedTenant: hard-abort on an already-registered
143
+ // email, otherwise an orphaned tenant would be created and seedUser
144
+ // (idempotent add-only) would silently return the existing userId ->
145
+ // confirm mints a session for the wrong account (#365).
146
+ // isDeleted: false — the partial bidx unique index only covers live rows,
147
+ // so a soft-deleted row must not block re-registering its email.
148
+ const existingUser = await fetchOne(db, userTable, { email: options.email, isDeleted: false });
147
149
  if (existingUser) {
148
150
  throw new ConflictError({ message: "signup: email already registered" });
149
151
  }
@@ -1,12 +1,13 @@
1
- import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import {
3
3
  createTestUser,
4
4
  setupTestStack,
5
5
  type TestStack,
6
+ testTenantId,
6
7
  unsafeCreateEntityTable,
7
8
  } from "@cosmicdrift/kumiko-framework/stack";
8
9
  import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
9
- import { CAP_COUNTER_LIST_SCREEN_ID, CapCounterQueries } from "../constants";
10
+ import { CAP_COUNTER_LIST_SCREEN_ID, CapCounterHandlers, CapCounterQueries } from "../constants";
10
11
  import { capCounterEntity } from "../entity";
11
12
  import { capCounterFeature } from "../feature";
12
13
 
@@ -23,6 +24,14 @@ afterAll(async () => {
23
24
  await stack.cleanup();
24
25
  });
25
26
 
27
+ function tenantAdminFor(tenantNumber: number) {
28
+ return createTestUser({
29
+ id: tenantNumber,
30
+ tenantId: testTenantId(tenantNumber),
31
+ roles: ["TenantAdmin"],
32
+ });
33
+ }
34
+
26
35
  describe("cap-counter access matrix", () => {
27
36
  test("cap-list screen and list query are SystemAdmin-only", () => {
28
37
  expect(rolesOf(stack.registry.getQueryHandler(CapCounterQueries.list)?.access)).toEqual([
@@ -51,3 +60,56 @@ describe("cap-counter list HTTP access", () => {
51
60
  expect((await stack.http.query(CapCounterQueries.list, {}, user)).status).toBe(403);
52
61
  });
53
62
  });
63
+
64
+ describe("cap-counter list cross-tenant read", () => {
65
+ const CAP_NAME = "cross-tenant-list-probe";
66
+
67
+ beforeEach(async () => {
68
+ // increment access is SystemAdmin-only (see increment.write.ts) — tenant
69
+ // scoping on the write side comes from the actor's tenantId, not the
70
+ // role, so a SystemAdmin actor per seed-tenant still writes into that
71
+ // tenant's own row.
72
+ const seedTenantA = createTestUser({
73
+ id: 201,
74
+ tenantId: testTenantId(201),
75
+ roles: ["SystemAdmin"],
76
+ });
77
+ const seedTenantB = createTestUser({
78
+ id: 202,
79
+ tenantId: testTenantId(202),
80
+ roles: ["SystemAdmin"],
81
+ });
82
+ await stack.http.writeOk(
83
+ CapCounterHandlers.increment,
84
+ { capName: CAP_NAME, amount: 11, periodStartIso: "2026-05-01T00:00:00Z" },
85
+ seedTenantA,
86
+ );
87
+ await stack.http.writeOk(
88
+ CapCounterHandlers.increment,
89
+ { capName: CAP_NAME, amount: 22, periodStartIso: "2026-05-01T00:00:00Z" },
90
+ seedTenantB,
91
+ );
92
+ });
93
+
94
+ test("SystemAdmin sees rows from BOTH tenants, not just its own", async () => {
95
+ const sysadmin = createTestUser({ id: 43, roles: ["SystemAdmin"] });
96
+ const res = await stack.http.queryOk<{ rows: readonly Record<string, unknown>[] }>(
97
+ CapCounterQueries.list,
98
+ { filter: { field: "capName", op: "eq", value: CAP_NAME } },
99
+ sysadmin,
100
+ );
101
+
102
+ const tenantIds = res.rows.map((row) => row["tenantId"]);
103
+ expect(tenantIds).toContain(testTenantId(201));
104
+ expect(tenantIds).toContain(testTenantId(202));
105
+ // Neither seeded tenant matches the querying SystemAdmin's own
106
+ // tenantId — proves this isn't accidentally passing via tenant-match.
107
+ expect(sysadmin.tenantId).not.toBe(testTenantId(201));
108
+ expect(sysadmin.tenantId).not.toBe(testTenantId(202));
109
+ });
110
+
111
+ test("TenantAdmin still gets 403 on list (cross-tenant read stays SystemAdmin-only)", async () => {
112
+ const tenantAdmin = tenantAdminFor(201);
113
+ expect((await stack.http.query(CapCounterQueries.list, {}, tenantAdmin)).status).toBe(403);
114
+ });
115
+ });
@@ -46,7 +46,11 @@
46
46
  // config, kein secrets, kein tenant-feature nötig. Tenant-Scoping kommt
47
47
  // vom Framework-Default (Base-Column tenantId).
48
48
 
49
- import { defineEntityListHandler, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
49
+ import {
50
+ access,
51
+ defineEntityListHandler,
52
+ defineFeature,
53
+ } from "@cosmicdrift/kumiko-framework/engine";
50
54
  import {
51
55
  CAP_COUNTER_FEATURE,
52
56
  CAP_COUNTER_LIST_SCREEN_ID,
@@ -62,7 +66,7 @@ import {
62
66
  import { markSoftWarnedHandler } from "./handlers/mark-soft-warned.write";
63
67
  import { CAP_COUNTER_I18N } from "./i18n";
64
68
 
65
- const sysadminAccess = { access: { roles: ["SystemAdmin"] } } as const;
69
+ const sysadminAccess = { access: { roles: access.systemAdmin }, crossTenant: true } as const;
66
70
 
67
71
  export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
68
72
  r.describe(
@@ -101,7 +105,7 @@ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
101
105
  id: CAP_COUNTER_LIST_SCREEN_ID,
102
106
  type: "entityList",
103
107
  entity: "cap-counter",
104
- columns: ["capName", "value", "periodStart", "lastSoftWarnedAt"],
108
+ columns: ["tenantId", "capName", "value", "periodStart", "lastSoftWarnedAt"],
105
109
  defaultSort: { field: "capName", dir: "asc" },
106
110
  searchable: true,
107
111
  access: { roles: ["SystemAdmin"] },
@@ -117,6 +121,7 @@ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
117
121
  keys: {
118
122
  ...CAP_COUNTER_I18N,
119
123
  "cap-counter:nav.cap-list": { de: "Limits", en: "Caps" },
124
+ "cap-counter:entity:cap-counter:field:tenantId": { de: "Mandant", en: "Tenant" },
120
125
  },
121
126
  });
122
127
  });
@@ -2,6 +2,7 @@ type LocalizedString = { readonly en: string };
2
2
 
3
3
  export const CAP_COUNTER_I18N: Readonly<Record<string, LocalizedString>> = {
4
4
  "screen:cap-list.title": { en: "Usage caps" },
5
+ "cap-counter:entity:cap-counter:field:tenantId": { en: "Tenant" },
5
6
  "cap-counter:entity:cap-counter:field:capName": { en: "Cap" },
6
7
  "cap-counter:entity:cap-counter:field:value": { en: "Value" },
7
8
  "cap-counter:entity:cap-counter:field:periodStart": { en: "Period start" },
@@ -1,9 +1,4 @@
1
- type LocalizedString = { readonly en: string };
1
+ import { SETTINGS_HUB_I18N } from "@cosmicdrift/kumiko-framework/i18n";
2
2
 
3
3
  /** Server boot keys for the auto-generated Settings-Hub (mirrors web/i18n.ts). */
4
- export const CONFIG_FEATURE_I18N: Readonly<Record<string, LocalizedString>> = {
5
- "config.settings.title": { en: "Settings" },
6
- "config.settings.system": { en: "Platform" },
7
- "config.settings.tenant": { en: "Tenant" },
8
- "config.settings.user": { en: "Personal" },
9
- };
4
+ export const CONFIG_FEATURE_I18N = SETTINGS_HUB_I18N;
@@ -11,6 +11,13 @@ import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
11
11
 
12
12
  export const defaultTranslations: TranslationsByLocale = {
13
13
  en: {
14
+ "config.secrets.delete": "Delete",
15
+ "config.secrets.notSet": "Not set",
16
+ "config.secrets.placeholder": "Enter a value",
17
+ "config.secrets.replacePlaceholder": "Enter a new value to replace",
18
+ "config.secrets.required": "Required",
19
+ "config.secrets.set": "Set",
20
+ "config.secrets.title": "Secrets",
14
21
  "config.settings.title": "Settings",
15
22
  "config.settings.system": "Platform",
16
23
  "config.settings.tenant": "Tenant",
@@ -18,5 +25,9 @@ export const defaultTranslations: TranslationsByLocale = {
18
25
  "config.errors.systemOnly": "This value can only be set by the system.",
19
26
  "config.errors.invalidScope": "This scope is not allowed for this key.",
20
27
  "config.errors.unknownKey": "Unknown configuration key.",
28
+ // Required by every generated screen (screenTitleKey, required-surface-keys.ts) —
29
+ // the secrets screen has a fixed id ("secrets"), so the framework ships its
30
+ // title translation directly instead of asking every app to declare it.
31
+ "screen:secrets.title": "Secrets",
21
32
  },
22
33
  };
@@ -46,7 +46,7 @@ import { seedTenantMembership } from "../../tenant/seeding";
46
46
  import { USER_STATUS, userEntity, userTable } from "../../user";
47
47
  import { createUserFeature } from "../../user/feature";
48
48
  import { seedUser } from "../../user/seeding";
49
- import { SUBJECT_FORGOTTEN_EVENT_NAME } from "../constants";
49
+ import { SUBJECT_FORGET_DENIED_EVENT_NAME, SUBJECT_FORGOTTEN_EVENT_NAME } from "../constants";
50
50
  import { createCryptoShreddingFeature } from "../feature";
51
51
 
52
52
  const FORGET = "crypto-shredding:write:forget-subject";
@@ -96,6 +96,17 @@ async function forgottenEvents(): Promise<Array<{ payload: Record<string, unknow
96
96
  })) as Array<{ payload: Record<string, unknown> }>;
97
97
  }
98
98
 
99
+ // Parameterized (unlike forgottenEvents) because the denial-audit tests
100
+ // below run against both this file's top-level stack and the separate
101
+ // tenant+user-mounted stack further down — each has its own events table.
102
+ async function deniedEvents(
103
+ db: TestStack["db"] = stack.db,
104
+ ): Promise<Array<{ payload: Record<string, unknown> }>> {
105
+ return (await selectMany(db, eventsTable, {
106
+ type: SUBJECT_FORGET_DENIED_EVENT_NAME,
107
+ })) as Array<{ payload: Record<string, unknown> }>;
108
+ }
109
+
99
110
  describe("crypto-shredding :: forget-subject", () => {
100
111
  test("DPO forgets a user subject → key erased + audit event", async () => {
101
112
  const subject = { kind: "user", userId: TARGET_USER_ID } as const;
@@ -150,6 +161,26 @@ describe("crypto-shredding :: forget-subject", () => {
150
161
  ).resolves.toBeTruthy();
151
162
  });
152
163
 
164
+ // fw#2592 regression: the denial audit event used to be appended via
165
+ // ctx.unsafeAppendEvent, inside the same tx the denial then rolled back —
166
+ // the store stayed empty even though a cross-tenant probe was denied.
167
+ test("cross-tenant denial (kind=tenant) still leaves an audit event that survives the rollback", async () => {
168
+ const subject = { kind: "tenant", tenantId: TARGET_TENANT_ID } as const;
169
+ await kms.createKey({ kind: "tenant", tenantId: TARGET_TENANT_ID as TenantId });
170
+
171
+ const err = await stack.http.writeErr(FORGET, { subject, reason: REASON }, dpoUser);
172
+ expect(err.httpStatus).toBe(403);
173
+
174
+ const denied = await deniedEvents();
175
+ expect(denied).toHaveLength(1);
176
+ expect(denied[0]?.payload).toMatchObject({
177
+ subjectKind: "tenant",
178
+ reason: REASON,
179
+ forgottenBy: dpoUser.id,
180
+ actorTenantId: dpoUser.tenantId,
181
+ });
182
+ });
183
+
153
184
  test("SystemAdmin bypasses the tenant-scope guard", async () => {
154
185
  const subject = { kind: "tenant", tenantId: TARGET_TENANT_ID } as const;
155
186
  await kms.createKey({ kind: "tenant", tenantId: TARGET_TENANT_ID as TenantId });
@@ -479,6 +510,64 @@ describe("crypto-shredding :: forget-subject closes the login door (user feature
479
510
  expect(userRow?.["status"]).not.toBe(USER_STATUS.Deleted);
480
511
  });
481
512
 
513
+ // fw#2592 regression, kind=user variant of the top-level "cross-tenant
514
+ // denial ... survives the rollback" test: before the fix, deniedEvents()
515
+ // would come back empty here too.
516
+ test("cross-tenant denial (kind=user) still leaves an audit event that survives the rollback", async () => {
517
+ const { id: userId } = await seedUser(stack.db, {
518
+ email: "foreign-tenant-user-2592@example.com",
519
+ displayName: "Foreign Tenant User 2592",
520
+ emailVerified: true,
521
+ });
522
+ await seedTenantMembership(stack.db, { userId, tenantId: TENANT_B, roles: ["Member"] });
523
+
524
+ const err = await stack.http.writeErr(
525
+ FORGET,
526
+ { subject: { kind: "user", userId }, reason: REASON },
527
+ dpoUser,
528
+ );
529
+ expect(err.httpStatus).toBe(403);
530
+
531
+ const denied = await deniedEvents(stack.db);
532
+ expect(denied).toHaveLength(1);
533
+ expect(denied[0]?.payload).toMatchObject({
534
+ subjectKind: "user",
535
+ reason: REASON,
536
+ forgottenBy: dpoUser.id,
537
+ actorTenantId: dpoUser.tenantId,
538
+ });
539
+ });
540
+
541
+ // fw#2591 payload-hardening coverage (previously untested): the denial
542
+ // event must carry only a digest, never the foreign user id or a
543
+ // plaintext subject key.
544
+ test("denial payload carries only the digest, never the foreign subject id or a plaintext key", async () => {
545
+ const { id: userId } = await seedUser(stack.db, {
546
+ email: "foreign-tenant-user-2591@example.com",
547
+ displayName: "Foreign Tenant User 2591",
548
+ emailVerified: true,
549
+ });
550
+ await seedTenantMembership(stack.db, { userId, tenantId: TENANT_B, roles: ["Member"] });
551
+
552
+ await stack.http.writeErr(
553
+ FORGET,
554
+ { subject: { kind: "user", userId }, reason: REASON },
555
+ dpoUser,
556
+ );
557
+
558
+ const denied = await deniedEvents(stack.db);
559
+ expect(denied).toHaveLength(1);
560
+ const payload = denied[0]?.payload ?? {};
561
+ expect(payload["subjectKeyDigest"]).toBeTruthy();
562
+ expect(payload["subjectKind"]).toBe("user");
563
+ expect(payload).not.toHaveProperty("subjectKey");
564
+ expect(payload["actorTenantId"]).toBe(dpoUser.tenantId);
565
+
566
+ const serialized = JSON.stringify(payload);
567
+ expect(serialized).not.toContain(userId);
568
+ expect(serialized).not.toContain(TENANT_B);
569
+ });
570
+
482
571
  test("SystemAdmin can forget a user with no membership in any tenant", async () => {
483
572
  const { id: userId } = await seedUser(stack.db, {
484
573
  email: "system-admin-forgets@example.com",
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { requestContext } from "@cosmicdrift/kumiko-framework/api";
2
3
  import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
3
4
  import {
@@ -13,8 +14,10 @@ import {
13
14
  import {
14
15
  defineWriteHandler,
15
16
  type FeatureDefinition,
17
+ type HandlerContext,
16
18
  type SessionUser,
17
19
  type TenantId,
20
+ type WriteEvent,
18
21
  } from "@cosmicdrift/kumiko-framework/engine";
19
22
  import {
20
23
  AccessDeniedError,
@@ -22,7 +25,9 @@ import {
22
25
  type WriteFailure,
23
26
  writeFailure,
24
27
  } from "@cosmicdrift/kumiko-framework/errors";
28
+ import { append } from "@cosmicdrift/kumiko-framework/event-store";
25
29
  import { purgeSearchDocumentsForSubject } from "@cosmicdrift/kumiko-framework/search";
30
+ import { generateId } from "@cosmicdrift/kumiko-framework/utils";
26
31
  import { z } from "zod";
27
32
  import { revokeAllPatTokensForUser } from "../../personal-access-tokens";
28
33
  import { USER_STATUS } from "../../user";
@@ -55,7 +60,8 @@ export const subjectForgottenSchema = z.object({
55
60
  });
56
61
 
57
62
  export const subjectForgetDeniedSchema = z.object({
58
- subjectKey: z.string().min(1),
63
+ subjectKeyDigest: z.string().min(1),
64
+ subjectKind: z.enum(["user", "tenant"]),
59
65
  reason: z.string().min(10),
60
66
  forgottenBy: z.string().min(1),
61
67
  actorTenantId: z.string().min(1),
@@ -99,6 +105,75 @@ async function resolveTenantScopeDenial(
99
105
  return ownedInTenant ? undefined : memberDenied;
100
106
  }
101
107
 
108
+ // Denied cross-tenant probes must still leave an audit trail (fw#2348).
109
+ // Appended outside the handler tx (fw#2592): the caller's
110
+ // `return tenantScopeDenial` rolls that tx back.
111
+ async function appendDenialAuditEvent(
112
+ ctx: HandlerContext,
113
+ event: WriteEvent<z.infer<typeof forgetSubjectSchema>>,
114
+ subjectKey: string,
115
+ subjectKind: SubjectIdInput["kind"],
116
+ denialCode: string,
117
+ ): Promise<WriteFailure | null> {
118
+ const outsideTx = ctx.dbOutsideTransaction;
119
+ if (!outsideTx) {
120
+ return writeFailure(
121
+ new InternalError({
122
+ message:
123
+ "[crypto-shredding] forget-subject denial audit event cannot be appended without " +
124
+ "ctx.dbOutsideTransaction — dispatch wiring is missing the outside-tx db source.",
125
+ }),
126
+ );
127
+ }
128
+ const eventDef = ctx.registry.getEvent(SUBJECT_FORGET_DENIED_EVENT_NAME);
129
+ if (!eventDef) {
130
+ return writeFailure(
131
+ new InternalError({
132
+ message: `[crypto-shredding] event "${SUBJECT_FORGET_DENIED_EVENT_NAME}" is not registered.`,
133
+ }),
134
+ );
135
+ }
136
+ const payload = subjectForgetDeniedSchema.parse({
137
+ // The denial lands in the REQUESTING actor's own tenant-scoped stream —
138
+ // it must never materialise the foreign subject's identifiers there. A
139
+ // plaintext subjectKey/aggregateId would survive as a permanent record
140
+ // for the prober and would still be present when the owning tenant
141
+ // later runs its own (legitimate) forget-subject for that subject. The
142
+ // digest still lets an operator correlate repeated probes of the same
143
+ // subject without exposing it (fw#2452).
144
+ subjectKeyDigest: createHash("sha256").update(subjectKey, "utf8").digest("base64url"),
145
+ subjectKind,
146
+ reason: event.payload.reason,
147
+ forgottenBy: event.user.id,
148
+ actorTenantId: event.user.tenantId,
149
+ denial: denialCode,
150
+ });
151
+ const reqCtx = requestContext.get();
152
+ // This event is the only proof the denial happened. Skipping
153
+ // runProjectionsForEvent here is fine — nothing projects
154
+ // crypto-shredding:event:forget-denied.
155
+ await append(outsideTx.raw, {
156
+ aggregateId: generateId(),
157
+ aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
158
+ // MUST be event.user.tenantId (the prober's own tenant), never
159
+ // SYSTEM_TENANT_ID — .raw bypasses TenantDb's scoping wrapper, so this
160
+ // is the only thing keeping the denial event out of the foreign
161
+ // subject's tenant (fw#2452).
162
+ tenantId: event.user.tenantId,
163
+ expectedVersion: 0,
164
+ type: SUBJECT_FORGET_DENIED_EVENT_NAME,
165
+ eventVersion: eventDef.version,
166
+ payload,
167
+ metadata: {
168
+ userId: event.user.id,
169
+ ...(reqCtx?.requestId ? { requestId: reqCtx.requestId } : {}),
170
+ ...(reqCtx?.correlationId ? { correlationId: reqCtx.correlationId } : {}),
171
+ ...(reqCtx?.causationId ? { causationId: reqCtx.causationId } : {}),
172
+ },
173
+ });
174
+ return null;
175
+ }
176
+
102
177
  // Manual crypto-shred for a DPO / platform operator: erases the subject's
103
178
  // DEK immediately (all its PII ciphertext becomes unreadable, reads render
104
179
  // "[[erased]]") and appends the audit event. Forget is final — the adapter
@@ -138,20 +213,14 @@ export const forgetSubjectWrite = defineWriteHandler({
138
213
  raw,
139
214
  );
140
215
  if (tenantScopeDenial) {
141
- // Denied cross-tenant probes must still leave an audit trail (fw#2348).
142
- await ctx.unsafeAppendEvent({
143
- aggregateId: raw.kind === "user" ? raw.userId : raw.tenantId,
144
- aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
145
- type: SUBJECT_FORGET_DENIED_EVENT_NAME,
146
- payload: {
147
- subjectKey,
148
- reason: event.payload.reason,
149
- forgottenBy: event.user.id,
150
- actorTenantId: event.user.tenantId,
151
- denial: tenantScopeDenial.error.code,
152
- },
153
- });
154
- return tenantScopeDenial;
216
+ const auditFailure = await appendDenialAuditEvent(
217
+ ctx,
218
+ event,
219
+ subjectKey,
220
+ raw.kind,
221
+ tenantScopeDenial.error.code,
222
+ );
223
+ return auditFailure ?? tenantScopeDenial;
155
224
  }
156
225
 
157
226
  // Erase BEFORE the audit append: if the append throws, the key is gone
@@ -451,10 +451,12 @@ describe("jobs:write:retry decrypts payload before dispatch (#2465)", () => {
451
451
  const RETRY_USER_ID = "u-pii-retry-1";
452
452
  const RETRY_JOB_NAME = "retryapp:job:capture-import";
453
453
  const capturedPayloads: Record<string, unknown>[] = [];
454
+ const capturedTriggeredBy: (string | null)[] = [];
454
455
 
455
456
  const retryAppFeature = defineFeature("retryapp", (r) => {
456
- r.job("captureImport", { trigger: { manual: true } }, async (payload) => {
457
+ r.job("captureImport", { trigger: { manual: true } }, async (payload, ctx) => {
457
458
  capturedPayloads.push(payload);
459
+ capturedTriggeredBy.push(ctx.triggeredBy?.id ?? null);
458
460
  });
459
461
  });
460
462
 
@@ -476,6 +478,7 @@ describe("jobs:write:retry decrypts payload before dispatch (#2465)", () => {
476
478
 
477
479
  beforeEach(() => {
478
480
  capturedPayloads.length = 0;
481
+ capturedTriggeredBy.length = 0;
479
482
  retryKms = new InMemoryKmsAdapter();
480
483
  configurePiiSubjectKms(retryKms);
481
484
  });
@@ -527,6 +530,40 @@ describe("jobs:write:retry decrypts payload before dispatch (#2465)", () => {
527
530
  expect(errInfo.details).toMatchObject({ reason: "job_payload_erased" });
528
531
  });
529
532
 
533
+ // #2469: retry dispatched without a `meta` argument, so the retried run
534
+ // carried no triggeredById at all — job-run-logger's onJobStart then saw
535
+ // triggeredById: null, skipped encryption entirely, and the retried run's
536
+ // payload landed in job_runs as unshreddable plaintext. The asserted
537
+ // invariant is the input to that chain: the retried BullMQ job must carry
538
+ // the ORIGINAL run's owner, not the retrying SystemAdmin and not null.
539
+ // (Asserting the retried run's job_runs row directly is not possible here:
540
+ // this stack wires no job-run-logger into the runner, so a real dispatch
541
+ // writes no row — every other row in this file is seeded by calling
542
+ // retryLogger.onJobStart by hand.)
543
+ test("retry re-dispatches with the original run's triggeredById so the new run's payload stays encrypted", async () => {
544
+ await retryLogger.onJobStart?.(RETRY_JOB_NAME, "bull-retry-5", {
545
+ triggeredById: RETRY_USER_ID,
546
+ payload: SECRET_PAYLOAD,
547
+ });
548
+ await retryLogger.onJobFailed?.(RETRY_JOB_NAME, "bull-retry-5", "boom", []);
549
+
550
+ const originalRow = await fetchOne(retryStack.db, jobRunsTable, { bullJobId: "bull-retry-5" });
551
+ expect(isPiiCiphertext(originalRow?.["payload"])).toBe(true);
552
+ expect(originalRow?.["triggeredById"]).toBe(RETRY_USER_ID);
553
+
554
+ await retryStack.http.writeOk<{
555
+ jobName: string;
556
+ bullJobId: string;
557
+ retriedFromRunId: string;
558
+ }>(JobHandlers.retry, { runId: originalRow?.["id"] }, TestUsers.systemAdmin);
559
+
560
+ await sleep(1000);
561
+
562
+ expect(capturedTriggeredBy).toEqual([RETRY_USER_ID]);
563
+ expect(capturedTriggeredBy[0]).not.toBe(TestUsers.systemAdmin.id);
564
+ expect(capturedPayloads).toEqual([JSON.parse(SECRET_PAYLOAD)]);
565
+ });
566
+
530
567
  // The other producer of PII_ERASED_SENTINEL: job-run-logger writes the
531
568
  // literal "[[erased]]" string (not ciphertext) when the key is already
532
569
  // gone at onJobStart time (see the mirrored case above this describe
@@ -18,6 +18,7 @@ type JobRunRow = {
18
18
  readonly status: string;
19
19
  readonly jobName: string;
20
20
  readonly payload: string | null;
21
+ readonly triggeredById: string | null;
21
22
  };
22
23
 
23
24
  export const retryWrite = defineWriteHandler({
@@ -61,6 +62,7 @@ export const retryWrite = defineWriteHandler({
61
62
  // PII_ERASED_SENTINEL, which is not JSON; reject the retry rather than
62
63
  // dispatch it with a silently emptied payload.
63
64
  let payload: Record<string, unknown> = {};
65
+ let decryptedPayloadJson: string | null = null;
64
66
  if (run.payload) {
65
67
  const decryptedPayload = await decryptStoredPii(
66
68
  run.payload,
@@ -78,9 +80,14 @@ export const retryWrite = defineWriteHandler({
78
80
  decryptedPayload,
79
81
  `job run ${event.payload.runId} payload`,
80
82
  );
83
+ decryptedPayloadJson = decryptedPayload;
81
84
  }
82
85
 
83
- const bullJobId = await jobRunner.dispatch(run.jobName, payload);
86
+ // Retry re-uses the original run's DEK owner so the retry's logs/payload stay encrypted.
87
+ const bullJobId = await jobRunner.dispatch(run.jobName, payload, {
88
+ triggeredById: run.triggeredById ?? undefined,
89
+ ...(decryptedPayloadJson !== null && { payload: decryptedPayloadJson }),
90
+ });
84
91
 
85
92
  return {
86
93
  isSuccess: true,