@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.
- package/package.json +3 -3
- package/src/api/__tests__/api.test.ts +6 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +6 -0
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +6 -0
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +6 -0
- package/src/api/__tests__/jwt.test.ts +8 -0
- package/src/api/__tests__/stream-escape-hatch.integration.test.ts +203 -0
- package/src/api/jwt.ts +8 -0
- package/src/changes.json +28 -0
- package/src/db/__tests__/event-store-executor-global-tenancy.integration.test.ts +73 -0
- package/src/db/__tests__/tenant-db-global.integration.test.ts +63 -26
- package/src/db/__tests__/tenant-db-global.test.ts +95 -5
- package/src/db/entity-table-meta.ts +7 -0
- package/src/db/event-store-executor-context.ts +19 -0
- package/src/db/event-store-executor-write.ts +16 -0
- package/src/db/index.ts +3 -1
- package/src/db/table-builder.ts +27 -0
- package/src/db/tenant-db.ts +83 -10
- package/src/engine/__tests__/extends-registrar.test.ts +1 -0
- package/src/engine/__tests__/hook-identity-switch-grant.test.ts +4 -2
- package/src/engine/active-membership.ts +15 -1
- package/src/engine/boot-validator/__tests__/access-declarations.test.ts +37 -0
- package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +58 -0
- package/src/engine/boot-validator/access-declarations.ts +6 -1
- package/src/engine/boot-validator/global-tenancy.ts +17 -0
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/feature-entity-handlers.ts +7 -1
- package/src/engine/feature-ui-extensions.ts +2 -2
- package/src/engine/index.ts +3 -0
- package/src/engine/registry-validate.ts +6 -6
- package/src/engine/steps/call-feature.ts +1 -1
- package/src/engine/types/index.ts +2 -0
- package/src/entrypoint/index.ts +2 -13
- package/src/errors/i18n/de.yaml +36 -0
- package/src/errors/i18n/en.yaml +34 -0
- package/src/errors/reasons.ts +13 -0
- package/src/jobs/job-runner.ts +14 -0
- package/src/pipeline/__tests__/dispatcher-utils.test.ts +17 -0
- package/src/pipeline/__tests__/escape-hatch.integration.test.ts +0 -3
- package/src/pipeline/__tests__/system-identity-switch.integration.test.ts +201 -8
- package/src/pipeline/__tests__/system-identity-switch.test.ts +244 -33
- package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +103 -0
- package/src/pipeline/active-membership.ts +3 -1
- package/src/pipeline/dispatch-shared.ts +107 -20
- package/src/pipeline/dispatch-stream.ts +9 -5
- package/src/pipeline/dispatch-write.ts +7 -0
- package/src/pipeline/dispatcher-utils.ts +1 -0
- package/src/pipeline/dispatcher.ts +18 -0
- package/src/pipeline/index.ts +1 -1
- package/src/pipeline/member-reader.ts +108 -0
- package/src/pipeline/system-identity-switch.ts +142 -25
- package/src/stack/test-stack.ts +10 -1
- 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
|
-
|
|
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 {
|
|
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
|
-
//
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
100
|
-
//
|
|
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);
|
|
@@ -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
|
}
|
package/src/pipeline/index.ts
CHANGED
|
@@ -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
|
+
}
|