@cosmicdrift/kumiko-bundled-features 0.198.0 → 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/compliance-profiles/__tests__/compliance-profiles.integration.test.ts +87 -0
- package/src/compliance-profiles/feature.ts +2 -2
- package/src/compliance-profiles/handlers/needs-profile.query.ts +52 -32
- 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-security.integration.test.ts +44 -1
- package/src/delivery/__tests__/delivery.integration.test.ts +94 -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/jobs.ts +20 -10
- 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/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 +39 -0
- 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/readiness/handlers/status.query.ts +2 -1
- package/src/tenant/handlers/update.write.ts +30 -5
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.199.0",
|
|
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>",
|
|
@@ -125,12 +125,12 @@
|
|
|
125
125
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
126
126
|
},
|
|
127
127
|
"dependencies": {
|
|
128
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
129
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
130
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
131
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
132
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
133
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
128
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.199.0",
|
|
129
|
+
"@cosmicdrift/kumiko-framework": "0.199.0",
|
|
130
|
+
"@cosmicdrift/kumiko-headless": "0.199.0",
|
|
131
|
+
"@cosmicdrift/kumiko-renderer": "0.199.0",
|
|
132
|
+
"@cosmicdrift/kumiko-renderer-web": "0.199.0",
|
|
133
|
+
"@cosmicdrift/kumiko-types": "0.199.0",
|
|
134
134
|
"@mollie/api-client": "^4.5.0",
|
|
135
135
|
"@node-rs/argon2": "^2.0.2",
|
|
136
136
|
"@types/mailparser": "^3.4.6",
|
|
@@ -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
|
+
});
|
|
@@ -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";
|
|
@@ -70,7 +70,7 @@ export function createComplianceProfilesFeature(
|
|
|
70
70
|
forTenant: r.queryHandler(forTenantQuery),
|
|
71
71
|
listProfiles: r.queryHandler(listProfilesQuery),
|
|
72
72
|
subProcessors: r.queryHandler(subProcessorsQuery),
|
|
73
|
-
needsProfile: r.queryHandler(
|
|
73
|
+
needsProfile: r.queryHandler(createNeedsProfileQuery(resolvedAccess)),
|
|
74
74
|
};
|
|
75
75
|
|
|
76
76
|
r.screen({
|
|
@@ -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
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
1
2
|
import {
|
|
2
3
|
type ConfigKeyType,
|
|
3
4
|
type ConfigScope,
|
|
@@ -7,7 +8,7 @@ import {
|
|
|
7
8
|
toKebab,
|
|
8
9
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
9
10
|
import { z } from "zod";
|
|
10
|
-
import { requireConfigResolver } from "../feature";
|
|
11
|
+
import { requireConfigResolver, requireSystemDb } from "../feature";
|
|
11
12
|
import { hasConfigAccess } from "../write-helpers";
|
|
12
13
|
|
|
13
14
|
export type ReadinessMissingKey = {
|
|
@@ -32,18 +33,23 @@ export type RequiredKeyGate = (qualifiedName: string) => boolean;
|
|
|
32
33
|
// Builds the per-tenant gate from r.extensionSelector declarations: resolve
|
|
33
34
|
// each selector through the config cascade, mark every provider-feature
|
|
34
35
|
// registered under that point as counted/uncounted. Features without a
|
|
35
|
-
// selector-gated registration always count.
|
|
36
|
+
// selector-gated registration always count. `db` is the caller's own scoped
|
|
37
|
+
// handle — config's own handlers pass requireSystemDb's result, the plain
|
|
38
|
+
// tenant-scoped readiness feature passes its own ctx.db. This function makes
|
|
39
|
+
// no scope decision itself; see readiness/handlers/status.query.ts and
|
|
40
|
+
// config/handlers/readiness.query.ts's own handler for the two call sites.
|
|
36
41
|
export async function buildProviderSelectionGate(
|
|
37
42
|
ctx: HandlerContext,
|
|
38
43
|
callerQn: string,
|
|
39
44
|
user: SessionUser,
|
|
45
|
+
db: TenantDb,
|
|
40
46
|
): Promise<RequiredKeyGate> {
|
|
41
47
|
const resolver = requireConfigResolver(ctx, callerQn);
|
|
42
48
|
const countsByFeature = new Map<string, boolean>();
|
|
43
49
|
for (const [extensionName, selectorKey] of ctx.registry.getAllExtensionSelectors()) {
|
|
44
50
|
const keyDef = ctx.registry.getConfigKey(selectorKey);
|
|
45
51
|
if (!keyDef) continue; // registry-build already failed this; defensive
|
|
46
|
-
const value = await resolver.get(selectorKey, keyDef, user.tenantId, user.id,
|
|
52
|
+
const value = await resolver.get(selectorKey, keyDef, user.tenantId, user.id, db);
|
|
47
53
|
const selected = typeof value === "string" ? value.trim() : "";
|
|
48
54
|
for (const usage of ctx.registry.getExtensionUsages(extensionName)) {
|
|
49
55
|
if (usage.featureName === undefined) continue;
|
|
@@ -65,6 +71,7 @@ export async function collectMissingRequiredConfig(
|
|
|
65
71
|
ctx: HandlerContext,
|
|
66
72
|
callerQn: string,
|
|
67
73
|
user: SessionUser,
|
|
74
|
+
db: TenantDb,
|
|
68
75
|
gate?: RequiredKeyGate,
|
|
69
76
|
options?: {
|
|
70
77
|
/** Verdict-Pfade (Rollup, selbst role-gated) MÜSSEN ungefiltert zählen:
|
|
@@ -75,7 +82,7 @@ export async function collectMissingRequiredConfig(
|
|
|
75
82
|
},
|
|
76
83
|
): Promise<ReadinessMissingKey[]> {
|
|
77
84
|
const resolver = requireConfigResolver(ctx, callerQn);
|
|
78
|
-
const effectiveGate = gate ?? (await buildProviderSelectionGate(ctx, callerQn, user));
|
|
85
|
+
const effectiveGate = gate ?? (await buildProviderSelectionGate(ctx, callerQn, user, db));
|
|
79
86
|
// Kandidaten erst sammeln, dann EIN Batch-Resolve — die sequentielle
|
|
80
87
|
// resolver.get-Schleife war ein N+1 über alle required Keys (272/1).
|
|
81
88
|
type KeyDef =
|
|
@@ -98,7 +105,7 @@ export async function collectMissingRequiredConfig(
|
|
|
98
105
|
candidates,
|
|
99
106
|
user.tenantId,
|
|
100
107
|
user.id,
|
|
101
|
-
|
|
108
|
+
db,
|
|
102
109
|
ctx.secrets,
|
|
103
110
|
);
|
|
104
111
|
for (const [qualifiedKey, keyDef] of candidates) {
|
|
@@ -124,7 +131,13 @@ export const readinessQuery = defineQueryHandler({
|
|
|
124
131
|
// Per-key read access enforced via hasConfigAccess inside the handler.
|
|
125
132
|
access: { openToAll: true },
|
|
126
133
|
handler: async (query, ctx) => {
|
|
127
|
-
const
|
|
134
|
+
const db = requireSystemDb(ctx, "config:query:readiness", query.user.tenantId);
|
|
135
|
+
const missing = await collectMissingRequiredConfig(
|
|
136
|
+
ctx,
|
|
137
|
+
"config:query:readiness",
|
|
138
|
+
query.user,
|
|
139
|
+
db,
|
|
140
|
+
);
|
|
128
141
|
return { missing };
|
|
129
142
|
},
|
|
130
143
|
});
|