@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.
Files changed (37) hide show
  1. package/package.json +7 -7
  2. package/src/compliance-profiles/__tests__/compliance-profiles.integration.test.ts +87 -0
  3. package/src/compliance-profiles/feature.ts +2 -2
  4. package/src/compliance-profiles/handlers/needs-profile.query.ts +52 -32
  5. package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +185 -0
  6. package/src/config/__tests__/require-system-db.test.ts +46 -0
  7. package/src/config/feature.ts +28 -0
  8. package/src/config/handlers/cascade.query.ts +2 -2
  9. package/src/config/handlers/readiness.query.ts +19 -6
  10. package/src/config/handlers/reencrypt.job.ts +79 -21
  11. package/src/config/handlers/values.query.ts +2 -2
  12. package/src/delivery/__tests__/delivery-security.integration.test.ts +44 -1
  13. package/src/delivery/__tests__/delivery.integration.test.ts +94 -3
  14. package/src/delivery/handlers/log.query.ts +10 -4
  15. package/src/delivery/handlers/preferences.query.ts +14 -1
  16. package/src/delivery/handlers/set-preference.write.ts +12 -1
  17. package/src/delivery/jobs.ts +20 -10
  18. package/src/feature-toggles/__tests__/acknowledge-cross-tenant.test.ts +23 -0
  19. package/src/feature-toggles/constants.ts +6 -0
  20. package/src/feature-toggles/handlers/list.query.ts +12 -1
  21. package/src/feature-toggles/handlers/registered.query.ts +12 -1
  22. package/src/feature-toggles/handlers/set.write.ts +18 -3
  23. package/src/jobs/__tests__/jobs-feature.integration.test.ts +57 -0
  24. package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +12 -3
  25. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +39 -0
  26. package/src/jobs/handlers/detail.query.ts +7 -1
  27. package/src/jobs/handlers/list.query.ts +8 -1
  28. package/src/jobs/handlers/projection-rebuild.job.ts +6 -3
  29. package/src/jobs/handlers/reindex-entity.job.ts +14 -10
  30. package/src/jobs/handlers/retry.write.ts +7 -1
  31. package/src/readiness/handlers/status.query.ts +2 -1
  32. package/src/tenant/handlers/update.write.ts +30 -5
  33. package/src/user/__tests__/user.integration.test.ts +21 -0
  34. package/src/user/handlers/create.write.ts +10 -3
  35. package/src/user/handlers/find-for-auth.query.ts +9 -1
  36. package/src/user/handlers/me.query.ts +10 -1
  37. 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
- ctx.db,
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(ctx.db, globalFeatureStateTable, {
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 ctx.db.updateMany(
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
- const runs = await selectMany<{ jobName: string; status: string }>(db, jobRunsTable, {
157
- jobName: PROJECTION_REBUILD_JOB,
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
- const db = ctx.db;
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(ctx.db, jobRunsTable, where, {
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.db) {
18
+ if (!ctx.systemDb) {
19
19
  throw new InternalError({
20
20
  message:
21
- "[jobs:projection-rebuild] ctx.db missing — job context requires a database connection.",
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
- const db = ctx.db as DbConnection; // @cast-boundary db-operator
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 _tenantId set; a run without one (e.g. misconfigured
37
- // manual dispatch outside the fan-out) has nothing scoped to reindex.
38
- const tenantId = ctx.systemUser?.tenantId ?? ctx._tenantId;
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
- const db = ctx.db as DbConnection; // @cast-boundary db-operator
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
- const db = ctx.db;
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 { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
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 (!event.user.roles.includes("SystemAdmin") && event.payload.id !== event.user.tenantId) {
24
- return writeFailure(new NotFoundError("tenant", event.payload.id));
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, ctx.db);
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
- const existing = await fetchOne<{ id: string }>(ctx.db, userTable, {
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, ctx.db);
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
- return (await fetchOne(ctx.db, userTable, where)) ?? null;
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) => crud.detail({ id: query.user.id }, query.user, ctx.db), // @wrapper-known semantic-alias
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 { AccessDeniedError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
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
- return crud.update(event.payload, event.user, ctx.db);
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
  });