@cosmicdrift/kumiko-bundled-features 0.221.0 → 0.223.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 +10 -9
- package/src/audit/__tests__/audit-log-screen.test.tsx +5 -5
- package/src/audit/__tests__/audit-screens.boot.test.ts +2 -1
- package/src/audit/__tests__/audit.integration.test.ts +3 -1
- package/src/audit/__tests__/constants.test.ts +6 -1
- package/src/audit/constants.ts +10 -1
- package/src/audit/feature.ts +2 -0
- package/src/audit/i18n.ts +2 -0
- package/src/audit/web/audit-log-detail-screen.tsx +7 -3
- package/src/audit/web/audit-log-screen.tsx +27 -29
- package/src/auth-email-password/handlers/invite-create.write.ts +7 -1
- package/src/auth-email-password/seeding.ts +3 -1
- package/src/auth-mfa/hmac-token-codec.ts +75 -0
- package/src/auth-mfa/mfa-challenge-token.ts +10 -65
- package/src/auth-mfa/mfa-preauth-setup-token.ts +10 -61
- package/src/cap-counter/stock-cap-guard.ts +1 -1
- package/src/channel-email/__tests__/email-channel.test.ts +19 -0
- package/src/channel-email/__tests__/pii-guard.test.ts +11 -0
- package/src/channel-email/email-channel.ts +14 -7
- package/src/channel-email/pii-guard.ts +8 -0
- package/src/compliance-profiles/handlers/set-profile.write.ts +1 -4
- package/src/crypto-shredding/constants.ts +1 -0
- package/src/crypto-shredding/feature.ts +6 -1
- package/src/crypto-shredding/handlers/forget-subject.write.ts +25 -1
- package/src/crypto-shredding/index.ts +2 -0
- package/src/custom-fields/__tests__/audit-integration.integration.test.ts +9 -1
- package/src/file-derivatives/feature.ts +2 -2
- package/src/file-derivatives/handlers/public-variant.query.ts +4 -5
- package/src/files-provider-s3/__tests__/s3-provider.test.ts +58 -1
- package/src/files-provider-s3/s3-provider.ts +45 -0
- package/src/files-tenant-data/__tests__/hooks.integration.test.ts +271 -0
- package/src/files-tenant-data/__tests__/sweep-orphaned-derivatives.job.integration.test.ts +275 -0
- package/src/files-tenant-data/changes.json +1 -0
- package/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +173 -0
- package/src/files-tenant-data/hooks.ts +93 -0
- package/src/files-tenant-data/index.ts +58 -0
- package/src/form-draft/db/queries/cleanup.ts +17 -0
- package/src/form-draft/handlers/cleanup.job.ts +20 -10
- package/src/form-draft/handlers/discard.write.ts +25 -7
- package/src/form-draft/handlers/get.query.ts +4 -2
- package/src/form-draft/handlers/list.query.ts +4 -1
- package/src/form-draft/lookup.ts +12 -5
- package/src/inbound-mail-foundation/watch-supervisor.ts +28 -4
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +128 -3
- package/src/jobs/constants.ts +1 -0
- package/src/jobs/handlers/retry.write.ts +26 -6
- package/src/jobs/i18n.ts +3 -0
- package/src/sessions/session-callbacks.ts +1 -0
- package/src/tags/__tests__/tag-screens.boot.test.ts +13 -0
- package/src/tenant/__tests__/tenant-timezone-boot.integration.test.ts +165 -0
- package/src/tenant/changes.json +1 -1
- package/src/tenant/constants.ts +5 -1
- package/src/tenant/handlers/remove-member.write.ts +12 -0
- package/src/tenant/handlers/update-member-roles.write.ts +10 -37
- package/src/tenant/last-tenant-admin.ts +39 -0
- package/src/tenant/screens.ts +2 -0
- package/src/tenant/seeding.ts +3 -1
- package/src/tenant-lifecycle/feature.ts +1 -0
- package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +1 -5
- package/src/tenant-lifecycle/run-tenant-destroy.ts +5 -0
- package/src/tenant-lifecycle/stages.ts +5 -0
- package/src/tier-engine/web/tier-admin-screen.tsx +1 -2
- package/src/user/__tests__/email-unique-blind-index.integration.test.ts +25 -13
- package/src/user/changes.json +12 -5
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +3 -10
- package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -8
- package/src/user-data-rights/handlers/deletion-grace-period.ts +2 -2
- package/src/user-data-rights/handlers/lift-restriction.write.ts +2 -2
- package/src/user-data-rights/handlers/restrict-account.write.ts +3 -3
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +10 -6
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +114 -1
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +63 -12
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
isPiiCiphertext,
|
|
15
15
|
PII_ERASED_SENTINEL,
|
|
16
16
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
17
|
-
import { createRegistry } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
|
+
import { createRegistry, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
18
18
|
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
19
19
|
import {
|
|
20
20
|
createTestDb,
|
|
@@ -26,8 +26,12 @@ import {
|
|
|
26
26
|
TestUsers,
|
|
27
27
|
unsafePushTables,
|
|
28
28
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
29
|
-
import {
|
|
30
|
-
|
|
29
|
+
import {
|
|
30
|
+
resetPiiSubjectKmsForTests,
|
|
31
|
+
resetTestTables,
|
|
32
|
+
sleep,
|
|
33
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
34
|
+
import { JobHandlers, JobQueries } from "../constants";
|
|
31
35
|
import { createJobsFeature } from "../feature";
|
|
32
36
|
import { createJobRunLogger } from "../job-run-logger";
|
|
33
37
|
import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
|
|
@@ -432,3 +436,124 @@ describe("jobs:query:details decrypts log messages and error end-to-end (#2247,
|
|
|
432
436
|
expect(listed?.error).toBe(SECRET_LIST_ERROR);
|
|
433
437
|
});
|
|
434
438
|
});
|
|
439
|
+
|
|
440
|
+
// #2465: jobs:write:retry read run.payload straight off the row and
|
|
441
|
+
// JSON.parse'd it without decrypting first — broken on main for any retry
|
|
442
|
+
// of a run whose payload was encrypted under the triggering user's DEK
|
|
443
|
+
// (#799). This dispatches the real jobs:write:retry HTTP handler so a
|
|
444
|
+
// regression (missing decrypt, wrong AAD field name, sentinel mishandled)
|
|
445
|
+
// fails a test instead of shipping a crash to the retry button.
|
|
446
|
+
describe("jobs:write:retry decrypts payload before dispatch (#2465)", () => {
|
|
447
|
+
let retryStack: TestStack;
|
|
448
|
+
let retryLogger: ReturnType<typeof createJobRunLogger>;
|
|
449
|
+
let retryKms: InMemoryKmsAdapter;
|
|
450
|
+
|
|
451
|
+
const RETRY_USER_ID = "u-pii-retry-1";
|
|
452
|
+
const RETRY_JOB_NAME = "retryapp:job:capture-import";
|
|
453
|
+
const capturedPayloads: Record<string, unknown>[] = [];
|
|
454
|
+
|
|
455
|
+
const retryAppFeature = defineFeature("retryapp", (r) => {
|
|
456
|
+
r.job("captureImport", { trigger: { manual: true } }, async (payload) => {
|
|
457
|
+
capturedPayloads.push(payload);
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
beforeAll(async () => {
|
|
462
|
+
retryStack = await setupTestStack({
|
|
463
|
+
features: [retryAppFeature, createJobsFeature()],
|
|
464
|
+
jobs: {
|
|
465
|
+
consumerLane: "worker",
|
|
466
|
+
queueNamePrefix: `kumiko-jobs-retry-pii-test-${Date.now()}`,
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
await unsafePushTables(retryStack.db, { jobRunsTable, jobRunLogsTable });
|
|
470
|
+
retryLogger = createJobRunLogger({ db: retryStack.db, registry: retryStack.registry });
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
afterAll(async () => {
|
|
474
|
+
await retryStack.cleanup();
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
beforeEach(() => {
|
|
478
|
+
capturedPayloads.length = 0;
|
|
479
|
+
retryKms = new InMemoryKmsAdapter();
|
|
480
|
+
configurePiiSubjectKms(retryKms);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
afterEach(() => {
|
|
484
|
+
resetPiiSubjectKmsForTests();
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test("retry decrypts an encrypted run payload and dispatches the plaintext", async () => {
|
|
488
|
+
await retryLogger.onJobStart?.(RETRY_JOB_NAME, "bull-retry-1", {
|
|
489
|
+
triggeredById: RETRY_USER_ID,
|
|
490
|
+
payload: SECRET_PAYLOAD,
|
|
491
|
+
});
|
|
492
|
+
await retryLogger.onJobFailed?.(RETRY_JOB_NAME, "bull-retry-1", "boom", []);
|
|
493
|
+
|
|
494
|
+
const row = await fetchOne(retryStack.db, jobRunsTable, { bullJobId: "bull-retry-1" });
|
|
495
|
+
expect(isPiiCiphertext(row?.["payload"])).toBe(true);
|
|
496
|
+
expect(row?.["status"]).toBe("failed");
|
|
497
|
+
|
|
498
|
+
const result = await retryStack.http.writeOk<{
|
|
499
|
+
jobName: string;
|
|
500
|
+
bullJobId: string;
|
|
501
|
+
retriedFromRunId: string;
|
|
502
|
+
}>(JobHandlers.retry, { runId: row?.["id"] }, TestUsers.systemAdmin);
|
|
503
|
+
expect(result.jobName).toBe(RETRY_JOB_NAME);
|
|
504
|
+
|
|
505
|
+
await sleep(1000);
|
|
506
|
+
|
|
507
|
+
expect(capturedPayloads).toHaveLength(1);
|
|
508
|
+
expect(capturedPayloads[0]).toEqual(JSON.parse(SECRET_PAYLOAD));
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("retry on a run whose payload key was erased is rejected instead of crashing", async () => {
|
|
512
|
+
await retryLogger.onJobStart?.(RETRY_JOB_NAME, "bull-retry-2", {
|
|
513
|
+
triggeredById: RETRY_USER_ID,
|
|
514
|
+
payload: SECRET_PAYLOAD,
|
|
515
|
+
});
|
|
516
|
+
await retryLogger.onJobFailed?.(RETRY_JOB_NAME, "bull-retry-2", "boom", []);
|
|
517
|
+
|
|
518
|
+
const row = await fetchOne(retryStack.db, jobRunsTable, { bullJobId: "bull-retry-2" });
|
|
519
|
+
await retryKms.eraseKey({ kind: "user", userId: RETRY_USER_ID });
|
|
520
|
+
|
|
521
|
+
const errInfo = await retryStack.http.writeErr(
|
|
522
|
+
JobHandlers.retry,
|
|
523
|
+
{ runId: row?.["id"] },
|
|
524
|
+
TestUsers.systemAdmin,
|
|
525
|
+
);
|
|
526
|
+
expect(errInfo.code).toBe("unprocessable");
|
|
527
|
+
expect(errInfo.details).toMatchObject({ reason: "job_payload_erased" });
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// The other producer of PII_ERASED_SENTINEL: job-run-logger writes the
|
|
531
|
+
// literal "[[erased]]" string (not ciphertext) when the key is already
|
|
532
|
+
// gone at onJobStart time (see the mirrored case above this describe
|
|
533
|
+
// block). retry.write.ts's isPiiCiphertext/decrypt path must reject this
|
|
534
|
+
// stored sentinel the same way it rejects a decrypt-time one.
|
|
535
|
+
test("retry on a run whose payload was stored as the erased sentinel is rejected", async () => {
|
|
536
|
+
await retryLogger.onJobStart?.(RETRY_JOB_NAME, "bull-retry-3", {
|
|
537
|
+
triggeredById: RETRY_USER_ID,
|
|
538
|
+
payload: SECRET_PAYLOAD,
|
|
539
|
+
});
|
|
540
|
+
await retryKms.eraseKey({ kind: "user", userId: RETRY_USER_ID });
|
|
541
|
+
|
|
542
|
+
await retryLogger.onJobStart?.(RETRY_JOB_NAME, "bull-retry-4", {
|
|
543
|
+
triggeredById: RETRY_USER_ID,
|
|
544
|
+
payload: SECRET_PAYLOAD,
|
|
545
|
+
});
|
|
546
|
+
await retryLogger.onJobFailed?.(RETRY_JOB_NAME, "bull-retry-4", "boom", []);
|
|
547
|
+
|
|
548
|
+
const row = await fetchOne(retryStack.db, jobRunsTable, { bullJobId: "bull-retry-4" });
|
|
549
|
+
expect(row?.["payload"]).toBe(PII_ERASED_SENTINEL);
|
|
550
|
+
|
|
551
|
+
const errInfo = await retryStack.http.writeErr(
|
|
552
|
+
JobHandlers.retry,
|
|
553
|
+
{ runId: row?.["id"] },
|
|
554
|
+
TestUsers.systemAdmin,
|
|
555
|
+
);
|
|
556
|
+
expect(errInfo.code).toBe("unprocessable");
|
|
557
|
+
expect(errInfo.details).toMatchObject({ reason: "job_payload_erased" });
|
|
558
|
+
});
|
|
559
|
+
});
|
package/src/jobs/constants.ts
CHANGED
|
@@ -21,6 +21,7 @@ export const JobErrors = {
|
|
|
21
21
|
notFound: "not_found",
|
|
22
22
|
onlyFailedCanRetry: "only_failed_jobs_can_be_retried",
|
|
23
23
|
notManual: "job_not_manual",
|
|
24
|
+
payloadErased: "job_payload_erased",
|
|
24
25
|
} as const;
|
|
25
26
|
|
|
26
27
|
/** SystemAdmin job-runs list. Nav: `jobs:screen:job-runs`. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-framework/crypto";
|
|
2
3
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
4
|
import {
|
|
4
5
|
InternalError,
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
import type { JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
|
|
10
11
|
import { parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
|
|
11
12
|
import { z } from "zod";
|
|
13
|
+
import { decryptStoredPii } from "../../shared";
|
|
12
14
|
import { JobErrors } from "../constants";
|
|
13
15
|
import { jobRunsTable } from "../job-run-table";
|
|
14
16
|
|
|
@@ -53,12 +55,30 @@ export const retryWrite = defineWriteHandler({
|
|
|
53
55
|
);
|
|
54
56
|
}
|
|
55
57
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
// payload is stored encrypted under the triggering user's DEK (#799),
|
|
59
|
+
// same mechanism as detail.query.ts/list.query.ts — decrypt before
|
|
60
|
+
// parsing it as JSON. A key erased since the run finished decrypts to
|
|
61
|
+
// PII_ERASED_SENTINEL, which is not JSON; reject the retry rather than
|
|
62
|
+
// dispatch it with a silently emptied payload.
|
|
63
|
+
let payload: Record<string, unknown> = {};
|
|
64
|
+
if (run.payload) {
|
|
65
|
+
const decryptedPayload = await decryptStoredPii(
|
|
66
|
+
run.payload,
|
|
67
|
+
"payload",
|
|
68
|
+
`job run ${event.payload.runId} payload`,
|
|
69
|
+
);
|
|
70
|
+
if (decryptedPayload === PII_ERASED_SENTINEL) {
|
|
71
|
+
return writeFailure(
|
|
72
|
+
new UnprocessableError(JobErrors.payloadErased, {
|
|
73
|
+
i18nKey: "jobs.errors.payloadErased",
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
payload = parseJsonOrThrow<Record<string, unknown>>(
|
|
78
|
+
decryptedPayload,
|
|
79
|
+
`job run ${event.payload.runId} payload`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
62
82
|
|
|
63
83
|
const bullJobId = await jobRunner.dispatch(run.jobName, payload);
|
|
64
84
|
|
package/src/jobs/i18n.ts
CHANGED
|
@@ -61,4 +61,7 @@ export const JOBS_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
61
61
|
"jobs.errors.onlyFailedCanRetry": {
|
|
62
62
|
en: "Only failed runs can be retried.",
|
|
63
63
|
},
|
|
64
|
+
"jobs.errors.payloadErased": {
|
|
65
|
+
en: "This run's payload can no longer be read — the triggering user's data was erased.",
|
|
66
|
+
},
|
|
64
67
|
};
|
|
@@ -115,6 +115,7 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
|
|
|
115
115
|
);
|
|
116
116
|
},
|
|
117
117
|
|
|
118
|
+
// kumiko-lint-ignore complexity-budget session check + lastSeen refresh on hot path
|
|
118
119
|
async sessionChecker(sid: string, expectedUserId: string): Promise<AuthSessionCheckResult> {
|
|
119
120
|
const row = await fetchOne<{
|
|
120
121
|
userId: string;
|
|
@@ -48,4 +48,17 @@ describe("tags catalog screens (entityList + entityEdit)", () => {
|
|
|
48
48
|
expect(custom.screens[TAGS_EDIT_SCREEN_ID]?.access).toEqual({ roles: ["Admin"] });
|
|
49
49
|
expect(custom.writeHandlers["tag:delete"]?.access).toEqual({ roles: ["Admin"] });
|
|
50
50
|
});
|
|
51
|
+
|
|
52
|
+
test("convention write schemas accept entityEdit envelopes", () => {
|
|
53
|
+
const createSchema = feature.writeHandlers["tag:create"]?.schema;
|
|
54
|
+
const updateSchema = feature.writeHandlers["tag:update"]?.schema;
|
|
55
|
+
const deleteSchema = feature.writeHandlers["tag:delete"]?.schema;
|
|
56
|
+
expect(createSchema).toBeDefined();
|
|
57
|
+
expect(updateSchema).toBeDefined();
|
|
58
|
+
expect(deleteSchema).toBeDefined();
|
|
59
|
+
expect(createSchema!.safeParse({ name: "x" }).success).toBe(true);
|
|
60
|
+
const id = "00000000-0000-4000-8000-000000000001";
|
|
61
|
+
expect(updateSchema!.safeParse({ id, version: 0, changes: { name: "x" } }).success).toBe(true);
|
|
62
|
+
expect(deleteSchema!.safeParse({ id }).success).toBe(true);
|
|
63
|
+
});
|
|
51
64
|
});
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
createTestUser,
|
|
11
11
|
setupTestStack,
|
|
12
12
|
type TestStack,
|
|
13
|
+
testTenantId,
|
|
13
14
|
unsafePushTables,
|
|
14
15
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
15
16
|
import { z } from "zod";
|
|
@@ -28,6 +29,16 @@ const probeFeature = defineFeature("tz-probe", (r) => {
|
|
|
28
29
|
);
|
|
29
30
|
});
|
|
30
31
|
|
|
32
|
+
// Query (not write) probe for the caching tests below: dispatcher.query()
|
|
33
|
+
// never opens a transaction, which is the hot path fw#2462 targets and the
|
|
34
|
+
// only path buildHandlerContext is allowed to populate the cache from.
|
|
35
|
+
const queryProbeFeature = defineFeature("tz-cache-probe", (r) => {
|
|
36
|
+
r.requires("tenant");
|
|
37
|
+
r.queryHandler("read-tz", z.object({}), async (_query, ctx) => ({ tenant: ctx.tz.tenant }), {
|
|
38
|
+
access: { openToAll: true },
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
31
42
|
describe("ctx.tz.tenant against the real tenant feature (fw#1648)", () => {
|
|
32
43
|
let stack: TestStack;
|
|
33
44
|
|
|
@@ -58,3 +69,157 @@ describe("ctx.tz.tenant against the real tenant feature (fw#1648)", () => {
|
|
|
58
69
|
expect(res.tenant).toBe("Asia/Tokyo");
|
|
59
70
|
});
|
|
60
71
|
});
|
|
72
|
+
|
|
73
|
+
// fw#2462: dispatch-shared.ts now caches the resolved tenant:config:timezone
|
|
74
|
+
// value per tenant instead of resolving it on every dispatch. These tests
|
|
75
|
+
// prove the cache is actually consulted (config() isn't re-invoked on a
|
|
76
|
+
// repeat read) and that config:write:set/reset correctly invalidate it —
|
|
77
|
+
// own stack per describe so cache state never leaks across test files.
|
|
78
|
+
describe("tenant:config:timezone dispatch cache (fw#2462)", () => {
|
|
79
|
+
let stack: TestStack;
|
|
80
|
+
let configCallCount: number;
|
|
81
|
+
|
|
82
|
+
beforeAll(async () => {
|
|
83
|
+
const resolver = createConfigResolver();
|
|
84
|
+
stack = await setupTestStack({
|
|
85
|
+
features: [createConfigFeature(), createTenantFeature(), queryProbeFeature],
|
|
86
|
+
extraContext: ({ registry }) => {
|
|
87
|
+
const realFactory = createConfigAccessorFactory(registry, resolver);
|
|
88
|
+
// Counts calls through the config accessor — a cache hit in
|
|
89
|
+
// dispatch-shared.ts's buildHandlerContext never reaches this
|
|
90
|
+
// wrapper, so the count is a direct proxy for "did we re-resolve
|
|
91
|
+
// tenant:config:timezone instead of serving it from cache".
|
|
92
|
+
const countingFactory: typeof realFactory = (args) => {
|
|
93
|
+
const accessor = realFactory(args);
|
|
94
|
+
// Probe only ever calls the qualified-string-key overload — the
|
|
95
|
+
// cast sidesteps re-declaring ConfigAccessor's generic handle
|
|
96
|
+
// overload for a test-only wrapper.
|
|
97
|
+
return ((key: string) => {
|
|
98
|
+
configCallCount++;
|
|
99
|
+
return accessor(key);
|
|
100
|
+
}) as typeof accessor; // @cast-boundary test-helper
|
|
101
|
+
};
|
|
102
|
+
return {
|
|
103
|
+
configResolver: resolver,
|
|
104
|
+
_configAccessorFactory: countingFactory,
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
await unsafePushTables(stack.db, { configValuesTable });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
afterAll(async () => {
|
|
112
|
+
await stack.cleanup();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("repeat reads for the same tenant are served from cache, not re-resolved", async () => {
|
|
116
|
+
configCallCount = 0;
|
|
117
|
+
const tenantId = testTenantId(101);
|
|
118
|
+
const admin = createTestUser({ id: 21, tenantId, roles: ["Admin"] });
|
|
119
|
+
|
|
120
|
+
await stack.http.queryOk("tz-cache-probe:query:read-tz", {}, admin);
|
|
121
|
+
expect(configCallCount).toBe(1);
|
|
122
|
+
|
|
123
|
+
await stack.http.queryOk("tz-cache-probe:query:read-tz", {}, admin);
|
|
124
|
+
await stack.http.queryOk("tz-cache-probe:query:read-tz", {}, admin);
|
|
125
|
+
expect(configCallCount).toBe(1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("config:write:set invalidates the cache for the written tenant", async () => {
|
|
129
|
+
configCallCount = 0;
|
|
130
|
+
const tenantId = testTenantId(102);
|
|
131
|
+
const admin = createTestUser({ id: 22, tenantId, roles: ["Admin"] });
|
|
132
|
+
|
|
133
|
+
const before = await stack.http.queryOk<{ tenant: string }>(
|
|
134
|
+
"tz-cache-probe:query:read-tz",
|
|
135
|
+
{},
|
|
136
|
+
admin,
|
|
137
|
+
);
|
|
138
|
+
expect(configCallCount).toBe(1);
|
|
139
|
+
|
|
140
|
+
await stack.http.writeOk(
|
|
141
|
+
"config:write:set",
|
|
142
|
+
{ key: "tenant:config:timezone", value: "Asia/Tokyo" },
|
|
143
|
+
admin,
|
|
144
|
+
);
|
|
145
|
+
expect(before.tenant).not.toBe("Asia/Tokyo");
|
|
146
|
+
|
|
147
|
+
const after = await stack.http.queryOk<{ tenant: string }>(
|
|
148
|
+
"tz-cache-probe:query:read-tz",
|
|
149
|
+
{},
|
|
150
|
+
admin,
|
|
151
|
+
);
|
|
152
|
+
expect(after.tenant).toBe("Asia/Tokyo");
|
|
153
|
+
// The cache entry was dropped by the write, so the read after it is a
|
|
154
|
+
// fresh resolve — a second config() call, not a stale cache hit.
|
|
155
|
+
expect(configCallCount).toBe(2);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("config:write:reset invalidates the cache for the written tenant", async () => {
|
|
159
|
+
configCallCount = 0;
|
|
160
|
+
const tenantId = testTenantId(103);
|
|
161
|
+
const admin = createTestUser({ id: 23, tenantId, roles: ["Admin"] });
|
|
162
|
+
|
|
163
|
+
await stack.http.writeOk(
|
|
164
|
+
"config:write:set",
|
|
165
|
+
{ key: "tenant:config:timezone", value: "Asia/Singapore" },
|
|
166
|
+
admin,
|
|
167
|
+
);
|
|
168
|
+
const before = await stack.http.queryOk<{ tenant: string }>(
|
|
169
|
+
"tz-cache-probe:query:read-tz",
|
|
170
|
+
{},
|
|
171
|
+
admin,
|
|
172
|
+
);
|
|
173
|
+
expect(before.tenant).toBe("Asia/Singapore");
|
|
174
|
+
|
|
175
|
+
await stack.http.writeOk("config:write:reset", { key: "tenant:config:timezone" }, admin);
|
|
176
|
+
|
|
177
|
+
const after = await stack.http.queryOk<{ tenant: string }>(
|
|
178
|
+
"tz-cache-probe:query:read-tz",
|
|
179
|
+
{},
|
|
180
|
+
admin,
|
|
181
|
+
);
|
|
182
|
+
expect(after.tenant).not.toBe("Asia/Singapore");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("a system-scope write invalidates every tenant's cached entry, not just the writer's", async () => {
|
|
186
|
+
const tenantA = testTenantId(104);
|
|
187
|
+
const tenantB = testTenantId(105);
|
|
188
|
+
const userA = createTestUser({ id: 24, tenantId: tenantA, roles: ["Admin"] });
|
|
189
|
+
const userB = createTestUser({ id: 25, tenantId: tenantB, roles: ["Admin"] });
|
|
190
|
+
const systemAdmin = createTestUser({ id: 26, tenantId: tenantA, roles: ["SystemAdmin"] });
|
|
191
|
+
|
|
192
|
+
// Warm both tenants' cache entries with neither having its own override.
|
|
193
|
+
const beforeA = await stack.http.queryOk<{ tenant: string }>(
|
|
194
|
+
"tz-cache-probe:query:read-tz",
|
|
195
|
+
{},
|
|
196
|
+
userA,
|
|
197
|
+
);
|
|
198
|
+
const beforeB = await stack.http.queryOk<{ tenant: string }>(
|
|
199
|
+
"tz-cache-probe:query:read-tz",
|
|
200
|
+
{},
|
|
201
|
+
userB,
|
|
202
|
+
);
|
|
203
|
+
expect(beforeA.tenant).not.toBe("Australia/Sydney");
|
|
204
|
+
expect(beforeB.tenant).not.toBe("Australia/Sydney");
|
|
205
|
+
|
|
206
|
+
await stack.http.writeOk(
|
|
207
|
+
"config:write:set",
|
|
208
|
+
{ key: "tenant:config:timezone", value: "Australia/Sydney", scope: "system" },
|
|
209
|
+
systemAdmin,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const afterA = await stack.http.queryOk<{ tenant: string }>(
|
|
213
|
+
"tz-cache-probe:query:read-tz",
|
|
214
|
+
{},
|
|
215
|
+
userA,
|
|
216
|
+
);
|
|
217
|
+
const afterB = await stack.http.queryOk<{ tenant: string }>(
|
|
218
|
+
"tz-cache-probe:query:read-tz",
|
|
219
|
+
{},
|
|
220
|
+
userB,
|
|
221
|
+
);
|
|
222
|
+
expect(afterA.tenant).toBe("Australia/Sydney");
|
|
223
|
+
expect(afterB.tenant).toBe("Australia/Sydney");
|
|
224
|
+
});
|
|
225
|
+
});
|
package/src/tenant/changes.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"version": "0.219.0",
|
|
4
4
|
"type": "improvement",
|
|
5
5
|
"title": "Invite/member-roles pickers include TenantAdmin",
|
|
6
|
-
"detail": "DEFAULT_INVITE_ROLE_OPTIONS
|
|
6
|
+
"detail": "DEFAULT_INVITE_ROLE_OPTIONS stays User/Editor/Admin so Admin actors never see an unassignable TenantAdmin option (fw#2414). OWNER_INVITE_ROLE_OPTIONS adds TenantAdmin for custom screens used by TenantAdmin/SystemAdmin. SystemAdmin stays global-only (user.roles), never a membership invite option."
|
|
7
7
|
},
|
|
8
8
|
{
|
|
9
9
|
"version": "0.209.0",
|
package/src/tenant/constants.ts
CHANGED
|
@@ -23,7 +23,11 @@ export const MEMBER_STATUS_CELL_COMPONENT = "MemberStatusCell" as const;
|
|
|
23
23
|
export const MEMBER_ROLES_CELL_COMPONENT = "MemberRolesCell" as const;
|
|
24
24
|
|
|
25
25
|
/** Closed allowlist for invite-role picker — never free text (escalation guard). */
|
|
26
|
-
|
|
26
|
+
// Admin (rank 2) shares these screens with TenantAdmin — TenantAdmin must not
|
|
27
|
+
// appear here or Admin sees an unassignable option (fw#2414). Apps that need
|
|
28
|
+
// owner invites compose OWNER_INVITE_ROLE_OPTIONS into a custom screen.
|
|
29
|
+
export const DEFAULT_INVITE_ROLE_OPTIONS = ["User", "Editor", "Admin"] as const;
|
|
30
|
+
export const OWNER_INVITE_ROLE_OPTIONS = ["User", "Editor", "Admin", "TenantAdmin"] as const;
|
|
27
31
|
|
|
28
32
|
// Qualified write handler names (QN format: scope:type:name)
|
|
29
33
|
export const TenantHandlers = {
|
|
@@ -6,7 +6,9 @@ import {
|
|
|
6
6
|
withResponseData,
|
|
7
7
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
8
|
import { InternalError, NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
9
|
+
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
9
10
|
import { z } from "zod";
|
|
11
|
+
import { assertNotLastTenantAdmin } from "../last-tenant-admin";
|
|
10
12
|
import { tenantMembershipEntity, tenantMembershipsTable } from "../membership-table";
|
|
11
13
|
|
|
12
14
|
const executor = createEventStoreExecutor(tenantMembershipsTable, tenantMembershipEntity, {
|
|
@@ -45,6 +47,16 @@ export const removeMemberWrite = defineWriteHandler({
|
|
|
45
47
|
);
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
const currentRoles = parseRoles((existing as DbRow)["roles"]);
|
|
51
|
+
if (currentRoles.includes("TenantAdmin")) {
|
|
52
|
+
const lastAdmin = await assertNotLastTenantAdmin(
|
|
53
|
+
db,
|
|
54
|
+
event.payload.tenantId,
|
|
55
|
+
event.payload.userId,
|
|
56
|
+
);
|
|
57
|
+
if (lastAdmin !== undefined) return lastAdmin;
|
|
58
|
+
}
|
|
59
|
+
|
|
48
60
|
// Revoke THIS tenant's sessions BEFORE the delete — a removed member must
|
|
49
61
|
// not keep a valid session for the window between the delete and a
|
|
50
62
|
// post-delete revoke. Best-effort cross-feature call: sessions may not be
|
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
import { fetchOne
|
|
2
|
-
import {
|
|
3
|
-
acquireNamespacedAdvisoryLock,
|
|
4
|
-
createEventStoreExecutor,
|
|
5
|
-
type DbRow,
|
|
6
|
-
} from "@cosmicdrift/kumiko-framework/db";
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { createEventStoreExecutor, type DbRow } from "@cosmicdrift/kumiko-framework/db";
|
|
7
3
|
import {
|
|
8
4
|
access,
|
|
9
5
|
createSystemUser,
|
|
@@ -12,7 +8,6 @@ import {
|
|
|
12
8
|
withResponseData,
|
|
13
9
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
14
10
|
import {
|
|
15
|
-
ConflictError,
|
|
16
11
|
InternalError,
|
|
17
12
|
NotFoundError,
|
|
18
13
|
ValidationError,
|
|
@@ -20,7 +15,7 @@ import {
|
|
|
20
15
|
} from "@cosmicdrift/kumiko-framework/errors";
|
|
21
16
|
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
22
17
|
import { z } from "zod";
|
|
23
|
-
import {
|
|
18
|
+
import { assertNotLastTenantAdmin } from "../last-tenant-admin";
|
|
24
19
|
import {
|
|
25
20
|
findForbiddenMembershipRole,
|
|
26
21
|
reservedMembershipRoleError,
|
|
@@ -37,10 +32,6 @@ const executor = createEventStoreExecutor(tenantMembershipsTable, tenantMembersh
|
|
|
37
32
|
// r.usesApi here, unlike user-data-rights:restrict-account's hard dep).
|
|
38
33
|
const REVOKE_ALL_SESSIONS_QN = "sessions:write:user-session:revoke-all-for-user";
|
|
39
34
|
|
|
40
|
-
// Serializes last-TenantAdmin demotion checks per tenant inside the write TX
|
|
41
|
-
// (dispatcher batch wraps handlers in transaction — xact lock holds through update).
|
|
42
|
-
const LAST_TENANT_ADMIN_LOCK_NAMESPACE = 0x7461646d; // 'tadm'
|
|
43
|
-
|
|
44
35
|
/** SystemAdmin may pass payload.tenantId (cross-tenant); the members actionForm
|
|
45
36
|
* only sends userId+roles, so fall back to the active session tenant. */
|
|
46
37
|
function resolveTargetTenantId(
|
|
@@ -60,8 +51,8 @@ export const updateMemberRolesWrite = defineWriteHandler({
|
|
|
60
51
|
}),
|
|
61
52
|
// "system" + access.admin ("TenantAdmin", "Admin", "SystemAdmin").
|
|
62
53
|
// The system user (createSystemUser, roles=["system"]) and SystemAdmin
|
|
63
|
-
// manage memberships cross-tenant (payload.tenantId). TenantAdmin
|
|
64
|
-
// session-scoped (event.user.tenantId).
|
|
54
|
+
// manage memberships cross-tenant (payload.tenantId). TenantAdmin and Admin
|
|
55
|
+
// are session-scoped (event.user.tenantId).
|
|
65
56
|
access: { roles: ["system", ...access.admin] },
|
|
66
57
|
handler: async (event, ctx) => {
|
|
67
58
|
if (!ctx.systemDb) {
|
|
@@ -129,28 +120,8 @@ export const updateMemberRolesWrite = defineWriteHandler({
|
|
|
129
120
|
const willBeTenantAdmin = event.payload.roles.includes("TenantAdmin");
|
|
130
121
|
|
|
131
122
|
if (targetIsTenantAdmin && !willBeTenantAdmin) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
await acquireNamespacedAdvisoryLock(db, LAST_TENANT_ADMIN_LOCK_NAMESPACE, targetTenantId);
|
|
135
|
-
const allMemberships = await selectMany(db, tenantMembershipsTable, {
|
|
136
|
-
tenantId: targetTenantId,
|
|
137
|
-
});
|
|
138
|
-
const adminCount = allMemberships.filter((m) =>
|
|
139
|
-
parseRoles(m["roles"]).includes("TenantAdmin"),
|
|
140
|
-
).length;
|
|
141
|
-
if (adminCount <= 1) {
|
|
142
|
-
return writeFailure(
|
|
143
|
-
new ConflictError({
|
|
144
|
-
message: "cannot demote the last tenant admin",
|
|
145
|
-
i18nKey: "tenant.errors.cannotDemoteLastTenantAdmin",
|
|
146
|
-
details: {
|
|
147
|
-
reason: TenantErrors.lastTenantAdmin,
|
|
148
|
-
userId: event.payload.userId,
|
|
149
|
-
tenantId: targetTenantId,
|
|
150
|
-
},
|
|
151
|
-
}),
|
|
152
|
-
);
|
|
153
|
-
}
|
|
123
|
+
const lastAdmin = await assertNotLastTenantAdmin(db, targetTenantId, event.payload.userId);
|
|
124
|
+
if (lastAdmin !== undefined) return lastAdmin;
|
|
154
125
|
}
|
|
155
126
|
|
|
156
127
|
// fetchOne already gave us the stream version — hand it to the executor
|
|
@@ -176,13 +147,15 @@ export const updateMemberRolesWrite = defineWriteHandler({
|
|
|
176
147
|
// Stream tenant follows the actor via streamTenantFor — use the
|
|
177
148
|
// membership tenant so SystemAdmin/cross-tenant and TenantAdmin paths
|
|
178
149
|
// hit the same stream seedTenantMembership/invite-accept wrote.
|
|
150
|
+
// Keep the real actor for audit metadata; only override tenantId so
|
|
151
|
+
// streamTenantFor writes into the membership tenant (#2401).
|
|
179
152
|
const result = await executor.update(
|
|
180
153
|
{
|
|
181
154
|
id: row["id"] as string, // @cast-boundary db-row
|
|
182
155
|
version: row["version"] as number, // @cast-boundary db-row
|
|
183
156
|
changes: { roles: JSON.stringify(event.payload.roles) },
|
|
184
157
|
},
|
|
185
|
-
|
|
158
|
+
{ ...event.user, tenantId: targetTenantId },
|
|
186
159
|
db,
|
|
187
160
|
);
|
|
188
161
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { acquireNamespacedAdvisoryLock } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import { ConflictError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
+
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
5
|
+
import { TenantErrors } from "./constants";
|
|
6
|
+
import { tenantMembershipsTable } from "./membership-table";
|
|
7
|
+
|
|
8
|
+
// Serializes last-TenantAdmin demotion/removal checks per tenant inside the write TX
|
|
9
|
+
// (dispatcher batch wraps handlers in transaction — xact lock holds through update).
|
|
10
|
+
export const LAST_TENANT_ADMIN_LOCK_NAMESPACE = 0x7461646d; // 'tadm'
|
|
11
|
+
|
|
12
|
+
type TenantDb = Parameters<typeof acquireNamespacedAdvisoryLock>[0];
|
|
13
|
+
|
|
14
|
+
/** Refuse demoting/removing the last TenantAdmin for `tenantId`. */
|
|
15
|
+
export async function assertNotLastTenantAdmin(
|
|
16
|
+
db: TenantDb,
|
|
17
|
+
tenantId: string,
|
|
18
|
+
userId: string,
|
|
19
|
+
): Promise<ReturnType<typeof writeFailure> | undefined> {
|
|
20
|
+
await acquireNamespacedAdvisoryLock(db, LAST_TENANT_ADMIN_LOCK_NAMESPACE, tenantId);
|
|
21
|
+
const allMemberships = await selectMany(db, tenantMembershipsTable, { tenantId });
|
|
22
|
+
const adminCount = allMemberships.filter((m) =>
|
|
23
|
+
parseRoles(m["roles"]).includes("TenantAdmin"),
|
|
24
|
+
).length;
|
|
25
|
+
if (adminCount <= 1) {
|
|
26
|
+
return writeFailure(
|
|
27
|
+
new ConflictError({
|
|
28
|
+
message: "cannot demote the last tenant admin",
|
|
29
|
+
i18nKey: "tenant.errors.cannotDemoteLastTenantAdmin",
|
|
30
|
+
details: {
|
|
31
|
+
reason: TenantErrors.lastTenantAdmin,
|
|
32
|
+
userId,
|
|
33
|
+
tenantId,
|
|
34
|
+
},
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
package/src/tenant/screens.ts
CHANGED
|
@@ -167,6 +167,8 @@ export const memberRolesEditScreen = {
|
|
|
167
167
|
id: MEMBER_ROLES_EDIT_SCREEN_ID,
|
|
168
168
|
type: "actionForm",
|
|
169
169
|
handler: TenantHandlers.updateMemberRoles,
|
|
170
|
+
redirect: MEMBERS_SCREEN_ID,
|
|
171
|
+
cancelTarget: MEMBERS_SCREEN_ID,
|
|
170
172
|
fields: {
|
|
171
173
|
userId: { type: "text", required: true },
|
|
172
174
|
roles: { type: "multiSelect", options: DEFAULT_INVITE_ROLE_OPTIONS, required: true },
|
package/src/tenant/seeding.ts
CHANGED
|
@@ -285,13 +285,15 @@ export async function seedTenantMembership(
|
|
|
285
285
|
return { id: extractMembershipId(existing) };
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
+
// Keep by.id as audit actor, but force stream tenant to the membership tenant
|
|
289
|
+
// so a cross-tenant `by` override cannot diverge row tenant vs event stream.
|
|
288
290
|
const result = await executor.create(
|
|
289
291
|
{
|
|
290
292
|
userId: options.userId,
|
|
291
293
|
tenantId: options.tenantId,
|
|
292
294
|
roles: JSON.stringify(options.roles),
|
|
293
295
|
},
|
|
294
|
-
by,
|
|
296
|
+
{ ...by, tenantId: options.tenantId },
|
|
295
297
|
tdb,
|
|
296
298
|
);
|
|
297
299
|
if (!result.isSuccess) {
|
|
@@ -34,11 +34,7 @@ export const cancelDestructionWrite = defineWriteHandler({
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
if (!isWithinGracePeriod(row.gracePeriodEnd)) {
|
|
37
|
-
return writeFailure(
|
|
38
|
-
new UnprocessableError("grace_period_expired", {
|
|
39
|
-
details: { reason: "grace_period_expired" },
|
|
40
|
-
}),
|
|
41
|
-
);
|
|
37
|
+
return writeFailure(new UnprocessableError("grace_period_expired"));
|
|
42
38
|
}
|
|
43
39
|
|
|
44
40
|
const update = await crud.update(
|