@cosmicdrift/kumiko-framework 0.266.0 → 0.269.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 (56) hide show
  1. package/package.json +3 -3
  2. package/src/api/__tests__/api.test.ts +6 -0
  3. package/src/api/__tests__/auth-routes-cookie.test.ts +6 -0
  4. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +6 -0
  5. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +6 -0
  6. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
  7. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +6 -0
  8. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +6 -0
  9. package/src/api/__tests__/jwt.test.ts +8 -0
  10. package/src/api/__tests__/stream-escape-hatch.integration.test.ts +203 -0
  11. package/src/api/jwt.ts +8 -0
  12. package/src/changes.json +28 -0
  13. package/src/db/__tests__/event-store-executor-global-tenancy.integration.test.ts +73 -0
  14. package/src/db/__tests__/tenant-db-global.integration.test.ts +63 -26
  15. package/src/db/__tests__/tenant-db-global.test.ts +95 -5
  16. package/src/db/entity-table-meta.ts +7 -0
  17. package/src/db/event-store-executor-context.ts +19 -0
  18. package/src/db/event-store-executor-write.ts +16 -0
  19. package/src/db/index.ts +3 -1
  20. package/src/db/table-builder.ts +27 -0
  21. package/src/db/tenant-db.ts +83 -10
  22. package/src/engine/__tests__/extends-registrar.test.ts +1 -0
  23. package/src/engine/__tests__/hook-identity-switch-grant.test.ts +4 -2
  24. package/src/engine/active-membership.ts +15 -1
  25. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +37 -0
  26. package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +58 -0
  27. package/src/engine/boot-validator/access-declarations.ts +6 -1
  28. package/src/engine/boot-validator/global-tenancy.ts +17 -0
  29. package/src/engine/boot-validator/index.ts +2 -0
  30. package/src/engine/feature-entity-handlers.ts +7 -1
  31. package/src/engine/feature-ui-extensions.ts +2 -2
  32. package/src/engine/index.ts +3 -0
  33. package/src/engine/registry-validate.ts +6 -6
  34. package/src/engine/steps/call-feature.ts +1 -1
  35. package/src/engine/types/index.ts +2 -0
  36. package/src/entrypoint/index.ts +2 -13
  37. package/src/errors/i18n/de.yaml +36 -0
  38. package/src/errors/i18n/en.yaml +34 -0
  39. package/src/errors/reasons.ts +13 -0
  40. package/src/jobs/job-runner.ts +14 -0
  41. package/src/pipeline/__tests__/dispatcher-utils.test.ts +17 -0
  42. package/src/pipeline/__tests__/escape-hatch.integration.test.ts +0 -3
  43. package/src/pipeline/__tests__/system-identity-switch.integration.test.ts +201 -8
  44. package/src/pipeline/__tests__/system-identity-switch.test.ts +244 -33
  45. package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +103 -0
  46. package/src/pipeline/active-membership.ts +3 -1
  47. package/src/pipeline/dispatch-shared.ts +107 -20
  48. package/src/pipeline/dispatch-stream.ts +9 -5
  49. package/src/pipeline/dispatch-write.ts +7 -0
  50. package/src/pipeline/dispatcher-utils.ts +1 -0
  51. package/src/pipeline/dispatcher.ts +18 -0
  52. package/src/pipeline/index.ts +1 -1
  53. package/src/pipeline/member-reader.ts +108 -0
  54. package/src/pipeline/system-identity-switch.ts +142 -25
  55. package/src/stack/test-stack.ts +10 -1
  56. package/src/testing/handler-context.ts +3 -0
@@ -0,0 +1,103 @@
1
+ // fw#2858 — a write/query handler's `escapeHatch` also gates ctx.db.unsafeRaw(reason):
2
+ // WITH it the raw query succeeds, WITHOUT it the same handler rejects with access_denied.
3
+ // Real HTTP calls + setupTestStack — never createTestDispatcher. Modelled exactly on
4
+ // escape-hatch.integration.test.ts (fw#2855).
5
+
6
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
+ import { z } from "zod";
8
+ import { executeRawQuery } from "../../db/queries/raw-sql";
9
+ import { defineFeature } from "../../engine";
10
+ import { createTestUser, setupTestStack, type TestStack } from "../../stack";
11
+
12
+ const unsafeRawFeature = defineFeature("unsafe-raw-probe", (r) => {
13
+ r.writeHandler({
14
+ name: "write-with-hatch",
15
+ schema: z.object({}),
16
+ access: { roles: ["User"] },
17
+ escapeHatch: { reason: "fw#2858 integration test — declared unsafeRaw write" },
18
+ handler: async (_event, ctx) => {
19
+ const runner = ctx.db.unsafeRaw("fw#2858 integration test — declared unsafeRaw write");
20
+ const rows = await executeRawQuery<{ one: number }>(runner, "SELECT 1 AS one");
21
+ return { isSuccess: true as const, data: { one: rows[0]?.one } };
22
+ },
23
+ });
24
+
25
+ r.writeHandler({
26
+ name: "write-without-hatch",
27
+ schema: z.object({}),
28
+ access: { roles: ["User"] },
29
+ handler: async (_event, ctx) => {
30
+ const runner = ctx.db.unsafeRaw("no escapeHatch declared — must throw before this runs");
31
+ const rows = await executeRawQuery<{ one: number }>(runner, "SELECT 1 AS one");
32
+ return { isSuccess: true as const, data: { one: rows[0]?.one } };
33
+ },
34
+ });
35
+
36
+ r.queryHandler({
37
+ name: "query-with-hatch",
38
+ schema: z.object({}),
39
+ access: { roles: ["User"] },
40
+ escapeHatch: { reason: "fw#2858 integration test — declared unsafeRaw query" },
41
+ handler: async (_query, ctx) => {
42
+ const runner = ctx.db.unsafeRaw("fw#2858 integration test — declared unsafeRaw query");
43
+ const rows = await executeRawQuery<{ one: number }>(runner, "SELECT 1 AS one");
44
+ return { one: rows[0]?.one };
45
+ },
46
+ });
47
+
48
+ r.queryHandler({
49
+ name: "query-without-hatch",
50
+ schema: z.object({}),
51
+ access: { roles: ["User"] },
52
+ handler: async (_query, ctx) => {
53
+ const runner = ctx.db.unsafeRaw("no escapeHatch declared — must throw before this runs");
54
+ const rows = await executeRawQuery<{ one: number }>(runner, "SELECT 1 AS one");
55
+ return { one: rows[0]?.one };
56
+ },
57
+ });
58
+ });
59
+
60
+ let stack: TestStack;
61
+ const user = createTestUser({
62
+ id: 92,
63
+ tenantId: "11111111-0000-4000-8000-000000000092",
64
+ roles: ["User"],
65
+ });
66
+
67
+ beforeAll(async () => {
68
+ stack = await setupTestStack({ features: [unsafeRawFeature] });
69
+ });
70
+
71
+ afterAll(async () => {
72
+ await stack.cleanup();
73
+ });
74
+
75
+ describe("write/query handler escapeHatch gates ctx.db.unsafeRaw()", () => {
76
+ test("write WITH escapeHatch: unsafeRaw succeeds", async () => {
77
+ const result = await stack.http.writeOk<{ one: number }>(
78
+ "unsafe-raw-probe:write:write-with-hatch",
79
+ {},
80
+ user,
81
+ );
82
+ expect(result.one).toBe(1);
83
+ });
84
+
85
+ test("write WITHOUT escapeHatch: unsafeRaw fails with access_denied", async () => {
86
+ const err = await stack.http.writeErr("unsafe-raw-probe:write:write-without-hatch", {}, user);
87
+ expect(err.code).toBe("access_denied");
88
+ });
89
+
90
+ test("query WITH escapeHatch: unsafeRaw succeeds", async () => {
91
+ const result = await stack.http.queryOk<{ one: number }>(
92
+ "unsafe-raw-probe:query:query-with-hatch",
93
+ {},
94
+ user,
95
+ );
96
+ expect(result.one).toBe(1);
97
+ });
98
+
99
+ test("query WITHOUT escapeHatch: unsafeRaw fails with access_denied", async () => {
100
+ const err = await stack.http.queryErr("unsafe-raw-probe:query:query-without-hatch", {}, user);
101
+ expect(err.code).toBe("access_denied");
102
+ });
103
+ });
@@ -76,7 +76,9 @@ async function findMembership(
76
76
  return rawMemberships.find((m) => m.tenantId === tenantId);
77
77
  }
78
78
 
79
- function resolvePrincipalPlugin(registry: Registry): PrincipalStatusPlugin {
79
+ // Exported so member-reader.ts (ctx.queryAsMember) can reach the registered
80
+ // PrincipalStatusPlugin's resolveProfile without duplicating this lookup.
81
+ export function resolvePrincipalPlugin(registry: Registry): PrincipalStatusPlugin {
80
82
  const usages = registry.getExtensionUsages(EXT_PRINCIPAL_STATUS);
81
83
  if (usages.length !== 1) {
82
84
  throw new InternalError({
@@ -16,13 +16,16 @@ import type {
16
16
  FetchForWritingArgs,
17
17
  HandlerContext,
18
18
  JobRunnerRef,
19
+ MemberReader,
19
20
  Registry,
20
21
  SessionUser,
21
22
  WriteResult,
22
23
  } from "../engine/types";
23
24
  import type { TenantId } from "../engine/types/identifiers";
24
25
  import {
26
+ AccessDeniedError,
25
27
  FeatureDisabledError,
28
+ FrameworkReasons,
26
29
  InternalError,
27
30
  VersionConflictError,
28
31
  type WriteErrorInfo,
@@ -76,7 +79,12 @@ import {
76
79
  } from "./dispatcher-utils";
77
80
  import type { IdempotencyGuard } from "./idempotency";
78
81
  import type { LifecycleHooks } from "./lifecycle-pipeline";
79
- import { createGatedIdentitySwitch, systemIdentitySwitchDenied } from "./system-identity-switch";
82
+ import { createMemberReaderFn } from "./member-reader";
83
+ import {
84
+ createGatedIdentitySwitch,
85
+ createGatedMemberReader,
86
+ systemIdentitySwitchDenied,
87
+ } from "./system-identity-switch";
80
88
  import type { TenantTimezoneCache } from "./tenant-timezone-cache";
81
89
 
82
90
  // Framework/pipeline stays bundled-features-free, so this can't import the
@@ -151,6 +159,8 @@ async function appendDomainEvent(
151
159
  tx: DbTx | undefined,
152
160
  callerFeature: string | undefined,
153
161
  ): Promise<void> {
162
+ // Sink behind every append surface, so a resolved member stays read-only even via fetchForWriting handles.
163
+ if (user.origin === "member-resolution") throw memberResolutionReadOnlyDenied();
154
164
  const { registry } = ctx;
155
165
  const dbSource = resolveDbSource(ctx, tx);
156
166
  if (!dbSource) {
@@ -196,6 +206,59 @@ function createSystemScopedDbGuard(
196
206
  });
197
207
  }
198
208
 
209
+ export function memberResolutionReadOnlyDenied(): AccessDeniedError {
210
+ return new AccessDeniedError({
211
+ message: "a resolved member principal (ctx.queryAsMember) cannot write — read-only",
212
+ details: { reason: FrameworkReasons.memberResolutionReadOnly },
213
+ });
214
+ }
215
+
216
+ async function denyMemberResolutionWrite(): Promise<never> {
217
+ throw memberResolutionReadOnlyDenied();
218
+ }
219
+
220
+ // JobRunnerRef's declared type only carries handleEvent, but callers
221
+ // routinely bracket-access a fuller runner — a Proxy denies ANY property read.
222
+ function denyingJobRunnerProxy(): JobRunnerRef {
223
+ return new Proxy({} as JobRunnerRef, {
224
+ get() {
225
+ throw memberResolutionReadOnlyDenied();
226
+ },
227
+ });
228
+ }
229
+
230
+ // Every write/side-effect surface throws, so "no writeAsMember" holds even
231
+ // if a handler tries ctx.write directly on a resolved member principal.
232
+ function applyMemberResolutionReadOnly(handlerContext: HandlerContext): HandlerContext {
233
+ return {
234
+ ...handlerContext,
235
+ // `db` itself stays open — same access the member's own HTTP request has;
236
+ // raw ctx.db writes inside a queried handler are not blocked by this.
237
+ dbOutsideTransaction: undefined,
238
+ write: denyMemberResolutionWrite,
239
+ writeAs: denyMemberResolutionWrite,
240
+ appendEvent: denyMemberResolutionWrite as AppendEventFn, // @cast-boundary engine-bridge
241
+ unsafeAppendEvent: denyMemberResolutionWrite,
242
+ tryAppendEvent: denyMemberResolutionWrite,
243
+ fetchForWriting: denyMemberResolutionWrite,
244
+ archiveStream: denyMemberResolutionWrite,
245
+ restoreStream: denyMemberResolutionWrite,
246
+ snapshotAggregate: denyMemberResolutionWrite,
247
+ queryAsMember: denyMemberResolutionWrite,
248
+ resolveActiveMembership: denyMemberResolutionWrite,
249
+ scheduleAfterCommit: () => {
250
+ throw memberResolutionReadOnlyDenied();
251
+ },
252
+ // A read as a member gets no system-scope DB, preSave pipeline or file storage.
253
+ runPreSave: undefined,
254
+ systemDb: undefined,
255
+ files: undefined,
256
+ derivatives: undefined,
257
+ ...(handlerContext.jobRunner && { jobRunner: denyingJobRunnerProxy() }),
258
+ ...(handlerContext.notify && { notify: denyMemberResolutionWrite }),
259
+ };
260
+ }
261
+
199
262
  export async function buildHandlerContext(
200
263
  ctx: DispatchContext,
201
264
  type: string,
@@ -211,13 +274,13 @@ export async function buildHandlerContext(
211
274
  // but at this point we're the root of the pipeline — cast is safe.
212
275
  const dbSource = resolveDbSource(ctx, tx);
213
276
  const reqCtx = requestContext.get();
214
- // db.global()'s write-gate — undefined for query/stream handlers, which can't declare escapeHatch.
215
- const escapeHatch = registry.getWriteHandler(type)?.escapeHatch;
216
- // Broader than `escapeHatch` above — a query handler can't reach db.global() but can still opt in here.
217
- const allowSystemIdentity =
218
- isSystem ||
219
- (registry.getWriteHandler(type)?.escapeHatch ?? registry.getQueryHandler(type)?.escapeHatch) !==
220
- undefined;
277
+ // global() writes are write-handler-only; SYSTEM identity switch and unsafeRaw accept write, query or stream escapeHatch.
278
+ const writeEscapeHatch = registry.getWriteHandler(type)?.escapeHatch;
279
+ const handlerEscapeHatch =
280
+ writeEscapeHatch ??
281
+ registry.getQueryHandler(type)?.escapeHatch ??
282
+ registry.getStreamHandler(type)?.escapeHatch;
283
+ const hasIdentitySwitchGrant = isSystem || handlerEscapeHatch !== undefined;
221
284
  const buildTenantScopedDb = (source: DbConnection | DbTx, signal: AbortSignal | undefined) =>
222
285
  createTenantDb(
223
286
  source,
@@ -226,7 +289,7 @@ export async function buildHandlerContext(
226
289
  context.tracer,
227
290
  context.meter,
228
291
  signal,
229
- escapeHatch,
292
+ { globalWrites: writeEscapeHatch, unsafeRaw: handlerEscapeHatch },
230
293
  );
231
294
  // Propagate the request's AbortSignal so every TenantDb query throws when
232
295
  // the client has disconnected — handlers with many sequential queries skip
@@ -315,17 +378,33 @@ export async function buildHandlerContext(
315
378
  : () => createNoopMetricsHandle();
316
379
 
317
380
  // Cross-feature bridge: ctx.query/write share the current tx + afterCommitHooks sink.
318
- // queryAs/writeAs switch identity; SYSTEM needs r.systemScope() or { escapeHatch } (system-identity-switch.ts).
381
+ // queryAs/writeAs to anyone but the caller itself needs r.systemScope() or { escapeHatch } (system-identity-switch.ts).
319
382
  const bridgeSink = afterCommitHooks ?? [];
320
383
  const scheduleAfterCommit = (hook: AfterCommitHook): void => {
321
384
  bridgeSink.push(hook);
322
385
  };
323
- const identitySwitch = createGatedIdentitySwitch(`handler "${type}"`, allowSystemIdentity, {
324
- queryAs: (asUser: SessionUser, targetType: string, payload: unknown) =>
325
- executeQuery(ctx, targetType, payload, asUser, tx), // @wrapper-known semantic-alias
326
- writeAs: (asUser: SessionUser, targetType: string, payload: unknown) =>
327
- executeWrite(ctx, targetType, payload, asUser, tx, bridgeSink),
328
- });
386
+ const identitySwitch = createGatedIdentitySwitch(
387
+ `handler "${type}"`,
388
+ user,
389
+ hasIdentitySwitchGrant,
390
+ {
391
+ queryAs: (asUser: SessionUser, targetType: string, payload: unknown) =>
392
+ executeQuery(ctx, targetType, payload, asUser, tx), // @wrapper-known semantic-alias
393
+ writeAs: (asUser: SessionUser, targetType: string, payload: unknown) =>
394
+ executeWrite(ctx, targetType, payload, asUser, tx, bridgeSink),
395
+ },
396
+ );
397
+ // Lazy — creates the reader (which fails closed on SYSTEM_TENANT_ID) only
398
+ // on first actual use, not on every HandlerContext build.
399
+ let ungatedMemberReader: MemberReader | undefined;
400
+ const queryAsMember = createGatedMemberReader(
401
+ `handler "${type}"`,
402
+ hasIdentitySwitchGrant,
403
+ (userId, qn, payload) => {
404
+ ungatedMemberReader ??= createMemberReaderFn(ctx, user.tenantId, tx);
405
+ return ungatedMemberReader(userId, qn, payload);
406
+ },
407
+ );
329
408
  const bridge = {
330
409
  query: (targetType: string, payload: unknown) =>
331
410
  executeQuery(ctx, targetType, payload, user, tx), // @wrapper-known semantic-alias
@@ -600,10 +679,13 @@ export async function buildHandlerContext(
600
679
  // Internally queries memberships as SYSTEM, so it needs the same grant
601
680
  // as a SYSTEM queryAs/writeAs (r.systemScope() or escapeHatch).
602
681
  resolveActiveMembership: (userId: string, tenantId: TenantId) => {
603
- if (!allowSystemIdentity) throw systemIdentitySwitchDenied(`handler "${type}"`);
682
+ if (!hasIdentitySwitchGrant) throw systemIdentitySwitchDenied(`handler "${type}"`);
604
683
  return resolveActiveMembershipFn(ctx, userId, tenantId, INTERACTIVE_SIGN_IN_POLICY); // @wrapper-known semantic-alias
605
684
  },
606
685
 
686
+ // Needs the same grant as a SYSTEM queryAs.
687
+ queryAsMember,
688
+
607
689
  // Feature-effective check for in-handler opt-in logic. Scope:
608
690
  // **current user's tenant** — for cross-tenant lookups (rare,
609
691
  // SysAdmin operations) read effectiveFeatures(otherTenantId) directly.
@@ -688,7 +770,7 @@ export async function buildHandlerContext(
688
770
  user.locale !== undefined && isValidLocaleTag(user.locale) ? user.locale : undefined;
689
771
  const locale = reqCtx?.locale ?? safeUserLocale ?? context.defaultLocale ?? DEFAULT_LOCALE;
690
772
 
691
- return {
773
+ const handlerContext = {
692
774
  ...context,
693
775
  registry,
694
776
  db: exposedDb,
@@ -740,6 +822,11 @@ export async function buildHandlerContext(
740
822
  ...(includeDeleted && { includeDeleted: true }),
741
823
  ...bridge,
742
824
  } as HandlerContext; // @cast-boundary engine-bridge
825
+
826
+ // A resolved member principal is read-only by construction.
827
+ return user.origin === "member-resolution"
828
+ ? applyMemberResolutionReadOnly(handlerContext)
829
+ : handlerContext;
743
830
  }
744
831
 
745
832
  // Wrap handler execution in a dispatcher.handler span AND emit the standard
@@ -967,7 +1054,7 @@ export async function enforceRateLimit(
967
1054
  // hooks run OUTSIDE any request transaction (login is itself the root
968
1055
  // operation, not a nested call) and read-only — so the TenantDb is
969
1056
  // scoped as "tenant" and no tx is threaded through. Cross-tenant lookups
970
- // go through queryAs(otherUser, ...); SYSTEM is always denied (no escapeHatch declaration site here).
1057
+ // are denied: queryAs only reaches the claims user itself (no escapeHatch declaration site here).
971
1058
  function buildAuthClaimsContext(ctx: DispatchContext, user: SessionUser): AuthClaimsContext {
972
1059
  const { appContext: context } = ctx;
973
1060
  const dbSource = resolveDbSource(ctx, undefined);
@@ -984,7 +1071,7 @@ function buildAuthClaimsContext(ctx: DispatchContext, user: SessionUser): AuthCl
984
1071
  secrets: context.secrets,
985
1072
  })
986
1073
  : undefined;
987
- const identitySwitch = createGatedIdentitySwitch("r.authClaims hook", false, {
1074
+ const identitySwitch = createGatedIdentitySwitch("r.authClaims hook", user, false, {
988
1075
  queryAs: (asUser: SessionUser, qn: string, payload: unknown) =>
989
1076
  executeQuery(ctx, qn, payload, asUser), // @wrapper-known semantic-alias
990
1077
  writeAs: async () => {
@@ -7,6 +7,7 @@ import {
7
7
  type DispatchContext,
8
8
  enforceRateLimit,
9
9
  ensureFeatureEnabled,
10
+ memberResolutionReadOnlyDenied,
10
11
  runStreamInstrumented,
11
12
  } from "./dispatch-shared";
12
13
 
@@ -35,6 +36,12 @@ async function* executeStreamInner(
35
36
  const handler = registry.getStreamHandler(type);
36
37
  if (!handler) throw new NotFoundError("handler", type);
37
38
 
39
+ // A resolved member principal (ctx.queryAsMember) is read-only — streams
40
+ // are excluded the same way executeWriteInner excludes writes.
41
+ if (user.origin === "member-resolution") {
42
+ throw memberResolutionReadOnlyDenied();
43
+ }
44
+
38
45
  await ensureFeatureEnabled(ctx, type, user.tenantId);
39
46
 
40
47
  if (handler.rateLimit !== undefined) {
@@ -96,11 +103,8 @@ async function* executeStreamInner(
96
103
  }
97
104
  } finally {
98
105
  unsubscribeAccessInvalidation?.();
99
- // Consumer break / throw — always close the handler generator so its
100
- // finally (cleanup) runs (for-await would do this). Do NOT swallow
101
- // return() errors — close-time cleanup failures must surface to
102
- // runStreamInstrumented (#1543). Skip the await after access-revoke
103
- // abandonment (overlapping next+return deadlocks — see above).
106
+ // Close the generator so cleanup runs; skip awaiting return() after
107
+ // access-revoke abandonment — overlapping next()+return() can deadlock.
104
108
  if (iterator !== undefined && !abandonedForInvalidation) {
105
109
  await iterator.return?.(undefined);
106
110
  }
@@ -27,6 +27,7 @@ import {
27
27
  CONFIG_WRITE_SET_TYPE,
28
28
  checkFeatureEnabled,
29
29
  enforceRateLimit,
30
+ memberResolutionReadOnlyDenied,
30
31
  runHandlerInstrumented,
31
32
  TENANT_TIMEZONE_CONFIG_KEY,
32
33
  } from "./dispatch-shared";
@@ -327,6 +328,12 @@ async function executeWriteInner(
327
328
  const handler = registry.getWriteHandler(type);
328
329
  if (!handler) return writeFailure(new NotFoundError("handler", type));
329
330
 
331
+ // Defense in depth — covers runBatch and a spread-copied user that kept
332
+ // `origin`, beyond buildHandlerContext's own ctx.write replacement.
333
+ if (user.origin === "member-resolution") {
334
+ return writeFailure(memberResolutionReadOnlyDenied());
335
+ }
336
+
330
337
  // Feature-toggle gate: disabled handlers must short-circuit before any
331
338
  // rate-limit/access/validation work — see executeQueryInner comment.
332
339
  const disabledErr = await checkFeatureEnabled(ctx, type, user.tenantId);
@@ -49,6 +49,7 @@ export function dispatcherSpanAttributes(
49
49
  "kumiko.tenant_id": user.tenantId,
50
50
  };
51
51
  if (feature) attrs["kumiko.feature"] = feature;
52
+ if (user.origin) attrs["kumiko.user_origin"] = user.origin;
52
53
  return attrs;
53
54
  }
54
55
 
@@ -6,7 +6,9 @@ import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extensio
6
6
  import type {
7
7
  ActiveMembershipResult,
8
8
  AppContext,
9
+ DispatchWriteRef,
9
10
  JobRunnerRef,
11
+ MemberReader,
10
12
  Registry,
11
13
  SessionUser,
12
14
  WriteResult,
@@ -23,6 +25,7 @@ import { executeStream } from "./dispatch-stream";
23
25
  import { type HandlerType, resolveType } from "./dispatcher-utils";
24
26
  import type { IdempotencyGuard } from "./idempotency";
25
27
  import type { LifecycleHooks } from "./lifecycle-pipeline";
28
+ import { createMemberReaderFn } from "./member-reader";
26
29
  import { createTenantTimezoneCache } from "./tenant-timezone-cache";
27
30
 
28
31
  // Re-export for callers that reach for dispatcher-adjacent types (tests,
@@ -97,8 +100,21 @@ export type Dispatcher = {
97
100
  // Membership check for interactive sign-in paths (login, MFA completion,
98
101
  // tenant switch) — single resolve implementation so callers can't independently drift.
99
102
  resolveActiveMembership(userId: string, tenantId: TenantId): Promise<ActiveMembershipResult>;
103
+ // Trusted server surface, ungated like resolveActiveMembership.
104
+ // Handler/hook ctx.queryAsMember uses buildHandlerContext's own gated reader instead.
105
+ createMemberReader(tenantId: TenantId): MemberReader;
100
106
  };
101
107
 
108
+ // Adapts Dispatcher's (type, payload, user) call shape to DispatchWriteRef's
109
+ // (user, qn, payload) — JobRunner.attachDispatcher needs the latter.
110
+ export function dispatcherToWriteRef(dispatcher: Dispatcher): DispatchWriteRef {
111
+ return {
112
+ write: (user, qn, payload) => dispatcher.write(qn, payload, user),
113
+ queryAs: (user, qn, payload) => dispatcher.query(qn, payload, user),
114
+ createMemberReader: (tenantId) => dispatcher.createMemberReader(tenantId),
115
+ };
116
+ }
117
+
102
118
  export function createDispatcher(
103
119
  registry: Registry,
104
120
  context: AppContext,
@@ -164,5 +180,7 @@ export function createDispatcher(
164
180
 
165
181
  resolveActiveMembership: (userId, tenantId) =>
166
182
  resolveActiveMembershipFn(ctx, userId, tenantId, INTERACTIVE_SIGN_IN_POLICY),
183
+
184
+ createMemberReader: (tenantId) => createMemberReaderFn(ctx, tenantId),
167
185
  };
168
186
  }
@@ -2,7 +2,7 @@ export type { ResolveAuthClaimsArgs } from "./auth-claims-resolver";
2
2
  export { resolveAuthClaims } from "./auth-claims-resolver";
3
3
  export { createCascadeDeleteHook } from "./cascade-handler";
4
4
  export type { Dispatcher } from "./dispatcher";
5
- export { createDispatcher } from "./dispatcher";
5
+ export { createDispatcher, dispatcherToWriteRef } from "./dispatcher";
6
6
  export type { DistributedLock } from "./distributed-lock";
7
7
  export { createDistributedLock } from "./distributed-lock";
8
8
  export type { EntityCache, EntityCacheOptions } from "./entity-cache";
@@ -0,0 +1,108 @@
1
+ // ctx.queryAsMember — reads a query handler as a stored tenant member. The
2
+ // resolved SessionUser never leaves this module.
3
+
4
+ import type { DbConnection, DbTx } from "../db/connection";
5
+ import { buildSessionRoles } from "../engine/membership-roles";
6
+ import type { MemberReader, SessionUser } from "../engine/types";
7
+ import { SYSTEM_TENANT_ID } from "../engine/types";
8
+ import type { TenantId } from "../engine/types/identifiers";
9
+ import { AccessDeniedError, FrameworkReasons, InternalError } from "../errors";
10
+ import {
11
+ type ActiveMembershipPolicy,
12
+ resolveActiveMembershipFn,
13
+ resolvePrincipalPlugin,
14
+ } from "./active-membership";
15
+ import { executeQuery } from "./dispatch-query";
16
+ import { type DispatchContext, resolveAuthClaimsFn, resolveDbSource } from "./dispatch-shared";
17
+ import { isSystemIdentity } from "./system-identity-switch";
18
+
19
+ // Stricter than interactive sign-in: an unknown principal or a tenant
20
+ // mid-teardown must not resolve — there is no user-facing flow to recover.
21
+ export const BACKGROUND_READ_POLICY: ActiveMembershipPolicy = {
22
+ allowPendingDestruction: false,
23
+ allowUnknownPrincipal: false,
24
+ };
25
+
26
+ // Generic on purpose — details never carry which check failed, so a caller
27
+ // can't probe another user's membership state via the error shape.
28
+ function memberResolutionDenied(): AccessDeniedError {
29
+ return new AccessDeniedError({
30
+ message: "not permitted to read as this member",
31
+ details: { reason: FrameworkReasons.memberResolutionDenied },
32
+ });
33
+ }
34
+
35
+ // Membership/principal/lifecycle resolve on the root connection, outside the
36
+ // caller's tx — only the target query runs inside it (via the `tx` argument).
37
+ async function resolveMember(
38
+ ctx: DispatchContext,
39
+ tenantId: TenantId,
40
+ userId: string,
41
+ ): Promise<SessionUser> {
42
+ const membership = await resolveActiveMembershipFn(ctx, userId, tenantId, BACKGROUND_READ_POLICY);
43
+ if (membership.kind === "rejected") throw memberResolutionDenied();
44
+
45
+ const principalPlugin = resolvePrincipalPlugin(ctx.registry);
46
+ const dbSource = resolveDbSource(ctx, undefined);
47
+ if (!dbSource) {
48
+ throw new InternalError({
49
+ message: "ctx.queryAsMember requires a database connection — none is configured.",
50
+ });
51
+ }
52
+ // resolveDbSource(ctx, undefined) never returns a tx, only AppContext.db.
53
+ const db = dbSource as DbConnection; // @cast-boundary db-operator
54
+
55
+ const profile = await principalPlugin.resolveProfile(userId, { db });
56
+ if (!profile) throw memberResolutionDenied();
57
+
58
+ // buildSessionRoles runs stripForbiddenMembershipRoles on the membership half (rebuild-resurrected roles).
59
+ const roles = buildSessionRoles(profile.globalRoles, membership.membership.roles);
60
+ const base: SessionUser = {
61
+ id: userId,
62
+ tenantId,
63
+ roles,
64
+ ...(profile.timezone !== undefined && { timezone: profile.timezone }),
65
+ ...(profile.locale !== undefined && { locale: profile.locale }),
66
+ origin: "member-resolution",
67
+ };
68
+ // Defense in depth: buildSessionRoles strips forbidden roles only from the
69
+ // membership half, not globalRoles — a resolved principal must never be SYSTEM.
70
+ if (isSystemIdentity(base)) throw memberResolutionDenied();
71
+
72
+ const claims = await resolveAuthClaimsFn(ctx, base);
73
+ return Object.keys(claims).length > 0 ? { ...base, claims } : base;
74
+ }
75
+
76
+ // One reader per handler invocation (shared with its hooks) or job run; a
77
+ // nested dispatch builds its own reader with its own cache.
78
+ export function createMemberReaderFn(
79
+ ctx: DispatchContext,
80
+ tenantId: TenantId,
81
+ tx?: DbTx,
82
+ ): MemberReader {
83
+ if (tenantId === SYSTEM_TENANT_ID) {
84
+ throw new InternalError({
85
+ message: "queryAsMember needs a tenant-scoped context — got SYSTEM_TENANT_ID.",
86
+ });
87
+ }
88
+
89
+ // Only successful resolutions are cached — a rejected/failed resolution
90
+ // deletes its own entry so a transient error can't poison the rest of the run.
91
+ const cache = new Map<string, Promise<SessionUser>>();
92
+
93
+ function resolve(userId: string): Promise<SessionUser> {
94
+ const cached = cache.get(userId);
95
+ if (cached) return cached;
96
+ const pending = resolveMember(ctx, tenantId, userId);
97
+ cache.set(userId, pending);
98
+ pending.catch(() => {
99
+ cache.delete(userId);
100
+ });
101
+ return pending;
102
+ }
103
+
104
+ return async (userId, qn, payload) => {
105
+ const user = await resolve(userId);
106
+ return executeQuery(ctx, qn, payload, user, tx);
107
+ };
108
+ }