@cosmicdrift/kumiko-bundled-features 0.198.0 → 0.199.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/src/compliance-profiles/__tests__/compliance-profiles.integration.test.ts +87 -0
- package/src/compliance-profiles/feature.ts +2 -2
- package/src/compliance-profiles/handlers/needs-profile.query.ts +52 -32
- package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +185 -0
- package/src/config/__tests__/require-system-db.test.ts +46 -0
- package/src/config/feature.ts +28 -0
- package/src/config/handlers/cascade.query.ts +2 -2
- package/src/config/handlers/readiness.query.ts +19 -6
- package/src/config/handlers/reencrypt.job.ts +79 -21
- package/src/config/handlers/values.query.ts +2 -2
- package/src/delivery/__tests__/delivery-security.integration.test.ts +44 -1
- package/src/delivery/__tests__/delivery.integration.test.ts +94 -3
- package/src/delivery/handlers/log.query.ts +10 -4
- package/src/delivery/handlers/preferences.query.ts +14 -1
- package/src/delivery/handlers/set-preference.write.ts +12 -1
- package/src/delivery/jobs.ts +20 -10
- package/src/feature-toggles/__tests__/acknowledge-cross-tenant.test.ts +23 -0
- package/src/feature-toggles/constants.ts +6 -0
- package/src/feature-toggles/handlers/list.query.ts +12 -1
- package/src/feature-toggles/handlers/registered.query.ts +12 -1
- package/src/feature-toggles/handlers/set.write.ts +18 -3
- package/src/jobs/__tests__/jobs-feature.integration.test.ts +57 -0
- package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +12 -3
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +39 -0
- package/src/jobs/handlers/detail.query.ts +7 -1
- package/src/jobs/handlers/list.query.ts +8 -1
- package/src/jobs/handlers/projection-rebuild.job.ts +6 -3
- package/src/jobs/handlers/reindex-entity.job.ts +14 -10
- package/src/jobs/handlers/retry.write.ts +7 -1
- package/src/readiness/handlers/status.query.ts +2 -1
- package/src/tenant/handlers/update.write.ts +30 -5
- package/src/user/__tests__/user.integration.test.ts +21 -0
- package/src/user/handlers/create.write.ts +10 -3
- package/src/user/handlers/find-for-auth.query.ts +9 -1
- package/src/user/handlers/me.query.ts +10 -1
- package/src/user/handlers/update.write.ts +12 -2
|
@@ -9,6 +9,7 @@ import { Temporal } from "temporal-polyfill";
|
|
|
9
9
|
import { z } from "zod";
|
|
10
10
|
import {
|
|
11
11
|
FEATURE_TOGGLE_AGGREGATE_TYPE,
|
|
12
|
+
FEATURE_TOGGLE_CROSS_TENANT_REASON,
|
|
12
13
|
FEATURE_TOGGLE_SET_EVENT_NAME,
|
|
13
14
|
FeatureToggleErrors,
|
|
14
15
|
} from "../constants";
|
|
@@ -53,6 +54,20 @@ export function createSetWriteHandler(getRuntime: (() => GlobalFeatureToggleRunt
|
|
|
53
54
|
);
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
// Guard 0.5: feature-toggles is r.systemScope() (feature.ts), so
|
|
58
|
+
// ctx.systemDb is always populated by buildHandlerContext for this
|
|
59
|
+
// handler — the `?` on the type is a HandlerContext-wide optionality,
|
|
60
|
+
// not a real runtime possibility here. Guard anyway instead of a
|
|
61
|
+
// non-null assertion: an app that ever un-declares r.systemScope()
|
|
62
|
+
// gets an actionable error instead of a silent `undefined.acknowledgeCrossTenant`.
|
|
63
|
+
if (!ctx.systemDb) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
"[feature-toggles] set-handler requires ctx.systemDb — the feature-toggles " +
|
|
66
|
+
"feature must stay r.systemScope() (see feature.ts).",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
const db = ctx.systemDb.acknowledgeCrossTenant(FEATURE_TOGGLE_CROSS_TENANT_REASON);
|
|
70
|
+
|
|
56
71
|
// Guard 1: featureName must be a registered feature. Otherwise we'd
|
|
57
72
|
// pile up orphan rows from typos that the gate would silently apply
|
|
58
73
|
// (if someone ever added a feature with that name later).
|
|
@@ -90,7 +105,7 @@ export function createSetWriteHandler(getRuntime: (() => GlobalFeatureToggleRunt
|
|
|
90
105
|
updatedBy: string;
|
|
91
106
|
};
|
|
92
107
|
const [existing] = await selectMany<StateRow>(
|
|
93
|
-
|
|
108
|
+
db,
|
|
94
109
|
globalFeatureStateTable,
|
|
95
110
|
{ featureName },
|
|
96
111
|
{ limit: 1 },
|
|
@@ -100,7 +115,7 @@ export function createSetWriteHandler(getRuntime: (() => GlobalFeatureToggleRunt
|
|
|
100
115
|
|
|
101
116
|
if (!existing) {
|
|
102
117
|
// First-time override: insert.
|
|
103
|
-
await insertOne(
|
|
118
|
+
await insertOne(db, globalFeatureStateTable, {
|
|
104
119
|
featureName,
|
|
105
120
|
enabled,
|
|
106
121
|
version: 1,
|
|
@@ -111,7 +126,7 @@ export function createSetWriteHandler(getRuntime: (() => GlobalFeatureToggleRunt
|
|
|
111
126
|
// Upsert with optimistic lock. Two operators flipping the same
|
|
112
127
|
// toggle simultaneously is rare but possible — the version-WHERE
|
|
113
128
|
// ensures only one wins; the loser sees VersionConflictError.
|
|
114
|
-
const updated = await
|
|
129
|
+
const updated = await db.updateMany(
|
|
115
130
|
globalFeatureStateTable,
|
|
116
131
|
{
|
|
117
132
|
enabled,
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type TestDb,
|
|
16
16
|
type TestRedis,
|
|
17
17
|
TestUsers,
|
|
18
|
+
testTenantId,
|
|
18
19
|
unsafePushTables,
|
|
19
20
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
20
21
|
import { sleep } from "@cosmicdrift/kumiko-framework/testing";
|
|
@@ -35,6 +36,14 @@ let jobRunner: JobRunner;
|
|
|
35
36
|
|
|
36
37
|
const systemAdmin = TestUsers.systemAdmin;
|
|
37
38
|
const normalUser = createTestUser({ id: 2, roles: ["User"] });
|
|
39
|
+
// jobs is platform-wide, cross-tenant-by-design monitoring (fw#2077) — this
|
|
40
|
+
// SystemAdmin lives under a different tenant than `systemAdmin` above, so
|
|
41
|
+
// scenario 6 can prove list/details/retry see runs across tenant boundaries.
|
|
42
|
+
const otherTenantSystemAdmin = createTestUser({
|
|
43
|
+
id: 20,
|
|
44
|
+
tenantId: testTenantId(2),
|
|
45
|
+
roles: ["SystemAdmin"],
|
|
46
|
+
});
|
|
38
47
|
const JWT_SECRET = "jobs-feature-test-secret-minimum-32-chars!!";
|
|
39
48
|
|
|
40
49
|
// Track job executions
|
|
@@ -321,6 +330,54 @@ describe("scenario 5: retry failed job", () => {
|
|
|
321
330
|
});
|
|
322
331
|
});
|
|
323
332
|
|
|
333
|
+
// --- Scenario 6: cross-tenant visibility (ctx.systemDb.acknowledgeCrossTenant) ---
|
|
334
|
+
|
|
335
|
+
describe("scenario 6: cross-tenant job monitoring", () => {
|
|
336
|
+
test("SystemAdmin from a different tenant sees runs triggered under another tenant", async () => {
|
|
337
|
+
const triggerResult = await write(systemAdmin, JobHandlers.trigger, {
|
|
338
|
+
jobName: "app:job:sync-data",
|
|
339
|
+
payload: { source: "cross-tenant-check" },
|
|
340
|
+
});
|
|
341
|
+
expect(triggerResult.isSuccess).toBe(true);
|
|
342
|
+
const bullJobId = triggerResult.data.bullJobId;
|
|
343
|
+
await sleep(1000);
|
|
344
|
+
|
|
345
|
+
const listResult = await query(otherTenantSystemAdmin, JobQueries.list, {
|
|
346
|
+
jobName: "app:job:sync-data",
|
|
347
|
+
});
|
|
348
|
+
const run = listResult.data.rows.find(
|
|
349
|
+
(r: Record<string, unknown>) => r["bullJobId"] === bullJobId,
|
|
350
|
+
);
|
|
351
|
+
expect(run).toBeDefined();
|
|
352
|
+
|
|
353
|
+
const detailResult = await query(otherTenantSystemAdmin, JobQueries.details, {
|
|
354
|
+
runId: run.id,
|
|
355
|
+
});
|
|
356
|
+
expect(detailResult.data).not.toBeNull();
|
|
357
|
+
expect(detailResult.data.id).toBe(run.id);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("SystemAdmin from a different tenant can retry a failed run triggered under another tenant", async () => {
|
|
361
|
+
const triggerResult = await write(systemAdmin, JobHandlers.trigger, {
|
|
362
|
+
jobName: "app:job:failing-import",
|
|
363
|
+
});
|
|
364
|
+
expect(triggerResult.isSuccess).toBe(true);
|
|
365
|
+
await sleep(1500);
|
|
366
|
+
|
|
367
|
+
const listResult = await query(otherTenantSystemAdmin, JobQueries.list, {
|
|
368
|
+
jobName: "app:job:failing-import",
|
|
369
|
+
status: "failed",
|
|
370
|
+
});
|
|
371
|
+
const failedRunId = listResult.data.rows[0].id;
|
|
372
|
+
|
|
373
|
+
const retryResult = await write(otherTenantSystemAdmin, JobHandlers.retry, {
|
|
374
|
+
runId: failedRunId,
|
|
375
|
+
});
|
|
376
|
+
expect(retryResult.isSuccess).toBe(true);
|
|
377
|
+
expect(retryResult.data.retriedFromRunId).toBe(failedRunId);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
324
381
|
// --- Access control ---
|
|
325
382
|
|
|
326
383
|
describe("access control", () => {
|
|
@@ -153,9 +153,18 @@ describe("projection-rebuild job (jobs feature composed)", () => {
|
|
|
153
153
|
for (let i = 0; i < 40 && (await getCount()) !== 2; i++) await sleep(200);
|
|
154
154
|
expect(await getCount()).toBe(2);
|
|
155
155
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
156
|
+
// getCount()==2 only proves rebuildProjection's own writes landed — the
|
|
157
|
+
// run-completed event (job-run-logger's onJobComplete) is a separate
|
|
158
|
+
// async append that starts only after the handler returns, so it can
|
|
159
|
+
// still be in flight here. Poll status too instead of racing it.
|
|
160
|
+
let runs: readonly { jobName: string; status: string }[] = [];
|
|
161
|
+
for (let i = 0; i < 40; i++) {
|
|
162
|
+
runs = await selectMany<{ jobName: string; status: string }>(db, jobRunsTable, {
|
|
163
|
+
jobName: PROJECTION_REBUILD_JOB,
|
|
164
|
+
});
|
|
165
|
+
if (runs.some((r) => r.status === "completed")) break;
|
|
166
|
+
await sleep(200);
|
|
167
|
+
}
|
|
159
168
|
expect(runs.length).toBeGreaterThanOrEqual(1);
|
|
160
169
|
expect(runs.some((r) => r.status === "completed")).toBe(true);
|
|
161
170
|
}, 30000);
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
buildEntityTable,
|
|
12
12
|
createEventStoreExecutor,
|
|
13
13
|
createTenantDb,
|
|
14
|
+
createUncheckedSystemDb,
|
|
14
15
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
15
16
|
import {
|
|
16
17
|
createEntity,
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
defineFeature,
|
|
19
20
|
type JobContext,
|
|
20
21
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
22
|
+
import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
|
|
21
23
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
22
24
|
import {
|
|
23
25
|
setupTestStack,
|
|
@@ -97,6 +99,7 @@ describe("reindexEntityJob", () => {
|
|
|
97
99
|
registry: stack.registry,
|
|
98
100
|
searchAdapter: stack.search,
|
|
99
101
|
systemUser: admin,
|
|
102
|
+
systemDb: createUncheckedSystemDb(createTenantDb(stack.db, admin.tenantId, "system")),
|
|
100
103
|
log: noopLogger,
|
|
101
104
|
triggeredBy: null,
|
|
102
105
|
...bridgeStub(),
|
|
@@ -137,6 +140,7 @@ describe("reindexEntityJob", () => {
|
|
|
137
140
|
registry: stack.registry,
|
|
138
141
|
searchAdapter: stack.search,
|
|
139
142
|
systemUser: admin,
|
|
143
|
+
systemDb: createUncheckedSystemDb(createTenantDb(stack.db, admin.tenantId, "system")),
|
|
140
144
|
log: noopLogger,
|
|
141
145
|
triggeredBy: null,
|
|
142
146
|
...bridgeStub(),
|
|
@@ -172,4 +176,39 @@ describe("reindexEntityJob", () => {
|
|
|
172
176
|
),
|
|
173
177
|
).rejects.toThrow(/searchAdapter/);
|
|
174
178
|
});
|
|
179
|
+
|
|
180
|
+
test("throws when ctx.systemDb is missing", async () => {
|
|
181
|
+
await expect(
|
|
182
|
+
reindexEntityJob(
|
|
183
|
+
{ entity: "widget" },
|
|
184
|
+
// @cast-boundary test-seam — deliberately no systemDb: proves the missing-systemDb throw
|
|
185
|
+
{
|
|
186
|
+
db: stack.db,
|
|
187
|
+
registry: stack.registry,
|
|
188
|
+
searchAdapter: stack.search,
|
|
189
|
+
systemUser: admin,
|
|
190
|
+
} as unknown as JobContext,
|
|
191
|
+
),
|
|
192
|
+
).rejects.toThrow(/systemDb/);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Honest scope: in production job-runner.ts derives both ctx.systemDb's
|
|
196
|
+
// tenantId and ctx.systemUser from the same per-dispatch value, so this
|
|
197
|
+
// mismatch can't occur live — it only proves the self-check is wired to
|
|
198
|
+
// the tenant that was actually resolved, catching future drift between
|
|
199
|
+
// the two, not a real cross-tenant hole.
|
|
200
|
+
test("fails closed when ctx.systemDb is scoped to a different tenant than ctx.systemUser", async () => {
|
|
201
|
+
const otherTenantId = TestUsers.otherTenant.tenantId;
|
|
202
|
+
const ctx: JobContext = {
|
|
203
|
+
db: stack.db,
|
|
204
|
+
registry: stack.registry,
|
|
205
|
+
searchAdapter: stack.search,
|
|
206
|
+
systemUser: admin,
|
|
207
|
+
systemDb: createUncheckedSystemDb(createTenantDb(stack.db, otherTenantId, "system")),
|
|
208
|
+
log: noopLogger,
|
|
209
|
+
triggeredBy: null,
|
|
210
|
+
...bridgeStub(),
|
|
211
|
+
};
|
|
212
|
+
await expect(reindexEntityJob({ entity: "widget" }, ctx)).rejects.toThrow(AccessDeniedError);
|
|
213
|
+
});
|
|
175
214
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { fetchOne, 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 { decryptStoredPii } from "../../shared";
|
|
5
6
|
import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
|
|
@@ -13,7 +14,12 @@ export const detailQuery = defineQueryHandler({
|
|
|
13
14
|
schema: z.object({ runId: z.uuid() }),
|
|
14
15
|
access: { roles: ["SystemAdmin"] },
|
|
15
16
|
handler: async (query, ctx) => {
|
|
16
|
-
|
|
17
|
+
if (!ctx.systemDb) {
|
|
18
|
+
throw new InternalError({
|
|
19
|
+
message: "jobs:query:details requires ctx.systemDb (feature must declare r.systemScope())",
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
const db = ctx.systemDb.acknowledgeCrossTenant("cross-tenant job monitoring");
|
|
17
23
|
|
|
18
24
|
const row = await fetchOne(db, jobRunsTable, { id: query.payload.runId });
|
|
19
25
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { selectMany, type WhereObject } 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 { decryptStoredPii } from "../../shared";
|
|
5
6
|
import { jobRunsTable } from "../job-run-table";
|
|
@@ -18,10 +19,16 @@ export const listQuery = defineQueryHandler({
|
|
|
18
19
|
}),
|
|
19
20
|
access: { roles: ["SystemAdmin"] },
|
|
20
21
|
handler: async (query, ctx) => {
|
|
22
|
+
if (!ctx.systemDb) {
|
|
23
|
+
throw new InternalError({
|
|
24
|
+
message: "jobs:query:list requires ctx.systemDb (feature must declare r.systemScope())",
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
const db = ctx.systemDb.acknowledgeCrossTenant("cross-tenant job monitoring");
|
|
21
28
|
const where: WhereObject = {};
|
|
22
29
|
if (query.payload.jobName) where["jobName"] = query.payload.jobName;
|
|
23
30
|
if (query.payload.status) where["status"] = query.payload.status;
|
|
24
|
-
const rows = await selectMany(
|
|
31
|
+
const rows = await selectMany(db, jobRunsTable, where, {
|
|
25
32
|
orderBy: { col: "id", direction: "desc" },
|
|
26
33
|
limit: query.payload.limit ?? 50,
|
|
27
34
|
});
|
|
@@ -15,10 +15,10 @@ export const projectionRebuildPayloadSchema = z.object({
|
|
|
15
15
|
|
|
16
16
|
export const projectionRebuildJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
|
|
17
17
|
const { projection, skipApplyErrors } = projectionRebuildPayloadSchema.parse(rawPayload);
|
|
18
|
-
if (!ctx.
|
|
18
|
+
if (!ctx.systemDb) {
|
|
19
19
|
throw new InternalError({
|
|
20
20
|
message:
|
|
21
|
-
"[jobs:projection-rebuild] ctx.
|
|
21
|
+
"[jobs:projection-rebuild] ctx.systemDb missing — is r.systemScope() still set on the jobs feature?",
|
|
22
22
|
});
|
|
23
23
|
}
|
|
24
24
|
if (!ctx.registry) {
|
|
@@ -27,7 +27,10 @@ export const projectionRebuildJob: JobHandlerFn = async (rawPayload, ctx): Promi
|
|
|
27
27
|
"[jobs:projection-rebuild] ctx.registry missing — job context requires the registry.",
|
|
28
28
|
});
|
|
29
29
|
}
|
|
30
|
-
|
|
30
|
+
// Global rebuild by design (streams every tenant's events, not just one) —
|
|
31
|
+
// acknowledgeCrossTenant documents that instead of the previous implicit
|
|
32
|
+
// ctx.db cast. .raw is the same DbConnection ctx.db carried before.
|
|
33
|
+
const db = ctx.systemDb.acknowledgeCrossTenant("global projection rebuild").raw as DbConnection; // @cast-boundary db-operator
|
|
31
34
|
const result = await rebuildProjection(projection, {
|
|
32
35
|
db,
|
|
33
36
|
registry: ctx.registry,
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
// just cron: one `jobs:write:trigger` call with { entity } re-runs once per
|
|
5
5
|
// active tenant.
|
|
6
6
|
|
|
7
|
-
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
8
7
|
import type { JobHandlerFn } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
8
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
10
9
|
import { reindexEntity } from "@cosmicdrift/kumiko-framework/search";
|
|
@@ -16,11 +15,6 @@ export const reindexEntityPayloadSchema = z.object({
|
|
|
16
15
|
|
|
17
16
|
export const reindexEntityJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
|
|
18
17
|
const { entity } = reindexEntityPayloadSchema.parse(rawPayload);
|
|
19
|
-
if (!ctx.db) {
|
|
20
|
-
throw new InternalError({
|
|
21
|
-
message: "[jobs:reindex-entity] ctx.db missing — job context requires a database connection.",
|
|
22
|
-
});
|
|
23
|
-
}
|
|
24
18
|
if (!ctx.registry) {
|
|
25
19
|
throw new InternalError({
|
|
26
20
|
message: "[jobs:reindex-entity] ctx.registry missing — job context requires the registry.",
|
|
@@ -33,14 +27,24 @@ export const reindexEntityJob: JobHandlerFn = async (rawPayload, ctx): Promise<v
|
|
|
33
27
|
});
|
|
34
28
|
}
|
|
35
29
|
// perTenant fan-out — the job-runner enqueues one child run per active
|
|
36
|
-
// tenant with
|
|
37
|
-
// manual dispatch outside the fan-out) has nothing scoped
|
|
38
|
-
|
|
30
|
+
// tenant with ctx.systemUser scoped to it; a run without one (e.g.
|
|
31
|
+
// misconfigured manual dispatch outside the fan-out) has nothing scoped
|
|
32
|
+
// to reindex. ctx._tenantId is never populated per-dispatch (only
|
|
33
|
+
// ctx.systemUser is), so it is not a valid fallback here.
|
|
34
|
+
const tenantId = ctx.systemUser?.tenantId;
|
|
39
35
|
if (tenantId === undefined) {
|
|
40
36
|
// skip: fired without a perTenant fan-out tenant — nothing scoped to reindex
|
|
41
37
|
return;
|
|
42
38
|
}
|
|
43
|
-
|
|
39
|
+
if (!ctx.systemDb) {
|
|
40
|
+
throw new InternalError({
|
|
41
|
+
message:
|
|
42
|
+
"[jobs:reindex-entity] ctx.systemDb missing — the owning feature must declare r.systemScope().",
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
// .raw bypasses tenant filtering, safe here only because reindexEntity
|
|
46
|
+
// filters `WHERE tenant_id = $1` itself with this same tenantId.
|
|
47
|
+
const db = ctx.systemDb.assertTenantMatch(tenantId).raw;
|
|
44
48
|
const result = await reindexEntity(db, ctx.registry, ctx.searchAdapter, entity, tenantId);
|
|
45
49
|
ctx.log?.info?.(
|
|
46
50
|
`[jobs:reindex-entity] tenant=${tenantId} reindexed ${entity}: ${result.indexedRows}/${result.scannedRows} rows indexed` +
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import {
|
|
4
|
+
InternalError,
|
|
4
5
|
NotFoundError,
|
|
5
6
|
UnprocessableError,
|
|
6
7
|
writeFailure,
|
|
@@ -24,7 +25,12 @@ export const retryWrite = defineWriteHandler({
|
|
|
24
25
|
schema: z.object({ runId: z.uuid() }),
|
|
25
26
|
access: { roles: ["SystemAdmin"] },
|
|
26
27
|
handler: async (event, ctx) => {
|
|
27
|
-
|
|
28
|
+
if (!ctx.systemDb) {
|
|
29
|
+
throw new InternalError({
|
|
30
|
+
message: "jobs:write:retry requires ctx.systemDb (feature must declare r.systemScope())",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
const db = ctx.systemDb.acknowledgeCrossTenant("cross-tenant job monitoring");
|
|
28
34
|
// @cast-boundary engine-payload — JobRunner attached by app-boot via ctx-extension
|
|
29
35
|
const jobRunner = ctx["jobRunner"] as JobRunner;
|
|
30
36
|
|
|
@@ -19,7 +19,7 @@ export const statusQuery = defineQueryHandler({
|
|
|
19
19
|
handler: async (query, ctx) => {
|
|
20
20
|
// One gate for both halves: required keys/secrets of provider-features
|
|
21
21
|
// count only while their provider is the selected one (r.extensionSelector).
|
|
22
|
-
const gate = await buildProviderSelectionGate(ctx, ReadinessQueries.status, query.user);
|
|
22
|
+
const gate = await buildProviderSelectionGate(ctx, ReadinessQueries.status, query.user, ctx.db);
|
|
23
23
|
// skipAccessFilter: das Verdict muss ALLE required Keys zählen — der
|
|
24
24
|
// Handler selbst ist TenantAdmin-gated, der Per-Key-Filter wäre hier
|
|
25
25
|
// eine ready:true-Lüge für SystemAdmin-gated Keys (277/1).
|
|
@@ -27,6 +27,7 @@ export const statusQuery = defineQueryHandler({
|
|
|
27
27
|
ctx,
|
|
28
28
|
ReadinessQueries.status,
|
|
29
29
|
query.user,
|
|
30
|
+
ctx.db,
|
|
30
31
|
gate,
|
|
31
32
|
{ skipAccessFilter: true },
|
|
32
33
|
);
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
1
|
+
import { createEventStoreExecutor, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
AccessDeniedError,
|
|
5
|
+
InternalError,
|
|
6
|
+
NotFoundError,
|
|
7
|
+
writeFailure,
|
|
8
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
4
9
|
import { z } from "zod";
|
|
5
10
|
import { tenantEntity, tenantTable } from "../schema/tenant";
|
|
6
11
|
|
|
@@ -18,11 +23,31 @@ export const updateWrite = defineWriteHandler({
|
|
|
18
23
|
}),
|
|
19
24
|
access: { roles: ["Admin", "SystemAdmin"] },
|
|
20
25
|
handler: async (event, ctx) => {
|
|
26
|
+
if (!ctx.systemDb) {
|
|
27
|
+
throw new InternalError({
|
|
28
|
+
message:
|
|
29
|
+
"tenant:write:update requires ctx.systemDb — is r.systemScope() still set on the tenant feature?",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
21
33
|
// "Admin" is tenant-scoped and must not touch another tenant's row —
|
|
22
34
|
// SystemAdmin is the platform-wide role and stays cross-tenant by design.
|
|
23
|
-
if (
|
|
24
|
-
|
|
35
|
+
if (event.user.roles.includes("SystemAdmin")) {
|
|
36
|
+
const db = ctx.systemDb.acknowledgeCrossTenant("SystemAdmin is platform-wide by design");
|
|
37
|
+
return crud.update(event.payload, event.user, db);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let db: TenantDb;
|
|
41
|
+
try {
|
|
42
|
+
db = ctx.systemDb.assertTenantMatch(event.payload.id);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
// Kept as tenant_not_found (not the underlying access_denied) so a
|
|
45
|
+
// cross-tenant Admin can't use the error to enumerate tenant existence.
|
|
46
|
+
if (err instanceof AccessDeniedError) {
|
|
47
|
+
return writeFailure(new NotFoundError("tenant", event.payload.id));
|
|
48
|
+
}
|
|
49
|
+
throw err;
|
|
25
50
|
}
|
|
26
|
-
return crud.update(event.payload, event.user,
|
|
51
|
+
return crud.update(event.payload, event.user, db);
|
|
27
52
|
},
|
|
28
53
|
});
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
3
|
+
import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
4
|
import {
|
|
4
5
|
createTestUser,
|
|
5
6
|
setupTestStack,
|
|
6
7
|
type TestStack,
|
|
7
8
|
TestUsers,
|
|
9
|
+
testTenantId,
|
|
8
10
|
unsafeCreateEntityTable,
|
|
9
11
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
10
12
|
import { expectErrorIncludes, resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
@@ -151,6 +153,25 @@ describe("scenario 2b: find-for-auth is system-only", () => {
|
|
|
151
153
|
expect(body).toContain("access_denied");
|
|
152
154
|
expect(body).not.toContain("must-never-leave-the-server");
|
|
153
155
|
});
|
|
156
|
+
|
|
157
|
+
// The "system" role reaches find-for-auth from any tenant — user rows are
|
|
158
|
+
// tenant-agnostic identity records, not scoped to the caller's own tenant.
|
|
159
|
+
test("system caller from a different tenant still resolves the user", async () => {
|
|
160
|
+
const created = await seedUser({
|
|
161
|
+
email: "cross-tenant@example.com",
|
|
162
|
+
displayName: "CrossTenant",
|
|
163
|
+
passwordHash: "cross-tenant-hash",
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const foreignSystemCaller = createSystemUser(testTenantId(99));
|
|
167
|
+
const result = await stack.http.queryOk<{ id: number } | null>(
|
|
168
|
+
UserQueries.findForAuth,
|
|
169
|
+
{ email: "cross-tenant@example.com" },
|
|
170
|
+
foreignSystemCaller,
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
expect(result).toMatchObject({ id: created.id });
|
|
174
|
+
});
|
|
154
175
|
});
|
|
155
176
|
|
|
156
177
|
// --- Scenario 3: user edits own profile, email/passwordHash are system-locked ---
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
-
import { ConflictError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
+
import { ConflictError, InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
5
5
|
import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { UserErrors } from "../constants";
|
|
@@ -35,7 +35,14 @@ export const createWrite = defineWriteHandler({
|
|
|
35
35
|
}),
|
|
36
36
|
access: { roles: ["system", "SystemAdmin"] },
|
|
37
37
|
handler: async (event, ctx) => {
|
|
38
|
-
|
|
38
|
+
if (!ctx.systemDb) {
|
|
39
|
+
throw new InternalError({ message: "user:create requires r.systemScope()" });
|
|
40
|
+
}
|
|
41
|
+
const db = ctx.systemDb.acknowledgeCrossTenant(
|
|
42
|
+
"user rows are tenant-agnostic identity records; uniqueness check and create need no tenant filter",
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const existing = await fetchOne<{ id: string }>(db, userTable, {
|
|
39
46
|
email: event.payload.email,
|
|
40
47
|
});
|
|
41
48
|
|
|
@@ -49,6 +56,6 @@ export const createWrite = defineWriteHandler({
|
|
|
49
56
|
);
|
|
50
57
|
}
|
|
51
58
|
|
|
52
|
-
return crud.create(event.payload, event.user,
|
|
59
|
+
return crud.create(event.payload, event.user, db);
|
|
53
60
|
},
|
|
54
61
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { fetchOne } 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 { userTable } from "../schema/user";
|
|
5
6
|
|
|
@@ -33,6 +34,13 @@ export const findForAuthQuery = defineQueryHandler({
|
|
|
33
34
|
? { email: query.payload.email }
|
|
34
35
|
: { id: query.payload.id as string }; // @cast-boundary engine-payload
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
if (!ctx.systemDb) {
|
|
38
|
+
throw new InternalError({ message: "user:find-for-auth requires r.systemScope()" });
|
|
39
|
+
}
|
|
40
|
+
const db = ctx.systemDb.acknowledgeCrossTenant(
|
|
41
|
+
"identity resolution for auth is intentionally cross-tenant",
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
return (await fetchOne(db, userTable, where)) ?? null;
|
|
37
45
|
},
|
|
38
46
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/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 { userEntity, userTable } from "../schema/user";
|
|
5
6
|
|
|
@@ -11,5 +12,13 @@ export const meQuery = defineQueryHandler({
|
|
|
11
12
|
name: "user:me",
|
|
12
13
|
schema: z.object({}),
|
|
13
14
|
access: { openToAll: true },
|
|
14
|
-
handler: async (query, ctx) =>
|
|
15
|
+
handler: async (query, ctx) => {
|
|
16
|
+
if (!ctx.systemDb) {
|
|
17
|
+
throw new InternalError({ message: "user:me requires r.systemScope()" });
|
|
18
|
+
}
|
|
19
|
+
const db = ctx.systemDb.acknowledgeCrossTenant(
|
|
20
|
+
"user rows are tenant-agnostic identity records; self-lookup by id needs no tenant filter",
|
|
21
|
+
);
|
|
22
|
+
return crud.detail({ id: query.user.id }, query.user, db); // @wrapper-known semantic-alias
|
|
23
|
+
},
|
|
15
24
|
});
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { access, defineWriteHandler, hasAccess } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
AccessDeniedError,
|
|
5
|
+
InternalError,
|
|
6
|
+
writeFailure,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
4
8
|
import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";
|
|
5
9
|
import { z } from "zod";
|
|
6
10
|
import { UserErrors } from "../constants";
|
|
@@ -51,6 +55,12 @@ export const updateWrite = defineWriteHandler({
|
|
|
51
55
|
}),
|
|
52
56
|
);
|
|
53
57
|
}
|
|
54
|
-
|
|
58
|
+
if (!ctx.systemDb) {
|
|
59
|
+
throw new InternalError({ message: "user:update requires r.systemScope()" });
|
|
60
|
+
}
|
|
61
|
+
const db = ctx.systemDb.acknowledgeCrossTenant(
|
|
62
|
+
"user rows are tenant-agnostic identity records; self/admin update needs no tenant filter",
|
|
63
|
+
);
|
|
64
|
+
return crud.update(event.payload, event.user, db);
|
|
55
65
|
},
|
|
56
66
|
});
|