@cosmicdrift/kumiko-bundled-features 0.197.1 → 0.199.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 (81) hide show
  1. package/package.json +7 -7
  2. package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +272 -0
  3. package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +31 -1
  4. package/src/auth-email-password/feature.ts +9 -1
  5. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +108 -35
  6. package/src/auth-email-password/handlers/login.write.ts +10 -5
  7. package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +5 -5
  8. package/src/auth-mfa/feature.ts +3 -0
  9. package/src/compliance-profiles/__tests__/compliance-profiles.integration.test.ts +87 -0
  10. package/src/compliance-profiles/__tests__/profile-picker.boot.test.ts +14 -3
  11. package/src/compliance-profiles/feature.ts +18 -5
  12. package/src/compliance-profiles/handlers/needs-profile.query.ts +52 -32
  13. package/src/compliance-profiles/index.ts +1 -0
  14. package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +185 -0
  15. package/src/config/__tests__/require-system-db.test.ts +46 -0
  16. package/src/config/feature.ts +28 -0
  17. package/src/config/handlers/cascade.query.ts +2 -2
  18. package/src/config/handlers/readiness.query.ts +19 -6
  19. package/src/config/handlers/reencrypt.job.ts +79 -21
  20. package/src/config/handlers/values.query.ts +2 -2
  21. package/src/delivery/__tests__/delivery-screens.boot.test.ts +22 -3
  22. package/src/delivery/__tests__/delivery-security.integration.test.ts +44 -1
  23. package/src/delivery/__tests__/delivery.integration.test.ts +96 -3
  24. package/src/delivery/feature.ts +13 -3
  25. package/src/delivery/handlers/log.query.ts +10 -4
  26. package/src/delivery/handlers/preferences.query.ts +14 -1
  27. package/src/delivery/handlers/set-preference.write.ts +12 -1
  28. package/src/delivery/index.ts +1 -1
  29. package/src/delivery/jobs.ts +20 -10
  30. package/src/derivatives-sharp/__tests__/render.test.ts +48 -0
  31. package/src/derivatives-sharp/render.ts +55 -15
  32. package/src/feature-toggles/__tests__/acknowledge-cross-tenant.test.ts +23 -0
  33. package/src/feature-toggles/constants.ts +6 -0
  34. package/src/feature-toggles/handlers/list.query.ts +12 -1
  35. package/src/feature-toggles/handlers/registered.query.ts +12 -1
  36. package/src/feature-toggles/handlers/set.write.ts +18 -3
  37. package/src/file-derivatives/__tests__/public-variant-query-gating.integration.test.ts +7 -1
  38. package/src/file-derivatives/feature.ts +12 -13
  39. package/src/file-derivatives/handlers/public-variant.query.ts +5 -1
  40. package/src/form-draft/__tests__/cleanup.integration.test.ts +22 -0
  41. package/src/form-draft/__tests__/discard-file-refs.integration.test.ts +17 -0
  42. package/src/form-draft/__tests__/limits.integration.test.ts +112 -0
  43. package/src/form-draft/constants.ts +13 -0
  44. package/src/form-draft/db/queries/cleanup.ts +4 -1
  45. package/src/form-draft/db/queries/draft-count.ts +20 -0
  46. package/src/form-draft/db/queries/owned-file-refs.ts +20 -3
  47. package/src/form-draft/handlers/__tests__/cleanup.job.test.ts +1 -0
  48. package/src/form-draft/handlers/cleanup.job.ts +6 -0
  49. package/src/form-draft/handlers/discard.write.ts +6 -0
  50. package/src/form-draft/handlers/save.write.ts +15 -2
  51. package/src/form-draft/schemas.ts +16 -2
  52. package/src/jobs/__tests__/jobs-feature.integration.test.ts +57 -0
  53. package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +12 -3
  54. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +47 -2
  55. package/src/jobs/handlers/detail.query.ts +7 -1
  56. package/src/jobs/handlers/list.query.ts +8 -1
  57. package/src/jobs/handlers/projection-rebuild.job.ts +6 -3
  58. package/src/jobs/handlers/reindex-entity.job.ts +14 -10
  59. package/src/jobs/handlers/retry.write.ts +7 -1
  60. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +14 -1
  61. package/src/personal-access-tokens/feature.ts +4 -1
  62. package/src/personal-access-tokens/roles.ts +11 -3
  63. package/src/readiness/handlers/status.query.ts +2 -1
  64. package/src/sessions/__tests__/membership-revoke.integration.test.ts +195 -0
  65. package/src/sessions/feature.ts +18 -10
  66. package/src/sessions/handlers/revoke-all-for-user.write.ts +12 -5
  67. package/src/sessions/index.ts +1 -1
  68. package/src/sessions/session-callbacks.ts +7 -1
  69. package/src/tenant/__tests__/update-cross-tenant.integration.test.ts +83 -0
  70. package/src/tenant/handlers/remove-member.write.ts +23 -1
  71. package/src/tenant/handlers/update-member-roles.write.ts +23 -1
  72. package/src/tenant/handlers/update.write.ts +35 -2
  73. package/src/tenant/invitation-table.ts +8 -1
  74. package/src/user/__tests__/user.integration.test.ts +21 -0
  75. package/src/user/handlers/create.write.ts +10 -3
  76. package/src/user/handlers/find-for-auth.query.ts +9 -1
  77. package/src/user/handlers/me.query.ts +10 -1
  78. package/src/user/handlers/update.write.ts +12 -2
  79. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +6 -6
  80. package/src/user-data-rights/feature.ts +10 -7
  81. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +35 -28
@@ -15,7 +15,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
15
15
  import { randomBytes } from "node:crypto";
16
16
  import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
17
17
  import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
18
- import type { AppContext } from "@cosmicdrift/kumiko-framework/engine";
18
+ import type { JobContext } from "@cosmicdrift/kumiko-framework/engine";
19
19
  import { rebuildProjection } from "@cosmicdrift/kumiko-framework/pipeline";
20
20
  import {
21
21
  createEnvelopeCipher,
@@ -107,13 +107,13 @@ const noopLog = {
107
107
  child: () => noopLog,
108
108
  };
109
109
 
110
- function jobCtx(): AppContext {
110
+ function jobCtx(): JobContext {
111
111
  return {
112
112
  db: stack.db,
113
113
  registry: stack.registry,
114
114
  masterKeyProvider: mutableProvider,
115
115
  log: noopLog,
116
- } as AppContext; // @cast-boundary test-seam — job only reads db/registry/masterKeyProvider/log
116
+ } as unknown as JobContext; // @cast-boundary test-seam — job only reads db/registry/masterKeyProvider/log
117
117
  }
118
118
 
119
119
  describe("auth-mfa KEK-rotation job — kumiko-framework#266 Step 8", () => {
@@ -227,7 +227,7 @@ describe("auth-mfa KEK-rotation job — unrecognized values are not silently ski
227
227
  registry: stack.registry,
228
228
  masterKeyProvider: mutableProvider,
229
229
  log: capturingLog,
230
- } as AppContext);
230
+ } as unknown as JobContext);
231
231
 
232
232
  const completeLine = captured.info.find((line) =>
233
233
  line.includes("[auth-mfa:reencrypt] complete:"),
@@ -301,7 +301,7 @@ describe("auth-mfa KEK-rotation job — unrecognized values are not silently ski
301
301
  registry: stack.registry,
302
302
  masterKeyProvider: mutableProvider,
303
303
  log: capturingLog,
304
- } as AppContext);
304
+ } as unknown as JobContext);
305
305
 
306
306
  const completeLine = captured.info.find((line) =>
307
307
  line.includes("[auth-mfa:reencrypt] complete:"),
@@ -91,11 +91,14 @@ export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefini
91
91
  // Dormant custom-screen — the client maps MFA_ENABLE_SCREEN_ID to
92
92
  // MfaEnableScreen (see personal-access-tokens/feature.ts for the same
93
93
  // convention). App places it via r.nav in its logged-in settings area.
94
+ // dormant: true skips createKumikoApp's missing-client-plugin boot
95
+ // diagnostic (#2025) for apps that don't nav this screen (#2034).
94
96
  r.screen({
95
97
  id: MFA_ENABLE_SCREEN_ID,
96
98
  type: "custom",
97
99
  renderer: { react: { __component: "MfaEnableScreen" } },
98
100
  access: { openToAll: true },
101
+ dormant: true,
99
102
  });
100
103
  r.translations({ keys: AUTH_MFA_FEATURE_I18N });
101
104
 
@@ -1,5 +1,6 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
+ import { access } from "@cosmicdrift/kumiko-framework/engine";
3
4
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
4
5
  import {
5
6
  createTestUser,
@@ -306,3 +307,89 @@ describe("compliance-profiles :: needs-profile (S1.5 — Onboarding-Banner)", ()
306
307
  expect(result.status).toBe(403);
307
308
  });
308
309
  });
310
+
311
+ describe("compliance-profiles :: needs-profile mit access.systemAdmin-Narrowing (#2063)", () => {
312
+ let narrowedStack: TestStack;
313
+ let narrowedDb: DbConnection;
314
+
315
+ beforeAll(async () => {
316
+ const narrowedFeature = createComplianceProfilesFeature({ access: access.systemAdmin });
317
+ narrowedStack = await setupTestStack({ features: [narrowedFeature] });
318
+ narrowedDb = narrowedStack.db;
319
+ await unsafeCreateEntityTable(narrowedDb, tenantComplianceProfileEntity);
320
+ await createEventsTable(narrowedDb);
321
+ });
322
+
323
+ afterAll(async () => {
324
+ await narrowedStack.cleanup();
325
+ });
326
+
327
+ // TenantAdmin can no longer reach the picker screen (access.systemAdmin
328
+ // excludes TenantAdmin) — needsSelection must stop pointing them at a
329
+ // screen they can't open.
330
+ test("TenantAdmin ohne Picker-Zugriff → needsSelection bleibt false, reason=picker_not_accessible_for_role", async () => {
331
+ const excludedTenantAdmin = createIsolatedTenantAdmin(200);
332
+ const result = await narrowedStack.http.queryOk<{
333
+ needsSelection: boolean;
334
+ currentProfile: string | null;
335
+ reason?: string;
336
+ }>(NEEDS_PROFILE, {}, excludedTenantAdmin);
337
+ expect(result.needsSelection).toBe(false);
338
+ expect(result.currentProfile).toBeNull();
339
+ expect(result.reason).toBe("picker_not_accessible_for_role");
340
+ });
341
+
342
+ // A user holding both roles can still reach the picker via SystemAdmin —
343
+ // the gate must intersect the caller's actual roles, not just check
344
+ // whether TenantAdmin (the query's fixed call-access role) is among the
345
+ // feature's configured picker roles.
346
+ test("TenantAdmin+SystemAdmin dual-role → needsSelection bleibt true (Picker via SystemAdmin erreichbar)", async () => {
347
+ const dualRoleUser = createTestUser({
348
+ id: 204,
349
+ tenantId: testTenantId(204),
350
+ roles: ["TenantAdmin", "SystemAdmin"],
351
+ });
352
+ const result = await narrowedStack.http.queryOk<{
353
+ needsSelection: boolean;
354
+ currentProfile: string | null;
355
+ reason?: string;
356
+ }>(NEEDS_PROFILE, {}, dualRoleUser);
357
+ expect(result.needsSelection).toBe(true);
358
+ expect(result.reason).toBe("no_profile_selected");
359
+ });
360
+
361
+ // The needs-profile call access itself stays TenantAdmin-only — the
362
+ // `access` option is not passed 1:1 to the query dispatcher (otherwise
363
+ // SystemAdmin could now call an endpoint only TenantAdmin used to be
364
+ // allowed to call — a widening #2063 does not ask for).
365
+ test("SystemAdmin darf needs-profile weiterhin nicht aufrufen (Call-Access unveraendert)", async () => {
366
+ const systemAdmin = createTestUser({
367
+ id: 201,
368
+ tenantId: testTenantId(201),
369
+ roles: ["SystemAdmin"],
370
+ });
371
+ const result = await narrowedStack.http.query(NEEDS_PROFILE, {}, systemAdmin);
372
+ expect(result.status).toBe(403);
373
+ });
374
+
375
+ test("TenantAdmin mit bereits gesetztem Profile → needsSelection bleibt false, kein reason-Sonderfall", async () => {
376
+ const setupAdmin = createIsolatedTenantAdmin(202);
377
+ // set-profile is also narrowed to access.systemAdmin — set up via
378
+ // SystemAdmin to create the profile row for the read test.
379
+ const systemAdminSetup = createTestUser({
380
+ id: 203,
381
+ tenantId: setupAdmin.tenantId,
382
+ roles: ["SystemAdmin"],
383
+ });
384
+ await narrowedStack.http.writeOk(SET_PROFILE, { profileKey: "eu-dsgvo" }, systemAdminSetup);
385
+
386
+ const result = await narrowedStack.http.queryOk<{
387
+ needsSelection: boolean;
388
+ currentProfile: string | null;
389
+ reason?: string;
390
+ }>(NEEDS_PROFILE, {}, setupAdmin);
391
+ expect(result.needsSelection).toBe(false);
392
+ expect(result.currentProfile).toBe("eu-dsgvo");
393
+ expect(result.reason).toBeUndefined();
394
+ });
395
+ });
@@ -15,9 +15,7 @@ describe("compliance profile screen + handler access alignment", () => {
15
15
  const feature = createComplianceProfilesFeature();
16
16
  const screen = feature.screens[COMPLIANCE_PROFILE_SCREEN_ID];
17
17
  expect(screen?.type).toBe("custom");
18
- if (screen && "access" in screen && screen.access && "roles" in screen.access) {
19
- expect(screen.access.roles).toEqual(access.admin);
20
- }
18
+ expect(rolesOf(screen?.access)).toEqual([...access.admin]);
21
19
  });
22
20
 
23
21
  test("set-profile handler shares access.admin", () => {
@@ -25,4 +23,17 @@ describe("compliance profile screen + handler access alignment", () => {
25
23
  expect(rolesOf(feature.writeHandlers["set-profile"]?.access)).toEqual([...access.admin]);
26
24
  void ComplianceProfileHandlers;
27
25
  });
26
+
27
+ test("access option narrows the profile-picker screen and its set-profile handler together (#2033)", () => {
28
+ const feature = createComplianceProfilesFeature({ access: access.systemAdmin });
29
+ const screen = feature.screens[COMPLIANCE_PROFILE_SCREEN_ID];
30
+ expect(rolesOf(screen?.access)).toEqual([...access.systemAdmin]);
31
+ expect(rolesOf(feature.writeHandlers["set-profile"]?.access)).toEqual([...access.systemAdmin]);
32
+ });
33
+
34
+ test("boot-validates with a narrowed access option", () => {
35
+ expect(() =>
36
+ validateBoot([createComplianceProfilesFeature({ access: access.systemAdmin })]),
37
+ ).not.toThrow();
38
+ });
28
39
  });
@@ -6,7 +6,7 @@ import {
6
6
  import { COMPLIANCE_PROFILE_SCREEN_ID } from "./constants";
7
7
  import { forTenantQuery } from "./handlers/for-tenant.query";
8
8
  import { listProfilesQuery } from "./handlers/list-profiles.query";
9
- import { needsProfileQuery } from "./handlers/needs-profile.query";
9
+ import { createNeedsProfileQuery } from "./handlers/needs-profile.query";
10
10
  import { setProfileWrite } from "./handlers/set-profile.write";
11
11
  import { subProcessorsQuery } from "./handlers/sub-processors.query";
12
12
  import { COMPLIANCE_PROFILES_I18N } from "./i18n";
@@ -31,7 +31,20 @@ export {
31
31
  // Architektur-Note: Profile-Selection lebt als separate Entity
32
32
  // (tenantComplianceProfile), nicht als config-key im tenant-Feature.
33
33
  // Begruendung in schema/profile-selection.ts.
34
- export function createComplianceProfilesFeature(): FeatureDefinition {
34
+ export type ComplianceProfilesFeatureOptions = {
35
+ // Access gate for the profile-picker screen and the `set-profile` write it
36
+ // submits to — both move together so the nav entry a role sees always
37
+ // matches a callable handler (#2033). Default access.admin includes
38
+ // TenantAdmin, who in a line-of-business app is the operator, not the
39
+ // platform — pass access.systemAdmin to keep this platform-only and out
40
+ // of their nav.
41
+ readonly access?: readonly string[];
42
+ };
43
+
44
+ export function createComplianceProfilesFeature(
45
+ options?: ComplianceProfilesFeatureOptions,
46
+ ): FeatureDefinition {
47
+ const resolvedAccess = options?.access ?? access.admin;
35
48
  return defineFeature("compliance-profiles", (r) => {
36
49
  r.describe(
37
50
  "Lets each tenant select a compliance regime (e.g. `eu-dsgvo`, `swiss-dsg`, `de-hr-dsgvo-hgb`) that bundles user-rights grace periods, breach-disclosure deadlines, sub-processor requirements, and audit-retention rules into a single named profile. Tenant admins call `compliance-profiles:write:set-profile` to choose a profile (with optional JSON override for edge cases); other features resolve the effective profile via the `compliance.forTenant` cross-feature API. Required by `user-data-rights` \u2014 mount this feature before it.",
@@ -50,21 +63,21 @@ export function createComplianceProfilesFeature(): FeatureDefinition {
50
63
  r.exposesApi("compliance.forTenant");
51
64
 
52
65
  const handlers = {
53
- setProfile: r.writeHandler(setProfileWrite),
66
+ setProfile: r.writeHandler({ ...setProfileWrite, access: { roles: resolvedAccess } }),
54
67
  };
55
68
 
56
69
  const queries = {
57
70
  forTenant: r.queryHandler(forTenantQuery),
58
71
  listProfiles: r.queryHandler(listProfilesQuery),
59
72
  subProcessors: r.queryHandler(subProcessorsQuery),
60
- needsProfile: r.queryHandler(needsProfileQuery),
73
+ needsProfile: r.queryHandler(createNeedsProfileQuery(resolvedAccess)),
61
74
  };
62
75
 
63
76
  r.screen({
64
77
  id: COMPLIANCE_PROFILE_SCREEN_ID,
65
78
  type: "custom",
66
79
  renderer: { react: { __component: "ComplianceProfileScreen" } },
67
- access: { roles: access.admin },
80
+ access: { roles: resolvedAccess },
68
81
  });
69
82
  r.nav({
70
83
  id: "profile-picker",
@@ -5,49 +5,69 @@ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
5
5
  import { z } from "zod";
6
6
  import { tenantComplianceProfileTable } from "../schema/profile-selection";
7
7
 
8
- // Onboarding-Banner-Trigger fuer Tenant-Admin.
8
+ // Onboarding banner trigger for tenant admin.
9
9
  //
10
- // Sprint 1.5 — minimaler API-Endpoint, UI-Banner kommt in einem
11
- // spaeteren UI-Sprint. Reine Read-Query: gibt es einen Eintrag in
12
- // tenantComplianceProfile fuer den aktuellen Tenant?
10
+ // Sprint 1.5 — minimal API endpoint, the UI banner comes in a later UI
11
+ // sprint. Pure read query: does an entry exist in tenantComplianceProfile
12
+ // for the current tenant?
13
13
  //
14
- // Wenn nein → Tenant-Admin muss Profile waehlen (Pflicht beim
15
- // Onboarding). Bis zur Wahl laeuft minimal-no-region mit warning,
16
- // das in der Tenant-Dashboard-Banner sichtbar gemacht werden soll.
14
+ // If not → tenant admin must pick a profile (mandatory during onboarding).
15
+ // Until picked, minimal-no-region runs with a warning that should surface
16
+ // in the tenant dashboard banner.
17
17
  //
18
- // Access: TenantAdmin only — der Banner ist nur fuer Tenant-Admins
19
- // relevant, nicht fuer normale Member.
20
- export const needsProfileQuery = defineQueryHandler({
21
- name: "needs-profile",
22
- schema: z.object({}),
23
- access: { roles: [ROLES.TenantAdmin] },
24
- handler: async (query, ctx): Promise<NeedsProfileResponse> => {
25
- const row = (await fetchOne(ctx.db, tenantComplianceProfileTable, {
26
- tenantId: query.user.tenantId,
27
- })) as { profileKey: ComplianceProfileKey } | null; // @cast-boundary db-runner
18
+ // Access: TenantAdmin only — the banner is only relevant to tenant admins,
19
+ // not regular members. Stays hard-coded to TenantAdmin (not the `access`
20
+ // option of createComplianceProfilesFeature) — passing the option through
21
+ // would widen call access by default to all three access.admin roles
22
+ // (including SystemAdmin). Instead the handler takes `pickerReachableRoles`
23
+ // and deliberately resolves the #2063 special case: if the *caller's*
24
+ // roles no longer intersect the picker's `access` option, the query stops
25
+ // reporting needsSelection — otherwise a tenant admin would be told to
26
+ // open a screen they can't reach.
27
+ export function createNeedsProfileQuery(pickerReachableRoles: readonly string[]) {
28
+ return defineQueryHandler({
29
+ name: "needs-profile",
30
+ schema: z.object({}),
31
+ access: { roles: [ROLES.TenantAdmin] },
32
+ handler: async (query, ctx): Promise<NeedsProfileResponse> => {
33
+ const row = (await fetchOne(ctx.db, tenantComplianceProfileTable, {
34
+ tenantId: query.user.tenantId,
35
+ })) as { profileKey: ComplianceProfileKey } | null; // @cast-boundary db-runner
36
+
37
+ if (row) {
38
+ // S1.7 X1: minimal-no-region is no longer settable via set-profile
39
+ // (Zod). If Sprint 2 ships a seedComplianceProfile helper that
40
+ // reintroduces the migration edge case, a defensive path belongs
41
+ // here again — until then, every existing entry is a deliberately
42
+ // chosen production profile.
43
+ return {
44
+ needsSelection: false,
45
+ currentProfile: row.profileKey,
46
+ };
47
+ }
48
+
49
+ const callerCanReachPicker = query.user.roles.some((role) =>
50
+ pickerReachableRoles.includes(role),
51
+ );
52
+ if (!callerCanReachPicker) {
53
+ return {
54
+ needsSelection: false,
55
+ currentProfile: null,
56
+ reason: "picker_not_accessible_for_role",
57
+ };
58
+ }
28
59
 
29
- if (!row) {
30
60
  return {
31
61
  needsSelection: true,
32
62
  currentProfile: null,
33
63
  reason: "no_profile_selected",
34
64
  };
35
- }
36
-
37
- // S1.7 X1: minimal-no-region ist via set-profile (Zod) nicht mehr
38
- // setzbar. Wenn Sprint 2 einen seedComplianceProfile-Helper liefert
39
- // der den Migration-Edge-Case einführt, kommt hier wieder ein
40
- // defensiver Pfad rein — bis dahin: jeder existierende Eintrag ist
41
- // ein bewusst gewähltes Production-Profile.
42
- return {
43
- needsSelection: false,
44
- currentProfile: row.profileKey,
45
- };
46
- },
47
- });
65
+ },
66
+ });
67
+ }
48
68
 
49
69
  interface NeedsProfileResponse {
50
70
  readonly needsSelection: boolean;
51
71
  readonly currentProfile: ComplianceProfileKey | null;
52
- readonly reason?: "no_profile_selected";
72
+ readonly reason?: "no_profile_selected" | "picker_not_accessible_for_role";
53
73
  }
@@ -5,6 +5,7 @@ export {
5
5
  ComplianceProfileQueries,
6
6
  } from "./constants";
7
7
  export {
8
+ type ComplianceProfilesFeatureOptions,
8
9
  createComplianceProfilesFeature,
9
10
  tenantComplianceProfileEntity,
10
11
  tenantComplianceProfileTable,
@@ -0,0 +1,185 @@
1
+ // Regression guard for kumiko-framework#2072: the KEK-rotation job's
2
+ // systemScope() fail-closed checks — createUncheckedSystemDb().
3
+ // acknowledgeCrossTenant() gating the initial cross-tenant scan,
4
+ // assertRowsTenant() re-verifying each per-tenant slice before it's served
5
+ // to the write loop — must not block legitimate multi-tenant rotation. A
6
+ // systemDb accidentally bound to the wrong tenant (e.g. always
7
+ // SYSTEM_TENANT_ID instead of the scanned row's own tenant) would make
8
+ // every non-system-tenant row fail closed via assertRowsTenant, landing in
9
+ // `failed` (not thrown — chunked-entity-migration.ts routes migrateRow
10
+ // throws through onRowError) rather than `migrated`.
11
+
12
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
13
+ import { randomBytes } from "node:crypto";
14
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
15
+ import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
16
+ import {
17
+ access,
18
+ createSystemUser,
19
+ createTenantConfig,
20
+ defineFeature,
21
+ } from "@cosmicdrift/kumiko-framework/engine";
22
+ import {
23
+ createEnvelopeCipher,
24
+ createEnvMasterKeyProvider,
25
+ } from "@cosmicdrift/kumiko-framework/secrets";
26
+ import {
27
+ setupTestStack,
28
+ type TestStack,
29
+ testTenantId,
30
+ unsafePushTables,
31
+ } from "@cosmicdrift/kumiko-framework/stack";
32
+ import { createMutableMasterKeyProvider } from "@cosmicdrift/kumiko-framework/testing";
33
+ import { createConfigFeature } from "../feature";
34
+ import { reencryptJob } from "../handlers/reencrypt.job";
35
+ import { createConfigResolver } from "../resolver";
36
+ import { configValueEntity, configValuesTable } from "../table";
37
+
38
+ const KEY = "tenant-check-rot:config:secret-pass";
39
+ const TENANT_A = testTenantId(201);
40
+ const TENANT_B = testTenantId(202);
41
+
42
+ const v1Key = randomBytes(32).toString("base64");
43
+ const v2Key = randomBytes(32).toString("base64");
44
+ const mutableProvider = createMutableMasterKeyProvider(
45
+ createEnvMasterKeyProvider({
46
+ env: {
47
+ KUMIKO_SECRETS_MASTER_KEY_V1: v1Key,
48
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
49
+ },
50
+ }),
51
+ );
52
+ const cipher = createEnvelopeCipher(mutableProvider, {});
53
+ const resolver = createConfigResolver({ cipher });
54
+
55
+ const keyDef = createTenantConfig("text", {
56
+ encrypted: true,
57
+ read: access.admin,
58
+ write: access.admin,
59
+ });
60
+
61
+ const tenantCheckFeature = defineFeature("tenant-check-rot", (r) => {
62
+ r.requires("config");
63
+ r.config({ keys: { "secret-pass": keyDef } });
64
+ });
65
+
66
+ const executor = createEventStoreExecutor(configValuesTable, configValueEntity, {
67
+ entityName: "config-value",
68
+ });
69
+
70
+ let stack: TestStack;
71
+
72
+ async function seedRow(tenantId: string, plaintext: string): Promise<void> {
73
+ const envelope = await cipher.encrypt(JSON.stringify(plaintext), { tenantId });
74
+ const systemUser = createSystemUser(tenantId);
75
+ const tdb = createTenantDb(stack.db, tenantId, "system");
76
+ const result = await executor.create(
77
+ { key: KEY, value: envelope, tenantId, userId: null },
78
+ systemUser,
79
+ tdb,
80
+ );
81
+ if (!result.isSuccess) throw new Error(`seed failed: ${result.error.code}`);
82
+ }
83
+
84
+ type RawConfigRow = { value: string; tenantId: string };
85
+
86
+ async function readRawValues(): Promise<readonly RawConfigRow[]> {
87
+ return await selectMany<RawConfigRow>(stack.db, configValuesTable, { key: KEY });
88
+ }
89
+
90
+ type CapturedLog = { info: string[]; warn: string[] };
91
+
92
+ type TestJobLog = {
93
+ info: (msg: string) => void;
94
+ warn: (msg: string) => void;
95
+ error: () => void;
96
+ debug: () => void;
97
+ child: () => TestJobLog;
98
+ };
99
+
100
+ function capturingLog(captured: CapturedLog): TestJobLog {
101
+ const log: TestJobLog = {
102
+ info: (msg) => captured.info.push(msg),
103
+ warn: (msg) => captured.warn.push(msg),
104
+ error: () => {},
105
+ debug: () => {},
106
+ child: () => log,
107
+ };
108
+ return log;
109
+ }
110
+
111
+ function jobCtx(captured: CapturedLog): Parameters<typeof reencryptJob>[1] {
112
+ return {
113
+ db: stack.db,
114
+ registry: stack.registry,
115
+ masterKeyProvider: mutableProvider,
116
+ configEncryption: cipher,
117
+ log: capturingLog(captured),
118
+ } as unknown as Parameters<typeof reencryptJob>[1]; // @cast-boundary test-seam — job only reads db/registry/masterKeyProvider/configEncryption/log
119
+ }
120
+
121
+ beforeAll(async () => {
122
+ stack = await setupTestStack({
123
+ features: [createConfigFeature(), tenantCheckFeature],
124
+ masterKeyProvider: mutableProvider,
125
+ extraContext: { configResolver: resolver, configEncryption: cipher },
126
+ });
127
+ await unsafePushTables(stack.db, { configValuesTable });
128
+ await seedRow(TENANT_A, "tenant-a-secret");
129
+ await seedRow(TENANT_B, "tenant-b-secret");
130
+ });
131
+
132
+ afterAll(async () => {
133
+ await stack.cleanup();
134
+ });
135
+
136
+ describe("config KEK-rotation job — multi-tenant scan + write checks (kumiko-framework#2072)", () => {
137
+ test("acknowledgeCrossTenant scan + per-tenant assertRowsTenant checks still rotate every tenant's row", async () => {
138
+ const beforeRows = await readRawValues();
139
+ expect(beforeRows).toHaveLength(2);
140
+ for (const row of beforeRows) {
141
+ expect(JSON.parse(row.value).kekVersion).toBe(1);
142
+ }
143
+
144
+ // "ops added a new master key version and flipped CURRENT=2".
145
+ mutableProvider.replace(
146
+ createEnvMasterKeyProvider({
147
+ env: {
148
+ KUMIKO_SECRETS_MASTER_KEY_V1: v1Key,
149
+ KUMIKO_SECRETS_MASTER_KEY_V2: v2Key,
150
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "2",
151
+ },
152
+ }),
153
+ );
154
+
155
+ const captured: CapturedLog = { info: [], warn: [] };
156
+ await reencryptJob({}, jobCtx(captured));
157
+
158
+ // No warn output — an over-restrictive systemDb binding would reject a
159
+ // row via assertRowsTenant, landing it in `failed` with a warn log
160
+ // instead of `migrated`.
161
+ expect(captured.warn).toEqual([]);
162
+
163
+ const completeLine = captured.info.find((line) =>
164
+ line.includes("[config:reencrypt] complete:"),
165
+ );
166
+ if (!completeLine) throw new Error("job did not log a completion summary");
167
+ const result = JSON.parse(completeLine.slice(completeLine.indexOf("{"))) as {
168
+ migrated: number;
169
+ failed: number;
170
+ alreadyCurrent: number;
171
+ };
172
+ expect(result.migrated).toBe(2);
173
+ expect(result.failed).toBe(0);
174
+ expect(result.alreadyCurrent).toBe(0);
175
+
176
+ const afterRows = await readRawValues();
177
+ expect(afterRows).toHaveLength(2);
178
+ for (const row of afterRows) {
179
+ expect(JSON.parse(row.value).kekVersion).toBe(2);
180
+ }
181
+
182
+ expect(await resolver.get(KEY, keyDef, TENANT_A, "u1", stack.db)).toBe("tenant-a-secret");
183
+ expect(await resolver.get(KEY, keyDef, TENANT_B, "u1", stack.db)).toBe("tenant-b-secret");
184
+ });
185
+ });
@@ -0,0 +1,46 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
+ import { createUncheckedSystemDb } from "@cosmicdrift/kumiko-framework/db";
4
+ import type { HandlerContext, TenantId } from "@cosmicdrift/kumiko-framework/engine";
5
+ import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
6
+ import { requireSystemDb } from "../feature";
7
+
8
+ // Minimal TenantDb stub — only .tenantId is read by assertTenantMatch /
9
+ // createUncheckedSystemDb; the real db-query methods are never called by
10
+ // requireSystemDb itself.
11
+ function fakeTenantDb(tenantId: TenantId): TenantDb {
12
+ return { tenantId, mode: "system" } as unknown as TenantDb;
13
+ }
14
+
15
+ // Minimal HandlerContext stub — only .db / .systemDb are read by requireSystemDb.
16
+ function contextStub(db: TenantDb, withSystemDb: boolean): HandlerContext {
17
+ return {
18
+ db,
19
+ systemDb: withSystemDb ? createUncheckedSystemDb(db) : undefined,
20
+ // biome-ignore lint/suspicious/noExplicitAny: rest of HandlerContext isn't touched by requireSystemDb.
21
+ } as any;
22
+ }
23
+
24
+ const TENANT_A = "tenant-a" as TenantId;
25
+ const TENANT_B = "tenant-b" as TenantId;
26
+
27
+ describe("requireSystemDb", () => {
28
+ test("throws InternalError when ctx.systemDb is missing", () => {
29
+ const ctx = contextStub(fakeTenantDb(TENANT_A), false);
30
+ expect(() => requireSystemDb(ctx, "config:query:test", TENANT_A)).toThrow(
31
+ /ctx\.systemDb missing/,
32
+ );
33
+ });
34
+
35
+ test("returns the underlying TenantDb when tenantId matches", () => {
36
+ const db = fakeTenantDb(TENANT_A);
37
+ const ctx = contextStub(db, true);
38
+ expect(requireSystemDb(ctx, "config:query:test", TENANT_A)).toBe(db);
39
+ });
40
+
41
+ test("fails closed with AccessDeniedError when tenantId does not match ctx.db's own tenant", () => {
42
+ const db = fakeTenantDb(TENANT_A);
43
+ const ctx = contextStub(db, true);
44
+ expect(() => requireSystemDb(ctx, "config:query:test", TENANT_B)).toThrow(AccessDeniedError);
45
+ });
46
+ });
@@ -120,6 +120,34 @@ export function requireConfigResolver(ctx: HandlerContext, handlerName: string):
120
120
  return ctx.configResolver;
121
121
  }
122
122
 
123
+ // Config declares r.systemScope() (raw ctx.db is unfiltered across tenants
124
+ // in that mode), so config's own query handlers must go through ctx.systemDb's
125
+ // explicit self-check instead of touching ctx.db directly. Only valid for
126
+ // handlers dispatched as config:query:* — those always run under config's
127
+ // own system-scoped context, so ctx.systemDb is never actually missing here;
128
+ // the throw documents that invariant instead of silently degrading.
129
+ // buildProviderSelectionGate / collectMissingRequiredConfig (readiness.query.ts)
130
+ // are shared with the separate, plain tenant-scoped readiness feature, so they
131
+ // take the already-scoped db as an explicit parameter instead of resolving it
132
+ // themselves — each call site (config's own handler here, readiness's
133
+ // status.query.ts) makes its own scope decision instead of this function
134
+ // guessing from ctx.systemDb's presence.
135
+ export function requireSystemDb(
136
+ ctx: HandlerContext,
137
+ handlerName: string,
138
+ tenantId: TenantId,
139
+ ): TenantDb {
140
+ if (!ctx.systemDb) {
141
+ throw new InternalError({
142
+ message:
143
+ `[${handlerName}] ctx.systemDb missing — only present for r.systemScope() handlers; ` +
144
+ `the config feature declares r.systemScope() in feature.ts, so this points at a ` +
145
+ `pipeline wiring bug rather than a missing feature declaration.`,
146
+ });
147
+ }
148
+ return ctx.systemDb.assertTenantMatch(tenantId);
149
+ }
150
+
123
151
  // Mirror of requireConfigResolver for the encryption round-trip side.
124
152
  // Only keys declared `encrypted: true` need this — the setter calls it
125
153
  // lazily so apps that never wire encryption still boot (and only crash
@@ -5,7 +5,7 @@ import {
5
5
  isEncryptedAtRest,
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import { z } from "zod";
8
- import { requireConfigResolver } from "../feature";
8
+ import { requireConfigResolver, requireSystemDb } from "../feature";
9
9
  import { MASKED, redactInheritedCascade, shouldRedactInherited } from "../read-redaction";
10
10
  import { hasConfigAccess } from "../write-helpers";
11
11
 
@@ -16,7 +16,7 @@ export const cascadeQuery = defineQueryHandler({
16
16
  }),
17
17
  access: { openToAll: true },
18
18
  handler: async (query, ctx) => {
19
- const db = ctx.db;
19
+ const db = requireSystemDb(ctx, "config:query:cascade", query.user.tenantId);
20
20
  const registry = ctx.registry;
21
21
  const resolver = requireConfigResolver(ctx, "config:query:cascade");
22
22