@cosmicdrift/kumiko-bundled-features 0.235.0 → 0.235.2

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.235.0",
3
+ "version": "0.235.2",
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.235.0",
134
- "@cosmicdrift/kumiko-framework": "0.235.0",
135
- "@cosmicdrift/kumiko-headless": "0.235.0",
136
- "@cosmicdrift/kumiko-renderer": "0.235.0",
137
- "@cosmicdrift/kumiko-renderer-web": "0.235.0",
138
- "@cosmicdrift/kumiko-types": "0.235.0",
133
+ "@cosmicdrift/kumiko-dispatcher-live": "0.235.2",
134
+ "@cosmicdrift/kumiko-framework": "0.235.2",
135
+ "@cosmicdrift/kumiko-headless": "0.235.2",
136
+ "@cosmicdrift/kumiko-renderer": "0.235.2",
137
+ "@cosmicdrift/kumiko-renderer-web": "0.235.2",
138
+ "@cosmicdrift/kumiko-types": "0.235.2",
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.235.0",
168
- "@cosmicdrift/kumiko-locale-es": "0.235.0"
167
+ "@cosmicdrift/kumiko-locale-de": "0.235.2",
168
+ "@cosmicdrift/kumiko-locale-es": "0.235.2"
169
169
  }
170
170
  }
@@ -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" },
@@ -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",
@@ -14,8 +14,10 @@ import {
14
14
  import {
15
15
  defineWriteHandler,
16
16
  type FeatureDefinition,
17
+ type HandlerContext,
17
18
  type SessionUser,
18
19
  type TenantId,
20
+ type WriteEvent,
19
21
  } from "@cosmicdrift/kumiko-framework/engine";
20
22
  import {
21
23
  AccessDeniedError,
@@ -23,7 +25,9 @@ import {
23
25
  type WriteFailure,
24
26
  writeFailure,
25
27
  } from "@cosmicdrift/kumiko-framework/errors";
28
+ import { append } from "@cosmicdrift/kumiko-framework/event-store";
26
29
  import { purgeSearchDocumentsForSubject } from "@cosmicdrift/kumiko-framework/search";
30
+ import { generateId } from "@cosmicdrift/kumiko-framework/utils";
27
31
  import { z } from "zod";
28
32
  import { revokeAllPatTokensForUser } from "../../personal-access-tokens";
29
33
  import { USER_STATUS } from "../../user";
@@ -101,6 +105,75 @@ async function resolveTenantScopeDenial(
101
105
  return ownedInTenant ? undefined : memberDenied;
102
106
  }
103
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
+
104
177
  // Manual crypto-shred for a DPO / platform operator: erases the subject's
105
178
  // DEK immediately (all its PII ciphertext becomes unreadable, reads render
106
179
  // "[[erased]]") and appends the audit event. Forget is final — the adapter
@@ -140,28 +213,14 @@ export const forgetSubjectWrite = defineWriteHandler({
140
213
  raw,
141
214
  );
142
215
  if (tenantScopeDenial) {
143
- // Denied cross-tenant probes must still leave an audit trail (fw#2348),
144
- // but the denial lands in the REQUESTING actor's own tenant-scoped
145
- // stream — it must never materialise the foreign subject's identifiers
146
- // there. A plaintext subjectKey/aggregateId would survive as a
147
- // permanent record for the prober and would still be present when the
148
- // owning tenant later runs its own (legitimate) forget-subject for that
149
- // subject. The digest still lets an operator correlate repeated probes
150
- // of the same subject without exposing it (fw#2452).
151
- await ctx.unsafeAppendEvent({
152
- aggregateId: event.user.id,
153
- aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
154
- type: SUBJECT_FORGET_DENIED_EVENT_NAME,
155
- payload: {
156
- subjectKeyDigest: createHash("sha256").update(subjectKey, "utf8").digest("base64url"),
157
- subjectKind: raw.kind,
158
- reason: event.payload.reason,
159
- forgottenBy: event.user.id,
160
- actorTenantId: event.user.tenantId,
161
- denial: tenantScopeDenial.error.code,
162
- },
163
- });
164
- 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;
165
224
  }
166
225
 
167
226
  // Erase BEFORE the audit append: if the append throws, the key is gone
@@ -4,11 +4,10 @@
4
4
  // Registration pattern:
5
5
  // registerEventTrigger(r, myWorkflow)
6
6
  //
7
- // The MSP apply-fn runs in the dispatcher's own tx, so `workflow.run-started`
8
- // plus the synchronous portion of the pipeline land atomically. Any throw
9
- // from startAndRunWorkflow is recorded as `workflow.run-failed` and
10
- // swallowed — the failure is already durable, so the dispatcher advances
11
- // past the trigger event instead of redelivering it.
7
+ // The apply-fn does NOT run in the dispatcher's cursor tx (the server's MSP
8
+ // wiring uses the unbound pool), so `workflow.run-started` and the rest of
9
+ // the pipeline are not atomic. A throw from startAndRunWorkflow is recorded
10
+ // as `workflow.run-failed` and swallowed, so the dispatcher moves on.
12
11
 
13
12
  import type {
14
13
  FeatureRegistrar,