@cosmicdrift/kumiko-bundled-features 0.28.0 → 0.31.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +20 -0
- package/src/auth-email-password/web/auth-client.ts +4 -0
- package/src/auth-email-password/web/tenant-switcher.tsx +8 -2
- package/src/config/__tests__/config.integration.test.ts +113 -0
- package/src/config/constants.ts +1 -0
- package/src/config/feature.ts +2 -0
- package/src/config/handlers/readiness.query.ts +96 -0
- package/src/config/index.ts +5 -0
- package/src/file-foundation/__tests__/file-foundation.integration.test.ts +12 -2
- package/src/file-foundation/feature.ts +3 -0
- package/src/file-provider-s3/feature.ts +8 -6
- package/src/foundation-shared/__tests__/config-helpers.test.ts +17 -0
- package/src/foundation-shared/config-helpers.ts +32 -6
- package/src/foundation-shared/index.ts +1 -1
- package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +7 -1
- package/src/mail-foundation/feature.ts +3 -0
- package/src/mail-transport-smtp/feature.ts +8 -6
- package/src/readiness/__tests__/readiness.integration.test.ts +338 -0
- package/src/readiness/constants.ts +7 -0
- package/src/readiness/feature.ts +26 -0
- package/src/readiness/handlers/status.query.ts +48 -0
- package/src/readiness/index.ts +3 -0
- package/src/secrets/__tests__/require-secrets-context.test.ts +1 -0
- package/src/secrets/feature.ts +2 -0
- package/src/secrets/secrets-context.ts +8 -0
- package/src/tenant/__tests__/multi-tenant.integration.test.ts +68 -0
- package/src/tenant/__tests__/tenant.integration.test.ts +16 -0
- package/src/tenant/constants.ts +1 -0
- package/src/tenant/feature.ts +3 -1
- package/src/tenant/handlers/enable.write.ts +20 -0
- package/src/tenant/handlers/memberships.query.ts +28 -5
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// Full-stack integration test for the readiness rollup. Drives
|
|
2
|
+
// readiness:query:status through the dispatcher so the real config-cascade
|
|
3
|
+
// + secrets-metadata-lookup are exercised — including the no-read-audit
|
|
4
|
+
// guarantee of the has() probe.
|
|
5
|
+
|
|
6
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
8
|
+
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
9
|
+
import { createEncryptionProvider, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
10
|
+
import { access, createTenantConfig, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
11
|
+
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
12
|
+
import { createEnvMasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
|
|
13
|
+
import {
|
|
14
|
+
createTestUser,
|
|
15
|
+
setupTestStack,
|
|
16
|
+
type TestStack,
|
|
17
|
+
testTenantId,
|
|
18
|
+
unsafeCreateEntityTable,
|
|
19
|
+
unsafePushTables,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
21
|
+
import { createConfigFeature } from "../../config";
|
|
22
|
+
import { createConfigAccessorFactory } from "../../config/feature";
|
|
23
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
24
|
+
import { configValuesTable } from "../../config/table";
|
|
25
|
+
import {
|
|
26
|
+
createSecretsContext,
|
|
27
|
+
createSecretsFeature,
|
|
28
|
+
TENANT_SECRET_READ_EVENT,
|
|
29
|
+
tenantSecretsTable,
|
|
30
|
+
} from "../../secrets";
|
|
31
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
32
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
33
|
+
import { ReadinessQueries } from "../constants";
|
|
34
|
+
import { readinessFeature } from "../feature";
|
|
35
|
+
|
|
36
|
+
// Probe-feature: one required + one optional config key, one required +
|
|
37
|
+
// one optional secret — the rollup must list exactly the required gaps.
|
|
38
|
+
const probeFeature = defineFeature("readiness-probe", (r) => {
|
|
39
|
+
r.requires("config");
|
|
40
|
+
r.requires("secrets");
|
|
41
|
+
|
|
42
|
+
r.config({
|
|
43
|
+
keys: {
|
|
44
|
+
apiUrl: createTenantConfig("text", {
|
|
45
|
+
required: true,
|
|
46
|
+
default: "",
|
|
47
|
+
write: access.roles("TenantAdmin", "SystemAdmin"),
|
|
48
|
+
read: access.roles("TenantAdmin", "SystemAdmin"),
|
|
49
|
+
}),
|
|
50
|
+
timeout: createTenantConfig("number", {
|
|
51
|
+
default: 30,
|
|
52
|
+
write: access.roles("TenantAdmin", "SystemAdmin"),
|
|
53
|
+
}),
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
r.secret("probe.apiToken", {
|
|
58
|
+
label: { de: "API-Token", en: "API token" },
|
|
59
|
+
scope: "tenant",
|
|
60
|
+
required: true,
|
|
61
|
+
});
|
|
62
|
+
r.secret("probe.optionalToken", {
|
|
63
|
+
label: { de: "Optionales Token", en: "Optional token" },
|
|
64
|
+
scope: "tenant",
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Provider-gating fixture: foundation declares the selector, two providers
|
|
69
|
+
// register under the point. The smtp-ish one carries required key + secret —
|
|
70
|
+
// they must count ONLY while "smtp" is the selected provider.
|
|
71
|
+
const probeMailFoundation = defineFeature("probe-mail-foundation", (r) => {
|
|
72
|
+
r.requires("config");
|
|
73
|
+
r.extendsRegistrar("probeMailTransport", { onRegister: () => undefined });
|
|
74
|
+
const configKeys = r.config({
|
|
75
|
+
keys: {
|
|
76
|
+
provider: createTenantConfig("text", {
|
|
77
|
+
default: "",
|
|
78
|
+
write: access.roles("TenantAdmin", "SystemAdmin"),
|
|
79
|
+
}),
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
r.extensionSelector("probeMailTransport", configKeys.provider);
|
|
83
|
+
return { configKeys };
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const probeSmtpProvider = defineFeature("probe-smtp", (r) => {
|
|
87
|
+
r.requires("config");
|
|
88
|
+
r.requires("secrets");
|
|
89
|
+
r.useExtension("probeMailTransport", "smtp");
|
|
90
|
+
r.config({
|
|
91
|
+
keys: {
|
|
92
|
+
host: createTenantConfig("text", {
|
|
93
|
+
required: true,
|
|
94
|
+
default: "",
|
|
95
|
+
write: access.roles("TenantAdmin", "SystemAdmin"),
|
|
96
|
+
read: access.roles("TenantAdmin", "SystemAdmin"),
|
|
97
|
+
}),
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
r.secret("smtp.password", {
|
|
101
|
+
label: { de: "SMTP-Passwort", en: "SMTP password" },
|
|
102
|
+
scope: "tenant",
|
|
103
|
+
required: true,
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const probeInMemoryProvider = defineFeature("probe-inmemory", (r) => {
|
|
108
|
+
r.useExtension("probeMailTransport", "inmemory");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const REQUIRED_CONFIG_KEY = "readiness-probe:config:api-url";
|
|
112
|
+
const REQUIRED_SECRET_KEY = "readiness-probe:secret:probe-api-token";
|
|
113
|
+
const PROVIDER_SELECTOR_KEY = "probe-mail-foundation:config:provider";
|
|
114
|
+
const GATED_CONFIG_KEY = "probe-smtp:config:host";
|
|
115
|
+
const GATED_SECRET_KEY = "probe-smtp:secret:smtp-password";
|
|
116
|
+
|
|
117
|
+
type StatusResult = {
|
|
118
|
+
missingConfig: ReadonlyArray<{ key: string; scope: string; type: string }>;
|
|
119
|
+
missingSecrets: ReadonlyArray<{ key: string }>;
|
|
120
|
+
ready: boolean;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
let stack: TestStack;
|
|
124
|
+
let db: DbConnection;
|
|
125
|
+
|
|
126
|
+
beforeAll(async () => {
|
|
127
|
+
const encryption = createEncryptionProvider(randomBytes(32).toString("base64"));
|
|
128
|
+
const resolver = createConfigResolver({ encryption });
|
|
129
|
+
const masterKeyProvider = createEnvMasterKeyProvider({
|
|
130
|
+
env: {
|
|
131
|
+
KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
|
|
132
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
stack = await setupTestStack({
|
|
137
|
+
features: [
|
|
138
|
+
createConfigFeature(),
|
|
139
|
+
createTenantFeature(),
|
|
140
|
+
createSecretsFeature(),
|
|
141
|
+
readinessFeature,
|
|
142
|
+
probeFeature,
|
|
143
|
+
probeMailFoundation,
|
|
144
|
+
probeSmtpProvider,
|
|
145
|
+
probeInMemoryProvider,
|
|
146
|
+
],
|
|
147
|
+
extraContext: ({ db, registry }) => ({
|
|
148
|
+
configResolver: resolver,
|
|
149
|
+
configEncryption: encryption,
|
|
150
|
+
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
151
|
+
secrets: createSecretsContext({ db, masterKeyProvider }),
|
|
152
|
+
}),
|
|
153
|
+
});
|
|
154
|
+
db = stack.db;
|
|
155
|
+
await unsafeCreateEntityTable(db, tenantEntity);
|
|
156
|
+
await unsafePushTables(db, { configValuesTable, tenant_secrets: tenantSecretsTable });
|
|
157
|
+
await createEventsTable(db);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
afterAll(async () => {
|
|
161
|
+
await stack.cleanup();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
function adminFor(tenantNumber: number) {
|
|
165
|
+
return createTestUser({
|
|
166
|
+
id: tenantNumber,
|
|
167
|
+
tenantId: testTenantId(tenantNumber),
|
|
168
|
+
roles: ["TenantAdmin"],
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function statusFor(admin: ReturnType<typeof adminFor>): Promise<StatusResult> {
|
|
173
|
+
return stack.http.queryOk<StatusResult>(ReadinessQueries.status, {}, admin);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
describe("readiness:query:status", () => {
|
|
177
|
+
test("fresh tenant → required config + secret listed as missing, ready false", async () => {
|
|
178
|
+
const admin = adminFor(601);
|
|
179
|
+
|
|
180
|
+
const status = await statusFor(admin);
|
|
181
|
+
|
|
182
|
+
expect(status.ready).toBe(false);
|
|
183
|
+
expect(status.missingConfig).toContainEqual({
|
|
184
|
+
key: REQUIRED_CONFIG_KEY,
|
|
185
|
+
scope: "tenant",
|
|
186
|
+
type: "text",
|
|
187
|
+
});
|
|
188
|
+
expect(status.missingSecrets).toContainEqual({ key: REQUIRED_SECRET_KEY });
|
|
189
|
+
// Optional keys must not appear — they have usable defaults / aren't required.
|
|
190
|
+
expect(status.missingConfig.map((k) => k.key)).not.toContain("readiness-probe:config:timeout");
|
|
191
|
+
expect(status.missingSecrets.map((s) => s.key)).not.toContain(
|
|
192
|
+
"readiness-probe:secret:probe-optional-token",
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("setting required config + secret flips ready to true", async () => {
|
|
197
|
+
const admin = adminFor(602);
|
|
198
|
+
|
|
199
|
+
await stack.http.writeOk(
|
|
200
|
+
"config:write:set",
|
|
201
|
+
{ key: REQUIRED_CONFIG_KEY, value: "https://api.example.test" },
|
|
202
|
+
admin,
|
|
203
|
+
);
|
|
204
|
+
await stack.http.writeOk(
|
|
205
|
+
"secrets:write:set",
|
|
206
|
+
{ key: REQUIRED_SECRET_KEY, value: "token-xyz" },
|
|
207
|
+
admin,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const status = await statusFor(admin);
|
|
211
|
+
expect(status.missingConfig.map((k) => k.key)).not.toContain(REQUIRED_CONFIG_KEY);
|
|
212
|
+
expect(status.missingSecrets).toEqual([]);
|
|
213
|
+
expect(status.ready).toBe(true);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("tenant isolation: tenant A's values don't make tenant B ready", async () => {
|
|
217
|
+
const adminA = adminFor(603);
|
|
218
|
+
const adminB = adminFor(604);
|
|
219
|
+
|
|
220
|
+
await stack.http.writeOk(
|
|
221
|
+
"config:write:set",
|
|
222
|
+
{ key: REQUIRED_CONFIG_KEY, value: "https://a.example.test" },
|
|
223
|
+
adminA,
|
|
224
|
+
);
|
|
225
|
+
await stack.http.writeOk(
|
|
226
|
+
"secrets:write:set",
|
|
227
|
+
{ key: REQUIRED_SECRET_KEY, value: "token-a" },
|
|
228
|
+
adminA,
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
expect((await statusFor(adminA)).ready).toBe(true);
|
|
232
|
+
const statusB = await statusFor(adminB);
|
|
233
|
+
expect(statusB.ready).toBe(false);
|
|
234
|
+
expect(statusB.missingSecrets).toContainEqual({ key: REQUIRED_SECRET_KEY });
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("non-TenantAdmin → access denied (same gate as secrets:query:list)", async () => {
|
|
238
|
+
const member = createTestUser({
|
|
239
|
+
id: 605,
|
|
240
|
+
tenantId: testTenantId(605),
|
|
241
|
+
roles: ["Member"],
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const res = await stack.http.query(ReadinessQueries.status, {}, member);
|
|
245
|
+
expect(res.status).toBe(403);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("provider-gated keys don't count while no provider is selected", async () => {
|
|
249
|
+
const admin = adminFor(607);
|
|
250
|
+
|
|
251
|
+
const status = await statusFor(admin);
|
|
252
|
+
expect(status.missingConfig.map((k) => k.key)).not.toContain(GATED_CONFIG_KEY);
|
|
253
|
+
expect(status.missingSecrets.map((s) => s.key)).not.toContain(GATED_SECRET_KEY);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("selecting the provider pulls its required key + secret into missing", async () => {
|
|
257
|
+
const admin = adminFor(608);
|
|
258
|
+
|
|
259
|
+
await stack.http.writeOk(
|
|
260
|
+
"config:write:set",
|
|
261
|
+
{ key: PROVIDER_SELECTOR_KEY, value: "smtp" },
|
|
262
|
+
admin,
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const status = await statusFor(admin);
|
|
266
|
+
expect(status.missingConfig.map((k) => k.key)).toContain(GATED_CONFIG_KEY);
|
|
267
|
+
expect(status.missingSecrets).toContainEqual({ key: GATED_SECRET_KEY });
|
|
268
|
+
expect(status.ready).toBe(false);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("tenant on the inmemory provider is ready despite unset smtp keys", async () => {
|
|
272
|
+
const admin = adminFor(609);
|
|
273
|
+
|
|
274
|
+
// The advisor scenario: smtp + inmemory both mounted, tenant runs
|
|
275
|
+
// inmemory — unset smtp keys must not block ready.
|
|
276
|
+
await stack.http.writeOk(
|
|
277
|
+
"config:write:set",
|
|
278
|
+
{ key: PROVIDER_SELECTOR_KEY, value: "inmemory" },
|
|
279
|
+
admin,
|
|
280
|
+
);
|
|
281
|
+
await stack.http.writeOk(
|
|
282
|
+
"config:write:set",
|
|
283
|
+
{ key: REQUIRED_CONFIG_KEY, value: "https://api.example.test" },
|
|
284
|
+
admin,
|
|
285
|
+
);
|
|
286
|
+
await stack.http.writeOk(
|
|
287
|
+
"secrets:write:set",
|
|
288
|
+
{ key: REQUIRED_SECRET_KEY, value: "token-609" },
|
|
289
|
+
admin,
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
const status = await statusFor(admin);
|
|
293
|
+
expect(status.missingConfig).toEqual([]);
|
|
294
|
+
expect(status.missingSecrets).toEqual([]);
|
|
295
|
+
expect(status.ready).toBe(true);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("config:query:readiness applies the same provider gating", async () => {
|
|
299
|
+
const admin = adminFor(610);
|
|
300
|
+
|
|
301
|
+
const before = await stack.http.queryOk<{ missing: ReadonlyArray<{ key: string }> }>(
|
|
302
|
+
"config:query:readiness",
|
|
303
|
+
{},
|
|
304
|
+
admin,
|
|
305
|
+
);
|
|
306
|
+
expect(before.missing.map((k) => k.key)).not.toContain(GATED_CONFIG_KEY);
|
|
307
|
+
|
|
308
|
+
await stack.http.writeOk(
|
|
309
|
+
"config:write:set",
|
|
310
|
+
{ key: PROVIDER_SELECTOR_KEY, value: "smtp" },
|
|
311
|
+
admin,
|
|
312
|
+
);
|
|
313
|
+
const after = await stack.http.queryOk<{ missing: ReadonlyArray<{ key: string }> }>(
|
|
314
|
+
"config:query:readiness",
|
|
315
|
+
{},
|
|
316
|
+
admin,
|
|
317
|
+
);
|
|
318
|
+
expect(after.missing.map((k) => k.key)).toContain(GATED_CONFIG_KEY);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("status probe writes NO secret-read audit events", async () => {
|
|
322
|
+
const admin = adminFor(606);
|
|
323
|
+
await stack.http.writeOk(
|
|
324
|
+
"secrets:write:set",
|
|
325
|
+
{ key: REQUIRED_SECRET_KEY, value: "token-606" },
|
|
326
|
+
admin,
|
|
327
|
+
);
|
|
328
|
+
await asRawClient(db).unsafe(
|
|
329
|
+
`DELETE FROM "${eventsTable.tableName}" WHERE type = '${TENANT_SECRET_READ_EVENT}'`,
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
// Probes both branches: set secret (has → true) + missing optional.
|
|
333
|
+
await statusFor(admin);
|
|
334
|
+
|
|
335
|
+
const readEvents = await selectMany(db, eventsTable, { type: TENANT_SECRET_READ_EVENT });
|
|
336
|
+
expect(readEvents).toEqual([]);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// kumiko-feature-version: 1
|
|
2
|
+
//
|
|
3
|
+
// readiness — one-call tenant-onboarding rollup above config + secrets.
|
|
4
|
+
//
|
|
5
|
+
// `config:query:readiness` lists required config keys without a usable
|
|
6
|
+
// value; `secrets:query:list` lists set secrets. Neither can verdict
|
|
7
|
+
// "tenant is ready" alone. This feature requires both, so its status
|
|
8
|
+
// query may roll up missing config + missing required secrets + a single
|
|
9
|
+
// `ready` boolean — the settings-checklist call for admin UIs.
|
|
10
|
+
|
|
11
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
12
|
+
import { statusQuery } from "./handlers/status.query";
|
|
13
|
+
|
|
14
|
+
export const readinessFeature = defineFeature("readiness", (r) => {
|
|
15
|
+
r.describe(
|
|
16
|
+
"One-call tenant-onboarding probe: `readiness:query:status` rolls up every config key and secret declared `required: true` across all mounted features and reports which still lack a usable value for the calling tenant, plus a single `ready` boolean. Provider-features under an `r.extensionSelector`-declared extension point count only while their provider is the selected one — a tenant on the inmemory mail transport is not blocked by unset SMTP keys. Mount it (together with `config` and `secrets`) when an admin UI needs a settings checklist before the first mail-send or file-write; the per-concern lists stay available via `config:query:readiness` and `secrets:query:list`.",
|
|
17
|
+
);
|
|
18
|
+
r.requires("config");
|
|
19
|
+
r.requires("secrets");
|
|
20
|
+
|
|
21
|
+
const queries = {
|
|
22
|
+
status: r.queryHandler(statusQuery),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return { queries };
|
|
26
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildProviderSelectionGate,
|
|
3
|
+
collectMissingRequiredConfig,
|
|
4
|
+
} from "@cosmicdrift/kumiko-bundled-features/config";
|
|
5
|
+
import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
6
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { ReadinessQueries } from "../constants";
|
|
9
|
+
|
|
10
|
+
export type ReadinessMissingSecret = { readonly key: string };
|
|
11
|
+
|
|
12
|
+
// The one-call rollup config:query:readiness deliberately refused: that
|
|
13
|
+
// query can't see secrets, this feature requires both — so it may verdict.
|
|
14
|
+
export const statusQuery = defineQueryHandler({
|
|
15
|
+
name: "status",
|
|
16
|
+
schema: z.object({}),
|
|
17
|
+
// Same gate as secrets:query:list — the response names missing secrets.
|
|
18
|
+
access: { roles: ["TenantAdmin"] },
|
|
19
|
+
handler: async (query, ctx) => {
|
|
20
|
+
// One gate for both halves: required keys/secrets of provider-features
|
|
21
|
+
// count only while their provider is the selected one (r.extensionSelector).
|
|
22
|
+
const gate = await buildProviderSelectionGate(ctx, ReadinessQueries.status, query.user);
|
|
23
|
+
const missingConfig = await collectMissingRequiredConfig(
|
|
24
|
+
ctx,
|
|
25
|
+
ReadinessQueries.status,
|
|
26
|
+
query.user,
|
|
27
|
+
gate,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
// has() is metadata-only: no decryption, no read-audit event — a
|
|
31
|
+
// readiness probe must not pollute the credential-read trail.
|
|
32
|
+
const secrets = requireSecretsContext(ctx, ReadinessQueries.status);
|
|
33
|
+
const missingSecrets: ReadinessMissingSecret[] = [];
|
|
34
|
+
for (const [qualifiedName, keyDef] of ctx.registry.getAllSecretKeys()) {
|
|
35
|
+
if (keyDef.required !== true) continue;
|
|
36
|
+
if (!gate(qualifiedName)) continue;
|
|
37
|
+
if (!(await secrets.has(query.user.tenantId, qualifiedName))) {
|
|
38
|
+
missingSecrets.push({ key: qualifiedName });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
missingConfig,
|
|
44
|
+
missingSecrets,
|
|
45
|
+
ready: missingConfig.length === 0 && missingSecrets.length === 0,
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
});
|
package/src/secrets/feature.ts
CHANGED
|
@@ -75,6 +75,8 @@ export function requireSecretsContext(
|
|
|
75
75
|
return {
|
|
76
76
|
get: (tenantId, key, overrideAudit) =>
|
|
77
77
|
raw.get(tenantId, key, overrideAudit ?? { userId, handlerName }),
|
|
78
|
+
// No audit injection: has() is metadata-only and never logs a read.
|
|
79
|
+
has: raw.has.bind(raw),
|
|
78
80
|
set: raw.set.bind(raw),
|
|
79
81
|
delete: raw.delete.bind(raw),
|
|
80
82
|
};
|
|
@@ -179,6 +179,14 @@ export function createSecretsContext(opts: SecretsContextOptions): SecretsContex
|
|
|
179
179
|
return createSecret(plaintext);
|
|
180
180
|
},
|
|
181
181
|
|
|
182
|
+
async has(tenantId, keyOrHandle) {
|
|
183
|
+
// Row-existence only — no decrypt, no DEK unwrap, no read-audit
|
|
184
|
+
// event. The audit table logs credential reads; a readiness probe
|
|
185
|
+
// never sees the value, so logging it would dilute the trail.
|
|
186
|
+
const existing = await lookup(tenantId, resolveKey(keyOrHandle));
|
|
187
|
+
return existing !== undefined;
|
|
188
|
+
},
|
|
189
|
+
|
|
182
190
|
async set(tenantId, keyOrHandle, value, setOpts = {}) {
|
|
183
191
|
const key = resolveKey(keyOrHandle);
|
|
184
192
|
const envelope = await encryptValue(value, masterKeyProvider);
|
|
@@ -205,6 +205,14 @@ describe("multi-tenant user", () => {
|
|
|
205
205
|
const tenantIds = memberships.map((m: Record<string, unknown>) => m["tenantId"]);
|
|
206
206
|
expect(tenantIds).toContain(testTenantId(1));
|
|
207
207
|
expect(tenantIds).toContain(testTenantId(2));
|
|
208
|
+
|
|
209
|
+
// tenantName/tenantKey machen die Memberships im UI unterscheidbar
|
|
210
|
+
// (Tenant-Switcher) — die Query reichert sie aus der tenants-Tabelle an.
|
|
211
|
+
const acme = memberships.find(
|
|
212
|
+
(m: Record<string, unknown>) => m["tenantId"] === testTenantId(1),
|
|
213
|
+
);
|
|
214
|
+
expect(acme["tenantName"]).toBe("ACME");
|
|
215
|
+
expect(acme["tenantKey"]).toBe("acme");
|
|
208
216
|
});
|
|
209
217
|
|
|
210
218
|
test("user has different roles per tenant", async () => {
|
|
@@ -231,6 +239,14 @@ describe("multi-tenant user", () => {
|
|
|
231
239
|
const body = await res.json();
|
|
232
240
|
expect(body.tenants.length).toBe(2);
|
|
233
241
|
expect(body.activeTenantId).toBe(testTenantId(1));
|
|
242
|
+
|
|
243
|
+
// name/key kommen bis in die HTTP-Response durch (Tenant-Switcher-Label).
|
|
244
|
+
const names = body.tenants.map((t: Record<string, unknown>) => t["name"]);
|
|
245
|
+
expect(names).toContain("ACME");
|
|
246
|
+
expect(names).toContain("Beta Inc");
|
|
247
|
+
const keys = body.tenants.map((t: Record<string, unknown>) => t["key"]);
|
|
248
|
+
expect(keys).toContain("acme");
|
|
249
|
+
expect(keys).toContain("beta");
|
|
234
250
|
});
|
|
235
251
|
|
|
236
252
|
test("POST /auth/switch-tenant issues new JWT with different tenant", async () => {
|
|
@@ -276,3 +292,55 @@ describe("perTenant jobs", () => {
|
|
|
276
292
|
}
|
|
277
293
|
});
|
|
278
294
|
});
|
|
295
|
+
|
|
296
|
+
// --- Scenario 6: disabled tenant verschwindet aus allen Auth-Flächen ---
|
|
297
|
+
//
|
|
298
|
+
// tenant:write:disable legt den Tenant still: memberships-Query filtert ihn,
|
|
299
|
+
// damit listet /auth/tenants ihn nicht mehr (Switcher), switch-tenant lehnt
|
|
300
|
+
// ab (not_a_member) und der Login wählt ihn nicht. active-tenant-ids lässt
|
|
301
|
+
// ihn ebenfalls aus (perTenant-Jobs). enable macht alles rückgängig.
|
|
302
|
+
// Läuft bewusst NACH den perTenant-Job-Tests — die erwarten 2 aktive Tenants.
|
|
303
|
+
|
|
304
|
+
describe("disabled tenant", () => {
|
|
305
|
+
const user = createTestUser({ id: 10 });
|
|
306
|
+
|
|
307
|
+
test("disable removes the tenant from memberships, /auth/tenants and switch-tenant", async () => {
|
|
308
|
+
const r = await writeApi(systemAdmin, TenantHandlers.disable, { id: testTenantId(2) });
|
|
309
|
+
expect(r.isSuccess).toBe(true);
|
|
310
|
+
|
|
311
|
+
const result = await queryApi(systemAdmin, TenantQueries.memberships, {
|
|
312
|
+
userId: "11111111-0000-4000-8000-000000000010",
|
|
313
|
+
});
|
|
314
|
+
const tenantIds = result.data.map((m: Record<string, unknown>) => m["tenantId"]);
|
|
315
|
+
expect(tenantIds).toEqual([testTenantId(1)]);
|
|
316
|
+
|
|
317
|
+
const res = await getApi(user, "/auth/tenants");
|
|
318
|
+
expect(res.status).toBe(200);
|
|
319
|
+
const body = await res.json();
|
|
320
|
+
expect(body.tenants.map((t: Record<string, unknown>) => t["tenantId"])).toEqual([
|
|
321
|
+
testTenantId(1),
|
|
322
|
+
]);
|
|
323
|
+
|
|
324
|
+
const switchRes = await postApi(user, "/auth/switch-tenant", { tenantId: testTenantId(2) });
|
|
325
|
+
expect(switchRes.status).toBe(403);
|
|
326
|
+
|
|
327
|
+
const activeIds = await queryApi(systemAdmin, TenantQueries.activeTenantIds, {});
|
|
328
|
+
expect(activeIds.data).toEqual([testTenantId(1)]);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("enable restores membership, switcher and active-tenant-ids", async () => {
|
|
332
|
+
const r = await writeApi(systemAdmin, TenantHandlers.enable, { id: testTenantId(2) });
|
|
333
|
+
expect(r.isSuccess).toBe(true);
|
|
334
|
+
|
|
335
|
+
const result = await queryApi(systemAdmin, TenantQueries.memberships, {
|
|
336
|
+
userId: "11111111-0000-4000-8000-000000000010",
|
|
337
|
+
});
|
|
338
|
+
expect(result.data.length).toBe(2);
|
|
339
|
+
|
|
340
|
+
const switchRes = await postApi(user, "/auth/switch-tenant", { tenantId: testTenantId(2) });
|
|
341
|
+
expect(switchRes.status).toBe(200);
|
|
342
|
+
|
|
343
|
+
const activeIds = await queryApi(systemAdmin, TenantQueries.activeTenantIds, {});
|
|
344
|
+
expect(activeIds.data).toContain(testTenantId(2));
|
|
345
|
+
});
|
|
346
|
+
});
|
|
@@ -145,6 +145,22 @@ describe("scenario 4: tenant.disable", () => {
|
|
|
145
145
|
"SystemAdmin",
|
|
146
146
|
]);
|
|
147
147
|
});
|
|
148
|
+
|
|
149
|
+
test("SystemAdmin can re-enable a disabled tenant", async () => {
|
|
150
|
+
const me = await stack.http.queryOk<Record<string, unknown>>(TenantQueries.me, {}, systemAdmin);
|
|
151
|
+
const tenantId = me["id"] as string;
|
|
152
|
+
|
|
153
|
+
const data = await stack.http.writeOk(TenantHandlers.enable, { id: tenantId }, systemAdmin);
|
|
154
|
+
expect(data!["data"]).toMatchObject({
|
|
155
|
+
isEnabled: true,
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("enable handler requires SystemAdmin role", async () => {
|
|
160
|
+
expect(rolesOf(stack.registry.getWriteHandler(TenantHandlers.enable)?.access)).toEqual([
|
|
161
|
+
"SystemAdmin",
|
|
162
|
+
]);
|
|
163
|
+
});
|
|
148
164
|
});
|
|
149
165
|
|
|
150
166
|
// --- Scenario 5: tenant.list ---
|
package/src/tenant/constants.ts
CHANGED
|
@@ -12,6 +12,7 @@ export const TenantHandlers = {
|
|
|
12
12
|
create: "tenant:write:create",
|
|
13
13
|
update: "tenant:write:update",
|
|
14
14
|
disable: "tenant:write:disable",
|
|
15
|
+
enable: "tenant:write:enable",
|
|
15
16
|
addMember: "tenant:write:add-member",
|
|
16
17
|
removeMember: "tenant:write:remove-member",
|
|
17
18
|
updateMemberRoles: "tenant:write:update-member-roles",
|
package/src/tenant/feature.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { addMemberWrite } from "./handlers/add-member.write";
|
|
|
10
10
|
import { cancelInvitationWrite } from "./handlers/cancel-invitation.write";
|
|
11
11
|
import { createWrite } from "./handlers/create.write";
|
|
12
12
|
import { disableWrite } from "./handlers/disable.write";
|
|
13
|
+
import { enableWrite } from "./handlers/enable.write";
|
|
13
14
|
import { invitationsQuery } from "./handlers/invitations.query";
|
|
14
15
|
import { listQuery } from "./handlers/list.query";
|
|
15
16
|
import { meQuery } from "./handlers/me.query";
|
|
@@ -30,7 +31,7 @@ export { tenantEntity, tenantTable } from "./schema/tenant";
|
|
|
30
31
|
export function createTenantFeature(): FeatureDefinition {
|
|
31
32
|
return defineFeature("tenant", (r) => {
|
|
32
33
|
r.describe(
|
|
33
|
-
"Registers the three core multi-tenancy entities \u2014 `tenant`, `tenant-membership`, and `tenant-invitation` (DB tables `read_tenants`, `read_tenant_memberships`, and `read_tenant_invitations`) \u2014 along with write handlers for create/update/disable/addMember/removeMember/updateMemberRoles and the matching queries. It also declares a set of per-tenant config keys (companyName, timezone, locale, SMTP credentials) and system-only keys (priceModel, maxUsers) via `r.config({ keys: { ... } })`. Use this feature in every multi-tenant app; membership resolution and invitation flows depend on it, and `auth-email-password` requires it.",
|
|
34
|
+
"Registers the three core multi-tenancy entities \u2014 `tenant`, `tenant-membership`, and `tenant-invitation` (DB tables `read_tenants`, `read_tenant_memberships`, and `read_tenant_invitations`) \u2014 along with write handlers for create/update/disable/enable/addMember/removeMember/updateMemberRoles and the matching queries. It also declares a set of per-tenant config keys (companyName, timezone, locale, SMTP credentials) and system-only keys (priceModel, maxUsers) via `r.config({ keys: { ... } })`. Use this feature in every multi-tenant app; membership resolution and invitation flows depend on it, and `auth-email-password` requires it.",
|
|
34
35
|
);
|
|
35
36
|
r.systemScope();
|
|
36
37
|
r.requires("config");
|
|
@@ -90,6 +91,7 @@ export function createTenantFeature(): FeatureDefinition {
|
|
|
90
91
|
create: r.writeHandler(createWrite),
|
|
91
92
|
update: r.writeHandler(updateWrite),
|
|
92
93
|
disable: r.writeHandler(disableWrite),
|
|
94
|
+
enable: r.writeHandler(enableWrite),
|
|
93
95
|
addMember: r.writeHandler(addMemberWrite),
|
|
94
96
|
removeMember: r.writeHandler(removeMemberWrite),
|
|
95
97
|
updateMemberRoles: r.writeHandler(updateMemberRolesWrite),
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { tenantEntity, tenantTable } from "../schema/tenant";
|
|
5
|
+
|
|
6
|
+
const crud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
|
|
7
|
+
|
|
8
|
+
// Recovery-Gegenstück zu disable — ohne enable wäre ein Fehlklick des
|
|
9
|
+
// Operators nur per Event-Hack reversibel.
|
|
10
|
+
export const enableWrite = defineWriteHandler({
|
|
11
|
+
name: "enable",
|
|
12
|
+
schema: z.object({ id: z.uuid() }),
|
|
13
|
+
access: { roles: ["SystemAdmin"] },
|
|
14
|
+
// Admin flip: last-writer-wins is fine. SystemAdmin is the only caller and
|
|
15
|
+
// there's no meaningful concurrent-edit race on this single boolean.
|
|
16
|
+
handler: async (event, ctx) =>
|
|
17
|
+
crud.update({ id: event.payload.id, changes: { isEnabled: true } }, event.user, ctx.db, {
|
|
18
|
+
skipOptimisticLock: true,
|
|
19
|
+
}),
|
|
20
|
+
});
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
1
|
+
import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { defineQueryHandler, SYSTEM_ROLE } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { tenantMembershipsTable } from "../membership-table";
|
|
6
|
+
import { tenantTable } from "../schema/tenant";
|
|
6
7
|
|
|
7
8
|
export const membershipsQuery = defineQueryHandler({
|
|
8
9
|
name: "memberships",
|
|
@@ -13,9 +14,31 @@ export const membershipsQuery = defineQueryHandler({
|
|
|
13
14
|
handler: async (query, ctx) => {
|
|
14
15
|
const rows = await selectMany(ctx.db, tenantMembershipsTable, { userId: query.payload.userId });
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
// tenantName/tenantKey machen Memberships in der UI unterscheidbar
|
|
18
|
+
// (Tenant-Switcher zeigte sonst nur das UUID-Präfix — bei Seed-Tenants
|
|
19
|
+
// mit 00000000-…-Präfix sind die ununterscheidbar). Eine Handvoll
|
|
20
|
+
// Memberships pro User → Einzel-Fetches sind ok.
|
|
21
|
+
const enriched = await Promise.all(
|
|
22
|
+
rows.map(async (row) => {
|
|
23
|
+
const tenant = await fetchOne<{ name?: unknown; key?: unknown; isEnabled?: unknown }>(
|
|
24
|
+
ctx.db,
|
|
25
|
+
tenantTable,
|
|
26
|
+
{ id: row["tenantId"] },
|
|
27
|
+
);
|
|
28
|
+
// Disabled Tenants (tenant:write:disable) zählen nicht als Membership:
|
|
29
|
+
// Login wählt sie nicht, /auth/tenants listet sie nicht, switch-tenant
|
|
30
|
+
// antwortet not_a_member. Nur das explizite false filtert — eine
|
|
31
|
+
// fehlende tenant-Row (Projektions-Drift) soll keinen Login-Lockout
|
|
32
|
+
// aller Member auslösen.
|
|
33
|
+
if (tenant !== undefined && tenant.isEnabled === false) return null;
|
|
34
|
+
return {
|
|
35
|
+
...row,
|
|
36
|
+
roles: parseRoles(row["roles"]),
|
|
37
|
+
...(typeof tenant?.name === "string" && { tenantName: tenant.name }),
|
|
38
|
+
...(typeof tenant?.key === "string" && { tenantKey: tenant.key }),
|
|
39
|
+
};
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
return enriched.filter((m) => m !== null);
|
|
20
43
|
},
|
|
21
44
|
});
|