@cosmicdrift/kumiko-framework 0.266.0 → 0.268.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/jwt.ts +8 -0
- package/src/changes.json +15 -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/active-membership.ts +15 -1
- package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +58 -0
- package/src/engine/boot-validator/global-tenancy.ts +17 -0
- package/src/engine/boot-validator/index.ts +2 -0
- 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/types/index.ts +2 -0
- package/src/entrypoint/index.ts +2 -13
- package/src/errors/i18n/de.yaml +20 -0
- package/src/errors/i18n/en.yaml +19 -0
- package/src/errors/reasons.ts +8 -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.test.ts +60 -15
- 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 +89 -10
- 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 +73 -13
- package/src/stack/test-stack.ts +10 -1
- package/src/testing/handler-context.ts +3 -0
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import type { DbRunner } from "../../db/connection";
|
|
3
|
+
import { createTenantDb, type TenantDb } from "../../db/tenant-db";
|
|
2
4
|
import { createSystemUser, SYSTEM_ROLE, SYSTEM_USER_ID } from "../../engine";
|
|
3
5
|
import type { SessionUser } from "../../engine/types";
|
|
4
6
|
import type { TenantId } from "../../engine/types/identifiers";
|
|
@@ -8,7 +10,8 @@ import {
|
|
|
8
10
|
type IdentitySwitch,
|
|
9
11
|
isSystemIdentity,
|
|
10
12
|
isSystemIdentitySwitchAllowed,
|
|
11
|
-
|
|
13
|
+
type WriteAsFn,
|
|
14
|
+
withHookEscapeHatchGrant,
|
|
12
15
|
} from "../system-identity-switch";
|
|
13
16
|
|
|
14
17
|
const TENANT = "00000000-0000-4000-8000-00000000ab01" as TenantId;
|
|
@@ -120,18 +123,14 @@ describe("createGatedIdentitySwitch", () => {
|
|
|
120
123
|
});
|
|
121
124
|
});
|
|
122
125
|
|
|
123
|
-
describe("
|
|
126
|
+
describe("withHookEscapeHatchGrant", () => {
|
|
124
127
|
test("hook without escapeHatch does NOT inherit the handler's SYSTEM grant", async () => {
|
|
125
128
|
const { ungated } = makeUngated();
|
|
126
129
|
// Simulates dispatch-shared.ts's buildHandlerContext for a handler that
|
|
127
130
|
// itself declared escapeHatch / is systemScope (allowSystemIdentity=true).
|
|
128
131
|
const handlerCtx = { ...createGatedIdentitySwitch('handler "outer"', true, ungated) };
|
|
129
132
|
|
|
130
|
-
const hookCtx =
|
|
131
|
-
handlerCtx,
|
|
132
|
-
'postSave hook of feature "f"',
|
|
133
|
-
undefined,
|
|
134
|
-
);
|
|
133
|
+
const hookCtx = withHookEscapeHatchGrant(handlerCtx, 'postSave hook of feature "f"', undefined);
|
|
135
134
|
|
|
136
135
|
await expect(hookCtx.queryAs(systemUserById, "q", {})).rejects.toBeInstanceOf(
|
|
137
136
|
AccessDeniedError,
|
|
@@ -143,7 +142,7 @@ describe("withHookIdentitySwitchGrant", () => {
|
|
|
143
142
|
// Handler itself has NO escapeHatch (allowSystemIdentity=false).
|
|
144
143
|
const handlerCtx = { ...createGatedIdentitySwitch('handler "outer"', false, ungated) };
|
|
145
144
|
|
|
146
|
-
const hookCtx =
|
|
145
|
+
const hookCtx = withHookEscapeHatchGrant(handlerCtx, 'postSave hook of feature "f"', {
|
|
147
146
|
reason: "test hook needs SYSTEM",
|
|
148
147
|
});
|
|
149
148
|
|
|
@@ -155,7 +154,7 @@ describe("withHookIdentitySwitchGrant", () => {
|
|
|
155
154
|
test("a non-system asUser is always allowed through the hook's own gate", async () => {
|
|
156
155
|
const { ungated, queryAsMock } = makeUngated();
|
|
157
156
|
const handlerCtx = { ...createGatedIdentitySwitch('handler "outer"', false, ungated) };
|
|
158
|
-
const hookCtx =
|
|
157
|
+
const hookCtx = withHookEscapeHatchGrant(handlerCtx, "hook", undefined);
|
|
159
158
|
|
|
160
159
|
await hookCtx.queryAs(normalUser, "q", {});
|
|
161
160
|
expect(queryAsMock).toHaveBeenCalledWith(normalUser, "q", {});
|
|
@@ -166,30 +165,76 @@ describe("withHookIdentitySwitchGrant", () => {
|
|
|
166
165
|
const handlerCtx = { ...createGatedIdentitySwitch('handler "outer"', true, ungated), extra: 1 };
|
|
167
166
|
const spread = { ...handlerCtx };
|
|
168
167
|
|
|
169
|
-
const hookCtx =
|
|
168
|
+
const hookCtx = withHookEscapeHatchGrant(spread, "hook", { reason: "needs system" });
|
|
170
169
|
await hookCtx.queryAs(systemUserById, "q", {});
|
|
171
170
|
|
|
172
171
|
expect(queryAsMock).toHaveBeenCalledWith(systemUserById, "q", {});
|
|
173
172
|
});
|
|
174
173
|
|
|
175
|
-
test("a context without queryAs/writeAs is returned unchanged", () => {
|
|
176
|
-
const plainContext = {
|
|
177
|
-
const result =
|
|
174
|
+
test("a context without queryAs/writeAs/db/dbOutsideTransaction is returned unchanged", () => {
|
|
175
|
+
const plainContext = { unrelated: 1 };
|
|
176
|
+
const result = withHookEscapeHatchGrant(plainContext, "hook", { reason: "x" });
|
|
178
177
|
expect(result).toBe(plainContext);
|
|
179
178
|
});
|
|
180
179
|
|
|
180
|
+
test("a deny-stubbed writeAs is not revived through a sibling queryAs's registered ungated pair", async () => {
|
|
181
|
+
// Like a member-resolution ctx: registered gated queryAs next to an unregistered deny-stub writeAs.
|
|
182
|
+
const { ungated, writeAsMock } = makeUngated();
|
|
183
|
+
const gatedIdentitySwitch = createGatedIdentitySwitch('handler "outer"', true, ungated);
|
|
184
|
+
const denyStubWriteAs: WriteAsFn = async () => {
|
|
185
|
+
throw new AccessDeniedError({
|
|
186
|
+
message: "read-only",
|
|
187
|
+
details: { reason: "member_resolution_read_only" },
|
|
188
|
+
});
|
|
189
|
+
};
|
|
190
|
+
const handlerCtx = { queryAs: gatedIdentitySwitch.queryAs, writeAs: denyStubWriteAs };
|
|
191
|
+
|
|
192
|
+
const hookCtx = withHookEscapeHatchGrant(handlerCtx, "hook", {
|
|
193
|
+
reason: "test hook needs SYSTEM",
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
await expect(hookCtx.writeAs(systemUserById, "w", {})).rejects.toBeInstanceOf(
|
|
197
|
+
AccessDeniedError,
|
|
198
|
+
);
|
|
199
|
+
expect(writeAsMock).not.toHaveBeenCalled();
|
|
200
|
+
});
|
|
201
|
+
|
|
181
202
|
test("an ungated context not built by the dispatcher bridge fails closed (unit-test stub)", async () => {
|
|
182
203
|
const stubQueryAs = mock(async (_user: SessionUser, _qn: string, _payload: unknown) => "ok");
|
|
183
204
|
const stubContext = { queryAs: stubQueryAs };
|
|
184
205
|
|
|
185
|
-
const hookCtx =
|
|
206
|
+
const hookCtx = withHookEscapeHatchGrant(stubContext, "hook", undefined);
|
|
186
207
|
await expect(hookCtx.queryAs(systemUserById, "q", {})).rejects.toBeInstanceOf(
|
|
187
208
|
AccessDeniedError,
|
|
188
209
|
);
|
|
189
210
|
expect(stubQueryAs).not.toHaveBeenCalled();
|
|
190
211
|
|
|
191
|
-
const allowedHookCtx =
|
|
212
|
+
const allowedHookCtx = withHookEscapeHatchGrant(stubContext, "hook", { reason: "x" });
|
|
192
213
|
await allowedHookCtx.queryAs(systemUserById, "q", {});
|
|
193
214
|
expect(stubQueryAs).toHaveBeenCalledWith(systemUserById, "q", {});
|
|
194
215
|
});
|
|
216
|
+
|
|
217
|
+
test("hook re-gates ctx.db.unsafeRaw() independently of the handler's own grant", () => {
|
|
218
|
+
const rawDb: DbRunner = {
|
|
219
|
+
unsafe: async () => [],
|
|
220
|
+
begin: async () => {
|
|
221
|
+
throw new Error("begin not used in this test");
|
|
222
|
+
},
|
|
223
|
+
} as DbRunner;
|
|
224
|
+
// Simulates buildHandlerContext granting the HANDLER its own unsafeRaw escapeHatch.
|
|
225
|
+
const handlerDb = createTenantDb(rawDb, TENANT, "tenant", undefined, undefined, undefined, {
|
|
226
|
+
unsafeRaw: { reason: "handler's own grant" },
|
|
227
|
+
});
|
|
228
|
+
const handlerCtx = { db: handlerDb };
|
|
229
|
+
|
|
230
|
+
const hookCtxWithoutEscapeHatch = withHookEscapeHatchGrant(handlerCtx, "hook", undefined) as {
|
|
231
|
+
db: TenantDb;
|
|
232
|
+
};
|
|
233
|
+
expect(() => hookCtxWithoutEscapeHatch.db.unsafeRaw("test reason")).toThrow(AccessDeniedError);
|
|
234
|
+
|
|
235
|
+
const hookCtxWithEscapeHatch = withHookEscapeHatchGrant(handlerCtx, "hook", {
|
|
236
|
+
reason: "hook's own grant",
|
|
237
|
+
}) as { db: TenantDb };
|
|
238
|
+
expect(hookCtxWithEscapeHatch.db.unsafeRaw("test reason")).toBe(rawDb);
|
|
239
|
+
});
|
|
195
240
|
});
|
|
@@ -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,10 @@ 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
|
-
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 or query escapeHatch.
|
|
278
|
+
const writeEscapeHatch = registry.getWriteHandler(type)?.escapeHatch;
|
|
279
|
+
const handlerEscapeHatch = writeEscapeHatch ?? registry.getQueryHandler(type)?.escapeHatch;
|
|
280
|
+
const allowSystemIdentity = isSystem || handlerEscapeHatch !== undefined;
|
|
221
281
|
const buildTenantScopedDb = (source: DbConnection | DbTx, signal: AbortSignal | undefined) =>
|
|
222
282
|
createTenantDb(
|
|
223
283
|
source,
|
|
@@ -226,7 +286,7 @@ export async function buildHandlerContext(
|
|
|
226
286
|
context.tracer,
|
|
227
287
|
context.meter,
|
|
228
288
|
signal,
|
|
229
|
-
|
|
289
|
+
{ globalWrites: writeEscapeHatch, unsafeRaw: handlerEscapeHatch },
|
|
230
290
|
);
|
|
231
291
|
// Propagate the request's AbortSignal so every TenantDb query throws when
|
|
232
292
|
// the client has disconnected — handlers with many sequential queries skip
|
|
@@ -326,6 +386,17 @@ export async function buildHandlerContext(
|
|
|
326
386
|
writeAs: (asUser: SessionUser, targetType: string, payload: unknown) =>
|
|
327
387
|
executeWrite(ctx, targetType, payload, asUser, tx, bridgeSink),
|
|
328
388
|
});
|
|
389
|
+
// Lazy — creates the reader (which fails closed on SYSTEM_TENANT_ID) only
|
|
390
|
+
// on first actual use, not on every HandlerContext build.
|
|
391
|
+
let ungatedMemberReader: MemberReader | undefined;
|
|
392
|
+
const queryAsMember = createGatedMemberReader(
|
|
393
|
+
`handler "${type}"`,
|
|
394
|
+
allowSystemIdentity,
|
|
395
|
+
(userId, qn, payload) => {
|
|
396
|
+
ungatedMemberReader ??= createMemberReaderFn(ctx, user.tenantId, tx);
|
|
397
|
+
return ungatedMemberReader(userId, qn, payload);
|
|
398
|
+
},
|
|
399
|
+
);
|
|
329
400
|
const bridge = {
|
|
330
401
|
query: (targetType: string, payload: unknown) =>
|
|
331
402
|
executeQuery(ctx, targetType, payload, user, tx), // @wrapper-known semantic-alias
|
|
@@ -604,6 +675,9 @@ export async function buildHandlerContext(
|
|
|
604
675
|
return resolveActiveMembershipFn(ctx, userId, tenantId, INTERACTIVE_SIGN_IN_POLICY); // @wrapper-known semantic-alias
|
|
605
676
|
},
|
|
606
677
|
|
|
678
|
+
// Needs the same grant as a SYSTEM queryAs.
|
|
679
|
+
queryAsMember,
|
|
680
|
+
|
|
607
681
|
// Feature-effective check for in-handler opt-in logic. Scope:
|
|
608
682
|
// **current user's tenant** — for cross-tenant lookups (rare,
|
|
609
683
|
// SysAdmin operations) read effectiveFeatures(otherTenantId) directly.
|
|
@@ -688,7 +762,7 @@ export async function buildHandlerContext(
|
|
|
688
762
|
user.locale !== undefined && isValidLocaleTag(user.locale) ? user.locale : undefined;
|
|
689
763
|
const locale = reqCtx?.locale ?? safeUserLocale ?? context.defaultLocale ?? DEFAULT_LOCALE;
|
|
690
764
|
|
|
691
|
-
|
|
765
|
+
const handlerContext = {
|
|
692
766
|
...context,
|
|
693
767
|
registry,
|
|
694
768
|
db: exposedDb,
|
|
@@ -740,6 +814,11 @@ export async function buildHandlerContext(
|
|
|
740
814
|
...(includeDeleted && { includeDeleted: true }),
|
|
741
815
|
...bridge,
|
|
742
816
|
} as HandlerContext; // @cast-boundary engine-bridge
|
|
817
|
+
|
|
818
|
+
// A resolved member principal is read-only by construction.
|
|
819
|
+
return user.origin === "member-resolution"
|
|
820
|
+
? applyMemberResolutionReadOnly(handlerContext)
|
|
821
|
+
: handlerContext;
|
|
743
822
|
}
|
|
744
823
|
|
|
745
824
|
// Wrap handler execution in a dispatcher.handler span AND emit the standard
|
|
@@ -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
|
+
}
|