@cosmicdrift/kumiko-bundled-features 0.197.1 → 0.199.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +272 -0
- package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +31 -1
- package/src/auth-email-password/feature.ts +9 -1
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +108 -35
- package/src/auth-email-password/handlers/login.write.ts +10 -5
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +5 -5
- package/src/auth-mfa/feature.ts +3 -0
- package/src/compliance-profiles/__tests__/compliance-profiles.integration.test.ts +87 -0
- package/src/compliance-profiles/__tests__/profile-picker.boot.test.ts +14 -3
- package/src/compliance-profiles/feature.ts +18 -5
- package/src/compliance-profiles/handlers/needs-profile.query.ts +52 -32
- package/src/compliance-profiles/index.ts +1 -0
- package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +185 -0
- package/src/config/__tests__/require-system-db.test.ts +46 -0
- package/src/config/feature.ts +28 -0
- package/src/config/handlers/cascade.query.ts +2 -2
- package/src/config/handlers/readiness.query.ts +19 -6
- package/src/config/handlers/reencrypt.job.ts +79 -21
- package/src/config/handlers/values.query.ts +2 -2
- package/src/delivery/__tests__/delivery-screens.boot.test.ts +22 -3
- package/src/delivery/__tests__/delivery-security.integration.test.ts +44 -1
- package/src/delivery/__tests__/delivery.integration.test.ts +96 -3
- package/src/delivery/feature.ts +13 -3
- package/src/delivery/handlers/log.query.ts +10 -4
- package/src/delivery/handlers/preferences.query.ts +14 -1
- package/src/delivery/handlers/set-preference.write.ts +12 -1
- package/src/delivery/index.ts +1 -1
- package/src/delivery/jobs.ts +20 -10
- package/src/derivatives-sharp/__tests__/render.test.ts +48 -0
- package/src/derivatives-sharp/render.ts +55 -15
- package/src/feature-toggles/__tests__/acknowledge-cross-tenant.test.ts +23 -0
- package/src/feature-toggles/constants.ts +6 -0
- package/src/feature-toggles/handlers/list.query.ts +12 -1
- package/src/feature-toggles/handlers/registered.query.ts +12 -1
- package/src/feature-toggles/handlers/set.write.ts +18 -3
- package/src/file-derivatives/__tests__/public-variant-query-gating.integration.test.ts +7 -1
- package/src/file-derivatives/feature.ts +12 -13
- package/src/file-derivatives/handlers/public-variant.query.ts +5 -1
- package/src/form-draft/__tests__/cleanup.integration.test.ts +22 -0
- package/src/form-draft/__tests__/discard-file-refs.integration.test.ts +17 -0
- package/src/form-draft/__tests__/limits.integration.test.ts +112 -0
- package/src/form-draft/constants.ts +13 -0
- package/src/form-draft/db/queries/cleanup.ts +4 -1
- package/src/form-draft/db/queries/draft-count.ts +20 -0
- package/src/form-draft/db/queries/owned-file-refs.ts +20 -3
- package/src/form-draft/handlers/__tests__/cleanup.job.test.ts +1 -0
- package/src/form-draft/handlers/cleanup.job.ts +6 -0
- package/src/form-draft/handlers/discard.write.ts +6 -0
- package/src/form-draft/handlers/save.write.ts +15 -2
- package/src/form-draft/schemas.ts +16 -2
- package/src/jobs/__tests__/jobs-feature.integration.test.ts +57 -0
- package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +12 -3
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +47 -2
- package/src/jobs/handlers/detail.query.ts +7 -1
- package/src/jobs/handlers/list.query.ts +8 -1
- package/src/jobs/handlers/projection-rebuild.job.ts +6 -3
- package/src/jobs/handlers/reindex-entity.job.ts +14 -10
- package/src/jobs/handlers/retry.write.ts +7 -1
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +14 -1
- package/src/personal-access-tokens/feature.ts +4 -1
- package/src/personal-access-tokens/roles.ts +11 -3
- package/src/readiness/handlers/status.query.ts +2 -1
- package/src/sessions/__tests__/membership-revoke.integration.test.ts +195 -0
- package/src/sessions/feature.ts +18 -10
- package/src/sessions/handlers/revoke-all-for-user.write.ts +12 -5
- package/src/sessions/index.ts +1 -1
- package/src/sessions/session-callbacks.ts +7 -1
- package/src/tenant/__tests__/update-cross-tenant.integration.test.ts +83 -0
- package/src/tenant/handlers/remove-member.write.ts +23 -1
- package/src/tenant/handlers/update-member-roles.write.ts +23 -1
- package/src/tenant/handlers/update.write.ts +35 -2
- package/src/tenant/invitation-table.ts +8 -1
- package/src/user/__tests__/user.integration.test.ts +21 -0
- package/src/user/handlers/create.write.ts +10 -3
- package/src/user/handlers/find-for-auth.query.ts +9 -1
- package/src/user/handlers/me.query.ts +10 -1
- package/src/user/handlers/update.write.ts +12 -2
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +6 -6
- package/src/user-data-rights/feature.ts +10 -7
- package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +35 -28
|
@@ -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
|
});
|
|
@@ -15,15 +15,28 @@
|
|
|
15
15
|
// so each rotation appends a normal `.updated` event whose payload carries
|
|
16
16
|
// the NEW envelope: after a full run even a from-scratch rebuild only
|
|
17
17
|
// ever sees the current-KEK envelope.
|
|
18
|
+
//
|
|
19
|
+
// systemScope() fail-closed pattern (framework#2056): the initial scan is
|
|
20
|
+
// intentionally cross-tenant (system-wide key rotation), acknowledged via
|
|
21
|
+
// UncheckedSystemDb.acknowledgeCrossTenant(). Rows are bucketed by tenant,
|
|
22
|
+
// and assertRowsTenant() re-verifies each row against its bucket's tenant
|
|
23
|
+
// right before the write — both checks are built locally from the exported
|
|
24
|
+
// createUncheckedSystemDb().
|
|
18
25
|
|
|
19
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
20
26
|
import {
|
|
21
27
|
createEventStoreExecutor,
|
|
22
28
|
createTenantDb,
|
|
29
|
+
createUncheckedSystemDb,
|
|
23
30
|
type DbConnection,
|
|
24
31
|
type TenantDb,
|
|
32
|
+
type UncheckedSystemDb,
|
|
25
33
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
26
|
-
import
|
|
34
|
+
import {
|
|
35
|
+
type JobHandlerFn,
|
|
36
|
+
type SessionUser,
|
|
37
|
+
SYSTEM_TENANT_ID,
|
|
38
|
+
type TenantId,
|
|
39
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
27
40
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
28
41
|
import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
|
|
29
42
|
import {
|
|
@@ -104,6 +117,22 @@ export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void>
|
|
|
104
117
|
return existing;
|
|
105
118
|
}
|
|
106
119
|
|
|
120
|
+
const sdbCache = new Map<TenantId, UncheckedSystemDb>();
|
|
121
|
+
function sdbFor(tenantId: TenantId): UncheckedSystemDb {
|
|
122
|
+
let existing = sdbCache.get(tenantId);
|
|
123
|
+
if (!existing) {
|
|
124
|
+
existing = createUncheckedSystemDb(tdbFor(tenantId));
|
|
125
|
+
sdbCache.set(tenantId, existing);
|
|
126
|
+
}
|
|
127
|
+
return existing;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Standalone from sdbCache/tdbCache on purpose: this one is only ever used
|
|
131
|
+
// to acknowledge the cross-tenant scan below, never bound to a real
|
|
132
|
+
// tenant's writes — sharing it with sdbFor(SYSTEM_TENANT_ID) would make a
|
|
133
|
+
// write-path cache entry double as the scan's ack gate.
|
|
134
|
+
const scanDb = createUncheckedSystemDb(createTenantDb(db, SYSTEM_TENANT_ID, "system"));
|
|
135
|
+
|
|
107
136
|
type ConfigRow = {
|
|
108
137
|
id: string;
|
|
109
138
|
key: string;
|
|
@@ -115,27 +144,51 @@ export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void>
|
|
|
115
144
|
let alreadyCurrent = 0;
|
|
116
145
|
const targetVersion = provider.currentVersion();
|
|
117
146
|
|
|
147
|
+
type BucketedRow = { tenantId: TenantId; row: ConfigRow };
|
|
148
|
+
|
|
118
149
|
// ponytail: one full candidate scan — config rows are operator-scale
|
|
119
150
|
// (tenants × encrypted keys), cursor pagination when that ever changes.
|
|
120
|
-
//
|
|
121
|
-
// checks
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
151
|
+
// Bucketed by tenant so each row keeps its bucket's tenant attached
|
|
152
|
+
// (assertRowsTenant below re-checks that against the row's own field
|
|
153
|
+
// right before the write) and the shared loop's deadline/signal/failure
|
|
154
|
+
// checks still run between chunks, not only once.
|
|
155
|
+
let buckets: Map<TenantId, ConfigRow[]> | undefined;
|
|
156
|
+
async function loadBuckets(): Promise<Map<TenantId, ConfigRow[]>> {
|
|
157
|
+
if (buckets) return buckets;
|
|
158
|
+
const grouped = new Map<TenantId, ConfigRow[]>();
|
|
159
|
+
if (encryptedKeys.length > 0) {
|
|
160
|
+
const scanned = await scanDb
|
|
161
|
+
.acknowledgeCrossTenant("system-wide key rotation")
|
|
162
|
+
.selectMany<ConfigRow>(configValuesTable, { key: { in: encryptedKeys } });
|
|
163
|
+
for (const row of scanned) {
|
|
164
|
+
const tenantId = row.tenantId as TenantId; // @cast-boundary db-row
|
|
165
|
+
const bucket = grouped.get(tenantId);
|
|
166
|
+
if (bucket) bucket.push(row);
|
|
167
|
+
else grouped.set(tenantId, [row]);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
buckets = grouped;
|
|
171
|
+
return buckets;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function nextBatch(): Promise<readonly BucketedRow[]> {
|
|
175
|
+
const grouped = await loadBuckets();
|
|
176
|
+
for (const [tenantId, rows] of grouped) {
|
|
177
|
+
if (rows.length === 0) {
|
|
178
|
+
grouped.delete(tenantId);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const slice = rows.splice(0, SCAN_SLICE_SIZE);
|
|
182
|
+
if (rows.length === 0) grouped.delete(tenantId);
|
|
183
|
+
return slice.map((row) => ({ tenantId, row }));
|
|
133
184
|
}
|
|
134
|
-
|
|
135
|
-
return slice.splice(0, SCAN_SLICE_SIZE);
|
|
185
|
+
return [];
|
|
136
186
|
}
|
|
137
187
|
|
|
138
|
-
async function migrateRow(
|
|
188
|
+
async function migrateRow({
|
|
189
|
+
tenantId,
|
|
190
|
+
row,
|
|
191
|
+
}: BucketedRow): Promise<"migrated" | "skipped" | "failed"> {
|
|
139
192
|
if (row.value === null || row.value === undefined) return "skipped";
|
|
140
193
|
const classification = classifyStoredEnvelope(row.value, targetVersion);
|
|
141
194
|
if (classification === "current") {
|
|
@@ -154,7 +207,12 @@ export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void>
|
|
|
154
207
|
return "failed";
|
|
155
208
|
}
|
|
156
209
|
|
|
157
|
-
|
|
210
|
+
// Re-verify the row's own tenantId against the bucket it was scanned
|
|
211
|
+
// into, right before the write (framework#2072) — a mismatch here
|
|
212
|
+
// fails this row only (via onRowError below), it does not abort the
|
|
213
|
+
// whole job.
|
|
214
|
+
sdbFor(tenantId).assertRowsTenant([row], "tenantId");
|
|
215
|
+
|
|
158
216
|
const plaintext = await cipher.decrypt(row.value, { tenantId });
|
|
159
217
|
const reencrypted = await cipher.encrypt(plaintext, { tenantId });
|
|
160
218
|
|
|
@@ -177,13 +235,13 @@ export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void>
|
|
|
177
235
|
return "migrated";
|
|
178
236
|
}
|
|
179
237
|
|
|
180
|
-
const outcome = await runChunkedMigration<
|
|
238
|
+
const outcome = await runChunkedMigration<BucketedRow>({
|
|
181
239
|
nextBatch,
|
|
182
240
|
migrateRow,
|
|
183
241
|
maxFailures,
|
|
184
242
|
deadlineAt: deadline,
|
|
185
243
|
signal: ctx.signal,
|
|
186
|
-
onRowError: (row, err) => {
|
|
244
|
+
onRowError: ({ row }, err) => {
|
|
187
245
|
ctx.log?.warn?.(`[config:reencrypt] failed to re-encrypt row ${row.id}`, { err });
|
|
188
246
|
},
|
|
189
247
|
});
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
isEncryptedAtRest,
|
|
7
7
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
8
|
import { z } from "zod";
|
|
9
|
-
import { requireConfigResolver } from "../feature";
|
|
9
|
+
import { requireConfigResolver, requireSystemDb } from "../feature";
|
|
10
10
|
import { MASKED, redactInheritedCascade, shouldRedactInherited } from "../read-redaction";
|
|
11
11
|
import { hasConfigAccess } from "../write-helpers";
|
|
12
12
|
|
|
@@ -16,7 +16,7 @@ export const valuesQuery = defineQueryHandler({
|
|
|
16
16
|
// Per-key read access enforced via hasConfigAccess inside the handler.
|
|
17
17
|
access: { openToAll: true },
|
|
18
18
|
handler: async (query, ctx) => {
|
|
19
|
-
const db = ctx.
|
|
19
|
+
const db = requireSystemDb(ctx, "config:query:values", query.user.tenantId);
|
|
20
20
|
const registry = ctx.registry;
|
|
21
21
|
const resolver = requireConfigResolver(ctx, "config:query:values");
|
|
22
22
|
|
|
@@ -15,13 +15,32 @@ describe("delivery screens + handler access alignment", () => {
|
|
|
15
15
|
const delivery = createDeliveryFeature();
|
|
16
16
|
const screen = delivery.screens[DELIVERY_LOG_SCREEN_ID];
|
|
17
17
|
expect(screen?.type).toBe("custom");
|
|
18
|
-
|
|
19
|
-
expect(screen.access.roles).toEqual(access.admin);
|
|
20
|
-
}
|
|
18
|
+
expect(rolesOf(screen?.access)).toEqual([...access.admin]);
|
|
21
19
|
});
|
|
22
20
|
|
|
23
21
|
test("delivery log handler shares access.admin", () => {
|
|
24
22
|
const delivery = createDeliveryFeature();
|
|
25
23
|
expect(rolesOf(delivery.queryHandlers["log"]?.access)).toEqual([...access.admin]);
|
|
26
24
|
});
|
|
25
|
+
|
|
26
|
+
test("access option narrows the delivery-log screen and its log handler together (#2033)", () => {
|
|
27
|
+
const delivery = createDeliveryFeature({ access: access.systemAdmin });
|
|
28
|
+
const screen = delivery.screens[DELIVERY_LOG_SCREEN_ID];
|
|
29
|
+
expect(rolesOf(screen?.access)).toEqual([...access.systemAdmin]);
|
|
30
|
+
expect(rolesOf(delivery.queryHandlers["log"]?.access)).toEqual([...access.systemAdmin]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("access option leaves the preferences handler openToAll — that's per-user, not an admin surface", () => {
|
|
34
|
+
const delivery = createDeliveryFeature({ access: access.systemAdmin });
|
|
35
|
+
const preferencesAccess = delivery.queryHandlers["preferences"]?.access;
|
|
36
|
+
expect(
|
|
37
|
+
preferencesAccess && "openToAll" in preferencesAccess && preferencesAccess.openToAll,
|
|
38
|
+
).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("boot-validates with a narrowed access option", () => {
|
|
42
|
+
expect(() =>
|
|
43
|
+
validateBoot([createDeliveryFeature({ access: access.systemAdmin })]),
|
|
44
|
+
).not.toThrow();
|
|
45
|
+
});
|
|
27
46
|
});
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
unsafePushTables,
|
|
10
10
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
11
11
|
import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
12
|
-
import { DELIVERY_LOG_SCREEN_ID, DeliveryQueries } from "../constants";
|
|
12
|
+
import { DELIVERY_LOG_SCREEN_ID, DeliveryHandlers, DeliveryQueries } from "../constants";
|
|
13
13
|
import { createDeliveryFeature } from "../feature";
|
|
14
14
|
import { deliveryAttemptsTable, notificationPreferencesTable } from "../tables";
|
|
15
15
|
|
|
@@ -104,3 +104,46 @@ describe("delivery log tenant isolation", () => {
|
|
|
104
104
|
expect(res.rows.some((r) => r.tenantId === tenantB)).toBe(false);
|
|
105
105
|
});
|
|
106
106
|
});
|
|
107
|
+
|
|
108
|
+
describe("delivery preferences tenant isolation", () => {
|
|
109
|
+
// A userId is not tenant-unique — the same person can be a member of
|
|
110
|
+
// several tenants (tenant:write:add-member takes {userId, tenantId}).
|
|
111
|
+
// delivery runs in system-scope, so the preferences query handler must
|
|
112
|
+
// filter by tenantId too — otherwise setting a preference in tenant A
|
|
113
|
+
// leaks into (or gets read from) tenant B for the same userId.
|
|
114
|
+
test("user only sees their own tenant's preference for a shared userId", async () => {
|
|
115
|
+
const tenantA = testTenantId(31);
|
|
116
|
+
const tenantB = testTenantId(32);
|
|
117
|
+
const sharedUser = 41;
|
|
118
|
+
const userInTenantA = createTestUser({ id: sharedUser, roles: ["User"], tenantId: tenantA });
|
|
119
|
+
const userInTenantB = createTestUser({ id: sharedUser, roles: ["User"], tenantId: tenantB });
|
|
120
|
+
expect(userInTenantA.id).toBe(userInTenantB.id);
|
|
121
|
+
|
|
122
|
+
await stack.http.writeOk(
|
|
123
|
+
DeliveryHandlers.setPreference,
|
|
124
|
+
{ notificationType: "welcome", channel: "email", enabled: false },
|
|
125
|
+
userInTenantA,
|
|
126
|
+
);
|
|
127
|
+
await stack.http.writeOk(
|
|
128
|
+
DeliveryHandlers.setPreference,
|
|
129
|
+
{ notificationType: "welcome", channel: "push", enabled: false },
|
|
130
|
+
userInTenantB,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const resA = await stack.http.queryOk<{ rows: readonly { channel: string }[] }>(
|
|
134
|
+
DeliveryQueries.preferences,
|
|
135
|
+
{},
|
|
136
|
+
userInTenantA,
|
|
137
|
+
);
|
|
138
|
+
expect(resA.rows.length).toBeGreaterThan(0);
|
|
139
|
+
expect(resA.rows.every((r) => r.channel === "email")).toBe(true);
|
|
140
|
+
|
|
141
|
+
const resB = await stack.http.queryOk<{ rows: readonly { channel: string }[] }>(
|
|
142
|
+
DeliveryQueries.preferences,
|
|
143
|
+
{},
|
|
144
|
+
userInTenantB,
|
|
145
|
+
);
|
|
146
|
+
expect(resB.rows.length).toBeGreaterThan(0);
|
|
147
|
+
expect(resB.rows.every((r) => r.channel === "push")).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { deleteMany, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
3
3
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
buildEntityTable,
|
|
6
|
+
createEventStoreExecutor,
|
|
7
|
+
createTenantDb,
|
|
8
|
+
createUncheckedSystemDb,
|
|
9
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
5
10
|
import {
|
|
6
11
|
createEntity,
|
|
7
12
|
createTextField,
|
|
8
13
|
defineFeature,
|
|
9
14
|
defineWriteHandler,
|
|
15
|
+
type JobContext,
|
|
10
16
|
type NotifyFn,
|
|
11
17
|
qn,
|
|
18
|
+
type TenantId,
|
|
12
19
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
13
20
|
import { createJobRunner, type JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
|
|
14
21
|
import {
|
|
@@ -16,6 +23,7 @@ import {
|
|
|
16
23
|
setupTestStack,
|
|
17
24
|
type TestStack,
|
|
18
25
|
TestUsers,
|
|
26
|
+
testTenantId,
|
|
19
27
|
unsafePushTables,
|
|
20
28
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
21
29
|
import { z } from "zod";
|
|
@@ -39,7 +47,7 @@ import { tenantEntity } from "../../tenant/schema/tenant";
|
|
|
39
47
|
import { DeliveryHandlers, DeliveryJobs, DeliveryQueries } from "../constants";
|
|
40
48
|
import { collectChannels, createDeliveryService } from "../delivery-service";
|
|
41
49
|
import { createDeliveryFeature } from "../feature";
|
|
42
|
-
import { deliveryRenderJob } from "../jobs";
|
|
50
|
+
import { deliveryRenderJob, deliverySendJob } from "../jobs";
|
|
43
51
|
import { deliveryAttemptsTable, notificationPreferencesTable } from "../tables";
|
|
44
52
|
import { createDeliveryTestContext } from "../testing";
|
|
45
53
|
import type { DeliveryService } from "../types";
|
|
@@ -1501,6 +1509,14 @@ describe("flow 17: async render→send pipeline", () => {
|
|
|
1501
1509
|
expect(emailRows.some((r) => r["status"] === "sent")).toBe(true);
|
|
1502
1510
|
});
|
|
1503
1511
|
|
|
1512
|
+
// Mirrors what job-runner.ts's handleJob really builds for a
|
|
1513
|
+
// r.systemScope()'d job (framework#2105) — a real TenantDb wrapped by
|
|
1514
|
+
// createUncheckedSystemDb, not a mock, so requireTenantScopedDeps'
|
|
1515
|
+
// assertTenantMatch() call exercises the actual production code path.
|
|
1516
|
+
function makeSystemDb(tenantId: TenantId) {
|
|
1517
|
+
return createUncheckedSystemDb(createTenantDb(db, tenantId, "system"));
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1504
1520
|
test("delivery.render on a channel without a render step records failed and does not dispatch send", async () => {
|
|
1505
1521
|
const { runner, dispatched } = makeStubRunner();
|
|
1506
1522
|
const attemptId = "00000000-0000-4000-8000-0000000000aa";
|
|
@@ -1516,7 +1532,13 @@ describe("flow 17: async render→send pipeline", () => {
|
|
|
1516
1532
|
priority: "normal",
|
|
1517
1533
|
message: { notificationType: "app:notify:render-isolation", title: "X" },
|
|
1518
1534
|
},
|
|
1519
|
-
|
|
1535
|
+
// @cast-boundary test-seam — job throws before touching systemUser/log/write/queryAs
|
|
1536
|
+
{
|
|
1537
|
+
db,
|
|
1538
|
+
registry: stack.registry,
|
|
1539
|
+
jobRunner: runner,
|
|
1540
|
+
systemDb: makeSystemDb(admin.tenantId),
|
|
1541
|
+
} as unknown as JobContext,
|
|
1520
1542
|
),
|
|
1521
1543
|
).rejects.toThrow(/no render step/);
|
|
1522
1544
|
|
|
@@ -1527,6 +1549,77 @@ describe("flow 17: async render→send pipeline", () => {
|
|
|
1527
1549
|
});
|
|
1528
1550
|
expect(rows.some((r) => r["status"] === "failed")).toBe(true);
|
|
1529
1551
|
});
|
|
1552
|
+
|
|
1553
|
+
// The regression this diff exists to prevent: a payload.tenantId that
|
|
1554
|
+
// doesn't match the systemDb's own tenant must be rejected, not silently
|
|
1555
|
+
// used to read/write another tenant's stream (what ctx.db without
|
|
1556
|
+
// assertTenantMatch would have allowed).
|
|
1557
|
+
test("job handlers throw when payload.tenantId doesn't match ctx.systemDb's tenant", async () => {
|
|
1558
|
+
const { runner, dispatched } = makeStubRunner();
|
|
1559
|
+
const foreignTenantId = testTenantId(999);
|
|
1560
|
+
const attemptId = "00000000-0000-4000-8000-0000000000cc";
|
|
1561
|
+
// @cast-boundary test-seam — mirrors job-runner.ts's real systemDb shape
|
|
1562
|
+
const ctxWithMismatchedSystemDb = {
|
|
1563
|
+
db,
|
|
1564
|
+
registry: stack.registry,
|
|
1565
|
+
jobRunner: runner,
|
|
1566
|
+
systemDb: makeSystemDb(admin.tenantId),
|
|
1567
|
+
} as unknown as JobContext;
|
|
1568
|
+
|
|
1569
|
+
await expect(
|
|
1570
|
+
deliveryRenderJob(
|
|
1571
|
+
{
|
|
1572
|
+
channelName: "email",
|
|
1573
|
+
address: testEmail(asyncRecipient),
|
|
1574
|
+
tenantId: foreignTenantId,
|
|
1575
|
+
recipientId: String(asyncRecipient),
|
|
1576
|
+
notificationType: "app:notify:cross-tenant",
|
|
1577
|
+
deliveryAttemptId: attemptId,
|
|
1578
|
+
priority: "normal",
|
|
1579
|
+
message: { notificationType: "app:notify:cross-tenant", title: "X" },
|
|
1580
|
+
},
|
|
1581
|
+
ctxWithMismatchedSystemDb,
|
|
1582
|
+
),
|
|
1583
|
+
).rejects.toThrow(/tenant self-check failed/);
|
|
1584
|
+
|
|
1585
|
+
expect(dispatched).toHaveLength(0);
|
|
1586
|
+
const rows = await selectMany(db, deliveryAttemptsTable, {
|
|
1587
|
+
notificationType: "app:notify:cross-tenant",
|
|
1588
|
+
});
|
|
1589
|
+
expect(rows).toHaveLength(0);
|
|
1590
|
+
});
|
|
1591
|
+
|
|
1592
|
+
// Unreachable via real dispatch (job-runner.ts only omits systemDb for jobs
|
|
1593
|
+
// whose feature is NOT r.systemScope()'d, and delivery always is) — this
|
|
1594
|
+
// exercises requireTenantScopedDeps' own defensive guard directly, the
|
|
1595
|
+
// same way the missing-render-step case above exercises resolveChannel's.
|
|
1596
|
+
test("job handlers throw when ctx.systemDb is missing on a system-scoped job", async () => {
|
|
1597
|
+
const { runner } = makeStubRunner();
|
|
1598
|
+
const attemptId = "00000000-0000-4000-8000-0000000000bb";
|
|
1599
|
+
const payload = {
|
|
1600
|
+
channelName: "email",
|
|
1601
|
+
address: testEmail(asyncRecipient),
|
|
1602
|
+
tenantId: admin.tenantId,
|
|
1603
|
+
recipientId: String(asyncRecipient),
|
|
1604
|
+
notificationType: "app:notify:missing-systemdb",
|
|
1605
|
+
deliveryAttemptId: attemptId,
|
|
1606
|
+
priority: "normal" as const,
|
|
1607
|
+
message: { notificationType: "app:notify:missing-systemdb", title: "X" },
|
|
1608
|
+
};
|
|
1609
|
+
// @cast-boundary test-seam — no systemDb, unlike what job-runner.ts actually wires
|
|
1610
|
+
const ctxWithoutSystemDb = {
|
|
1611
|
+
db,
|
|
1612
|
+
registry: stack.registry,
|
|
1613
|
+
jobRunner: runner,
|
|
1614
|
+
} as unknown as JobContext;
|
|
1615
|
+
|
|
1616
|
+
await expect(deliveryRenderJob(payload, ctxWithoutSystemDb)).rejects.toThrow(
|
|
1617
|
+
/ctx\.systemDb missing/,
|
|
1618
|
+
);
|
|
1619
|
+
await expect(deliverySendJob(payload, ctxWithoutSystemDb)).rejects.toThrow(
|
|
1620
|
+
/ctx\.systemDb missing/,
|
|
1621
|
+
);
|
|
1622
|
+
});
|
|
1530
1623
|
});
|
|
1531
1624
|
|
|
1532
1625
|
// --- Flow 18: priority maps onto the BullMQ job priority ---
|
package/src/delivery/feature.ts
CHANGED
|
@@ -19,7 +19,17 @@ import {
|
|
|
19
19
|
notificationPreferencesTable,
|
|
20
20
|
} from "./tables";
|
|
21
21
|
|
|
22
|
-
export
|
|
22
|
+
export type DeliveryFeatureOptions = {
|
|
23
|
+
// Access gate for the delivery-log screen and the `log` query it reads —
|
|
24
|
+
// both move together so the nav entry a role sees always matches a
|
|
25
|
+
// callable handler (#2033). Default access.admin includes TenantAdmin,
|
|
26
|
+
// who in a line-of-business app is the operator, not the platform — pass
|
|
27
|
+
// access.systemAdmin to keep this platform-only and out of their nav.
|
|
28
|
+
readonly access?: readonly string[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function createDeliveryFeature(options?: DeliveryFeatureOptions): FeatureDefinition {
|
|
32
|
+
const resolvedAccess = options?.access ?? access.admin;
|
|
23
33
|
return defineFeature("delivery", (r) => {
|
|
24
34
|
r.describe(
|
|
25
35
|
"The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). It stores per-user channel preferences in the `notification-preference` entity, logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting \u2014 add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything.",
|
|
@@ -118,7 +128,7 @@ export function createDeliveryFeature(): FeatureDefinition {
|
|
|
118
128
|
};
|
|
119
129
|
|
|
120
130
|
const queries = {
|
|
121
|
-
log: r.queryHandler(logQuery),
|
|
131
|
+
log: r.queryHandler({ ...logQuery, access: { roles: resolvedAccess } }),
|
|
122
132
|
preferences: r.queryHandler(preferencesQuery),
|
|
123
133
|
};
|
|
124
134
|
|
|
@@ -126,7 +136,7 @@ export function createDeliveryFeature(): FeatureDefinition {
|
|
|
126
136
|
id: DELIVERY_LOG_SCREEN_ID,
|
|
127
137
|
type: "custom",
|
|
128
138
|
renderer: { react: { __component: "DeliveryLogScreen" } },
|
|
129
|
-
access: { roles:
|
|
139
|
+
access: { roles: resolvedAccess },
|
|
130
140
|
});
|
|
131
141
|
r.nav({
|
|
132
142
|
id: "delivery-log",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { decryptStoredPii } from "../../shared";
|
|
5
6
|
import { deliveryAttemptsTable } from "../tables";
|
|
@@ -11,11 +12,16 @@ export const logQuery = defineQueryHandler({
|
|
|
11
12
|
}),
|
|
12
13
|
access: { roles: access.admin },
|
|
13
14
|
handler: async (query, ctx) => {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
if (!ctx.systemDb) {
|
|
16
|
+
throw new InternalError({ message: "delivery log handler requires ctx.systemDb" });
|
|
17
|
+
}
|
|
18
|
+
// delivery is r.systemScope()'d, so the TenantDb is system-mode (reads
|
|
19
|
+
// unfiltered). assertTenantMatch is a self-check on the caller's own
|
|
20
|
+
// tenantId, not a query filter — it returns that same unfiltered db, so
|
|
21
|
+
// the explicit `where` below still does the actual tenant scoping.
|
|
22
|
+
const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
|
|
17
23
|
const rows = await selectMany(
|
|
18
|
-
|
|
24
|
+
db,
|
|
19
25
|
deliveryAttemptsTable,
|
|
20
26
|
{ tenantId: query.user.tenantId },
|
|
21
27
|
{
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { notificationPreferencesTable } from "../tables";
|
|
5
6
|
|
|
@@ -8,7 +9,19 @@ export const preferencesQuery = defineQueryHandler({
|
|
|
8
9
|
schema: z.object({}),
|
|
9
10
|
access: { openToAll: true },
|
|
10
11
|
handler: async (query, ctx) => {
|
|
11
|
-
|
|
12
|
+
// delivery runs in system-scope, so ctx.db is not auto-tenant-filtered —
|
|
13
|
+
// userId alone is not tenant-unique, so a userId-only filter here leaked preferences cross-tenant.
|
|
14
|
+
if (!ctx.systemDb) {
|
|
15
|
+
throw new InternalError({
|
|
16
|
+
message: "preferences: ctx.systemDb missing on a system-scoped handler",
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
|
|
20
|
+
|
|
21
|
+
const rows = await selectMany(db, notificationPreferencesTable, {
|
|
22
|
+
tenantId: query.user.tenantId,
|
|
23
|
+
userId: query.user.id,
|
|
24
|
+
});
|
|
12
25
|
|
|
13
26
|
return { rows };
|
|
14
27
|
},
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import { upsertPreference } from "../upsert-preference";
|
|
4
5
|
|
|
@@ -15,7 +16,17 @@ export const setPreferenceWrite = defineWriteHandler({
|
|
|
15
16
|
const { notificationType, channel, enabled } = event.payload;
|
|
16
17
|
const { id: userId, tenantId } = event.user;
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
// delivery runs in system-scope (feature.ts r.systemScope()) — ctx.systemDb
|
|
20
|
+
// is guaranteed there. No client-supplied userId exists on this payload;
|
|
21
|
+
// assertTenantMatch is the enforcement path that keeps it that way.
|
|
22
|
+
if (!ctx.systemDb) {
|
|
23
|
+
throw new InternalError({
|
|
24
|
+
message: "setPreference: ctx.systemDb missing on a system-scoped handler",
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
const db = ctx.systemDb.assertTenantMatch(tenantId);
|
|
28
|
+
|
|
29
|
+
const result = await upsertPreference(db, event.user, {
|
|
19
30
|
tenantId,
|
|
20
31
|
userId,
|
|
21
32
|
notificationType,
|
package/src/delivery/index.ts
CHANGED
|
@@ -15,7 +15,7 @@ export {
|
|
|
15
15
|
type KillSwitchResolver,
|
|
16
16
|
type RateLimitConfig,
|
|
17
17
|
} from "./delivery-service";
|
|
18
|
-
export { createDeliveryFeature } from "./feature";
|
|
18
|
+
export { createDeliveryFeature, type DeliveryFeatureOptions } from "./feature";
|
|
19
19
|
export {
|
|
20
20
|
deliveryAttemptsTable,
|
|
21
21
|
notificationPreferenceEntity,
|