@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.
- package/package.json +7 -7
- package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +272 -0
- package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +31 -1
- package/src/auth-email-password/feature.ts +9 -1
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +108 -35
- package/src/auth-email-password/handlers/login.write.ts +10 -5
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +5 -5
- package/src/auth-mfa/feature.ts +3 -0
- package/src/compliance-profiles/__tests__/compliance-profiles.integration.test.ts +87 -0
- package/src/compliance-profiles/__tests__/profile-picker.boot.test.ts +14 -3
- package/src/compliance-profiles/feature.ts +18 -5
- package/src/compliance-profiles/handlers/needs-profile.query.ts +52 -32
- package/src/compliance-profiles/index.ts +1 -0
- package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +185 -0
- package/src/config/__tests__/require-system-db.test.ts +46 -0
- package/src/config/feature.ts +28 -0
- package/src/config/handlers/cascade.query.ts +2 -2
- package/src/config/handlers/readiness.query.ts +19 -6
- package/src/config/handlers/reencrypt.job.ts +79 -21
- package/src/config/handlers/values.query.ts +2 -2
- package/src/delivery/__tests__/delivery-screens.boot.test.ts +22 -3
- package/src/delivery/__tests__/delivery-security.integration.test.ts +44 -1
- package/src/delivery/__tests__/delivery.integration.test.ts +96 -3
- package/src/delivery/feature.ts +13 -3
- package/src/delivery/handlers/log.query.ts +10 -4
- package/src/delivery/handlers/preferences.query.ts +14 -1
- package/src/delivery/handlers/set-preference.write.ts +12 -1
- package/src/delivery/index.ts +1 -1
- package/src/delivery/jobs.ts +20 -10
- package/src/derivatives-sharp/__tests__/render.test.ts +48 -0
- package/src/derivatives-sharp/render.ts +55 -15
- package/src/feature-toggles/__tests__/acknowledge-cross-tenant.test.ts +23 -0
- package/src/feature-toggles/constants.ts +6 -0
- package/src/feature-toggles/handlers/list.query.ts +12 -1
- package/src/feature-toggles/handlers/registered.query.ts +12 -1
- package/src/feature-toggles/handlers/set.write.ts +18 -3
- package/src/file-derivatives/__tests__/public-variant-query-gating.integration.test.ts +7 -1
- package/src/file-derivatives/feature.ts +12 -13
- package/src/file-derivatives/handlers/public-variant.query.ts +5 -1
- package/src/form-draft/__tests__/cleanup.integration.test.ts +22 -0
- package/src/form-draft/__tests__/discard-file-refs.integration.test.ts +17 -0
- package/src/form-draft/__tests__/limits.integration.test.ts +112 -0
- package/src/form-draft/constants.ts +13 -0
- package/src/form-draft/db/queries/cleanup.ts +4 -1
- package/src/form-draft/db/queries/draft-count.ts +20 -0
- package/src/form-draft/db/queries/owned-file-refs.ts +20 -3
- package/src/form-draft/handlers/__tests__/cleanup.job.test.ts +1 -0
- package/src/form-draft/handlers/cleanup.job.ts +6 -0
- package/src/form-draft/handlers/discard.write.ts +6 -0
- package/src/form-draft/handlers/save.write.ts +15 -2
- package/src/form-draft/schemas.ts +16 -2
- package/src/jobs/__tests__/jobs-feature.integration.test.ts +57 -0
- package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +12 -3
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +47 -2
- package/src/jobs/handlers/detail.query.ts +7 -1
- package/src/jobs/handlers/list.query.ts +8 -1
- package/src/jobs/handlers/projection-rebuild.job.ts +6 -3
- package/src/jobs/handlers/reindex-entity.job.ts +14 -10
- package/src/jobs/handlers/retry.write.ts +7 -1
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +14 -1
- package/src/personal-access-tokens/feature.ts +4 -1
- package/src/personal-access-tokens/roles.ts +11 -3
- package/src/readiness/handlers/status.query.ts +2 -1
- package/src/sessions/__tests__/membership-revoke.integration.test.ts +195 -0
- package/src/sessions/feature.ts +18 -10
- package/src/sessions/handlers/revoke-all-for-user.write.ts +12 -5
- package/src/sessions/index.ts +1 -1
- package/src/sessions/session-callbacks.ts +7 -1
- package/src/tenant/__tests__/update-cross-tenant.integration.test.ts +83 -0
- package/src/tenant/handlers/remove-member.write.ts +23 -1
- package/src/tenant/handlers/update-member-roles.write.ts +23 -1
- package/src/tenant/handlers/update.write.ts +35 -2
- package/src/tenant/invitation-table.ts +8 -1
- package/src/user/__tests__/user.integration.test.ts +21 -0
- package/src/user/handlers/create.write.ts +10 -3
- package/src/user/handlers/find-for-auth.query.ts +9 -1
- package/src/user/handlers/me.query.ts +10 -1
- package/src/user/handlers/update.write.ts +12 -2
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +6 -6
- package/src/user-data-rights/feature.ts +10 -7
- 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 {
|
|
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():
|
|
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
|
|
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
|
|
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
|
|
304
|
+
} as unknown as JobContext);
|
|
305
305
|
|
|
306
306
|
const completeLine = captured.info.find((line) =>
|
|
307
307
|
line.includes("[auth-mfa:reencrypt] complete:"),
|
package/src/auth-mfa/feature.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
|
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(
|
|
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:
|
|
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
|
|
8
|
+
// Onboarding banner trigger for tenant admin.
|
|
9
9
|
//
|
|
10
|
-
// Sprint 1.5 —
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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 —
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
+
});
|
package/src/config/feature.ts
CHANGED
|
@@ -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.
|
|
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
|
|