@cosmicdrift/kumiko-framework 0.269.2 → 0.273.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/README.md +5 -1
- package/package.json +3 -3
- package/src/__tests__/anonymous-access.integration.test.ts +1 -1
- package/src/__tests__/error-contract.integration.test.ts +1 -1
- package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.expected.ts +44 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.input.ts +44 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.expected.ts +41 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.input.ts +41 -0
- package/src/__tests__/fixtures/migrate-open-to-all/manual-only.expected.ts +16 -0
- package/src/__tests__/fixtures/migrate-open-to-all/manual-only.input.ts +16 -0
- package/src/__tests__/fixtures/migrate-open-to-all/test-nested-shorthand.expected.ts +12 -0
- package/src/__tests__/fixtures/migrate-open-to-all/test-nested-shorthand.input.ts +12 -0
- package/src/__tests__/fixtures/migrate-open-to-all/test-simple.expected.ts +7 -0
- package/src/__tests__/fixtures/migrate-open-to-all/test-simple.input.ts +7 -0
- package/src/__tests__/full-stack.integration.test.ts +2 -2
- package/src/__tests__/migrate-cross-tenant-codemod.test.ts +106 -0
- package/src/__tests__/migrate-open-to-all-codemod.test.ts +84 -0
- package/src/__tests__/related-list-parent-filter.integration.test.ts +103 -0
- package/src/__tests__/transition-guard.integration.test.ts +7 -7
- package/src/api/__tests__/api.test.ts +2 -2
- package/src/api/__tests__/body-limit.test.ts +1 -1
- package/src/api/__tests__/nested-write-ownership.integration.test.ts +268 -0
- package/src/api/__tests__/pii-leak-guard.integration.test.ts +2 -2
- package/src/api/__tests__/request-locale.integration.test.ts +1 -1
- package/src/api/__tests__/server-error-logging.test.ts +1 -1
- package/src/api/server.ts +7 -20
- package/src/bun-db/__tests__/query-guards.test.ts +60 -3
- package/src/bun-db/query.ts +24 -21
- package/src/changes.json +99 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +77 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +1 -1
- package/src/db/__tests__/list-filter-field-access.integration.test.ts +1 -1
- package/src/db/__tests__/replay-migration-sql.test.ts +17 -0
- package/src/db/__tests__/tenant-db-convention-cross-tenant.test.ts +72 -0
- package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +104 -0
- package/src/db/__tests__/tenant-db-global.test.ts +92 -0
- package/src/db/__tests__/tenant-db-no-raw.test.ts +75 -0
- package/src/db/__tests__/tenant-db.integration.test.ts +3 -1
- package/src/db/__tests__/unchecked-system-db.test.ts +76 -0
- package/src/db/apply-entity-event.ts +1 -1
- package/src/db/assert-exists-in.ts +1 -1
- package/src/db/event-store-executor-context.ts +5 -2
- package/src/db/event-store-executor-read.ts +6 -4
- package/src/db/event-store-executor-write.ts +24 -18
- package/src/db/queries/shadow-swap.ts +36 -0
- package/src/db/replay-migration-sql.ts +5 -5
- package/src/db/tenant-db-runner.ts +23 -0
- package/src/db/tenant-db.ts +97 -24
- package/src/derivatives/__tests__/derivatives-context.integration.test.ts +2 -2
- package/src/derivatives/__tests__/derivatives-context.test.ts +0 -1
- package/src/engine/__tests__/boot-validator-dashboard.test.ts +5 -5
- package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-projection-list.test.ts +108 -17
- package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +161 -18
- package/src/engine/__tests__/boot-validator-query-refs.test.ts +3 -3
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
- package/src/engine/__tests__/boot-validator.test.ts +69 -18
- package/src/engine/__tests__/build-app-schema.test.ts +10 -6
- package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +48 -0
- package/src/engine/__tests__/build-config-feature-schema.test.ts +34 -5
- package/src/engine/__tests__/engine.test.ts +39 -25
- package/src/engine/__tests__/entity-create-caller-id.integration.test.ts +87 -0
- package/src/engine/__tests__/entity-handlers-escape-hatch.test.ts +91 -0
- package/src/engine/__tests__/entity-handlers.test.ts +47 -8
- package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +2 -2
- package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +30 -5
- package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +206 -47
- package/src/engine/__tests__/extends-registrar.test.ts +3 -3
- package/src/engine/__tests__/feature-crud-shorthand.test.ts +9 -3
- package/src/engine/__tests__/feature-crud-verb-access.integration.test.ts +1 -1
- package/src/engine/__tests__/hook-phases.test.ts +5 -5
- package/src/engine/__tests__/lifecycle-hooks.test.ts +9 -3
- package/src/engine/__tests__/multiselect-filter.integration.test.ts +1 -1
- package/src/engine/__tests__/post-query-hook.test.ts +6 -2
- package/src/engine/__tests__/projection-detail-actions.test.ts +2 -2
- package/src/engine/__tests__/projection-detail-singleton.test.ts +3 -3
- package/src/engine/__tests__/projection-detail-tabs.test.ts +3 -3
- package/src/engine/__tests__/projection-list-filter.integration.test.ts +1 -1
- package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +215 -0
- package/src/engine/__tests__/registry.test.ts +19 -13
- package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
- package/src/engine/__tests__/screen.test.ts +176 -20
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -1
- package/src/engine/__tests__/steps-read.test.ts +204 -8
- package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +7 -4
- package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +6 -3
- package/src/engine/boot-validator/__tests__/access-declarations.test.ts +5 -4
- package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +16 -1
- package/src/engine/boot-validator/__tests__/parent-ref.test.ts +18 -6
- package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
- package/src/engine/boot-validator/access-declarations.ts +3 -5
- package/src/engine/boot-validator/entity-handler.ts +21 -0
- package/src/engine/boot-validator/global-tenancy.ts +13 -1
- package/src/engine/boot-validator/nav.ts +4 -3
- package/src/engine/boot-validator/parent-ref.ts +1 -1
- package/src/engine/boot-validator/projection-list-screens.ts +67 -2
- package/src/engine/boot-validator/query-output-columns.ts +33 -0
- package/src/engine/boot-validator/screens.ts +154 -31
- package/src/engine/build-config-feature-schema.ts +12 -2
- package/src/engine/entity-handlers.ts +72 -20
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +3 -1
- package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +2 -2
- package/src/engine/feature-ast/__tests__/parse.test.ts +106 -4
- package/src/engine/feature-ast/__tests__/patcher.test.ts +3 -3
- package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +4 -4
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +5 -1
- package/src/engine/feature-ast/extractors/handlers.ts +16 -2
- package/src/engine/feature-ast/extractors/hooks.ts +25 -4
- package/src/engine/feature-ast/patcher.ts +15 -3
- package/src/engine/feature-ast/patterns.ts +4 -0
- package/src/engine/feature-ast/render.ts +3 -0
- package/src/engine/feature-entity-handlers.ts +4 -4
- package/src/engine/index.ts +5 -0
- package/src/engine/pattern-library/shared-fields.ts +7 -3
- package/src/engine/registry-validate.ts +12 -4
- package/src/engine/schema-builder.ts +6 -0
- package/src/engine/screen-helpers.ts +52 -0
- package/src/engine/steps/_read-source.ts +12 -0
- package/src/engine/steps/read-find-many.ts +8 -2
- package/src/engine/steps/read-find-one.ts +11 -8
- package/src/engine/steps/unsafe-projection-delete.ts +2 -1
- package/src/engine/steps/unsafe-projection-upsert.ts +2 -1
- package/src/engine/types/index.ts +8 -1
- package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +1 -1
- package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +3 -3
- package/src/entrypoint/__tests__/split-deploy.integration.test.ts +1 -1
- package/src/errors/i18n/de.yaml +14 -0
- package/src/errors/i18n/en.yaml +13 -0
- package/src/errors/reasons.ts +4 -0
- package/src/es-ops/__tests__/runner.integration.test.ts +2 -2
- package/src/i18n/required-surface-keys.ts +6 -2
- package/src/jobs/__tests__/job-event-trigger.integration.test.ts +2 -2
- package/src/jobs/__tests__/job-multi-trigger.integration.test.ts +2 -2
- package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
- package/src/jobs/__tests__/jobs.integration.test.ts +42 -1
- package/src/jobs/job-runner.ts +35 -5
- package/src/observability/__tests__/observability.integration.test.ts +7 -7
- package/src/pipeline/__tests__/archive-stream.integration.test.ts +2 -2
- package/src/pipeline/__tests__/dispatcher.test.ts +15 -15
- package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +254 -0
- package/src/pipeline/__tests__/escape-hatch-report.test.ts +149 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +10 -10
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -1
- package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +28 -0
- package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
- package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +94 -0
- package/src/pipeline/__tests__/projections-runner-systemscope.integration.test.ts +101 -0
- package/src/pipeline/__tests__/query-projection.integration.test.ts +172 -17
- package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +118 -0
- package/src/pipeline/__tests__/rate-limit-default.test.ts +117 -0
- package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +5 -2
- package/src/pipeline/__tests__/system-identity-switch.test.ts +240 -1
- package/src/pipeline/dispatch-query.ts +27 -8
- package/src/pipeline/dispatch-shared.ts +141 -57
- package/src/pipeline/dispatch-stream.ts +1 -3
- package/src/pipeline/dispatch-write.ts +62 -14
- package/src/pipeline/dispatcher.ts +20 -0
- package/src/pipeline/escape-hatch-report.ts +127 -0
- package/src/pipeline/index.ts +8 -0
- package/src/pipeline/member-read-only-transaction.ts +75 -0
- package/src/pipeline/msp-rebuild.ts +2 -0
- package/src/pipeline/projection-rebuild.ts +2 -0
- package/src/pipeline/projections-runner.ts +15 -13
- package/src/pipeline/system-identity-switch.ts +121 -9
- package/src/scripts/codemod/migrate-cross-tenant.ts +335 -0
- package/src/scripts/codemod/migrate-db-raw.ts +19 -6
- package/src/scripts/codemod/migrate-open-to-all.ts +249 -0
- package/src/secrets/__tests__/contains-secret.test.ts +5 -5
- package/src/stack/__tests__/request-helper.integration.test.ts +3 -3
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +1 -1
- package/src/testing/__tests__/e2e-generator.test.ts +19 -5
- package/src/ui-types/index.ts +3 -0
- package/src/utils/__tests__/ids.test.ts +29 -1
- package/src/utils/ids.ts +10 -1
- package/src/utils/index.ts +1 -1
|
@@ -2,18 +2,39 @@ import { describe, expect, mock, test } from "bun:test";
|
|
|
2
2
|
import type { DbRunner } from "../../db/connection";
|
|
3
3
|
import { createTenantDb, type TenantDb } from "../../db/tenant-db";
|
|
4
4
|
import { createSystemUser, SYSTEM_ROLE, SYSTEM_USER_ID } from "../../engine";
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
EscapeHatchReporter,
|
|
7
|
+
EscapeHatchTarget,
|
|
8
|
+
MemberReader,
|
|
9
|
+
SessionUser,
|
|
10
|
+
} from "../../engine/types";
|
|
6
11
|
import type { TenantId } from "../../engine/types/identifiers";
|
|
7
12
|
import { AccessDeniedError, FrameworkReasons } from "../../errors";
|
|
8
13
|
import {
|
|
9
14
|
createGatedIdentitySwitch,
|
|
15
|
+
createGatedMemberReader,
|
|
16
|
+
createGatedProjectionReader,
|
|
10
17
|
type IdentitySwitch,
|
|
11
18
|
isIdentitySwitchAllowed,
|
|
12
19
|
isSystemIdentity,
|
|
20
|
+
type ProjectionReader,
|
|
13
21
|
type WriteAsFn,
|
|
14
22
|
withHookEscapeHatchGrant,
|
|
15
23
|
} from "../system-identity-switch";
|
|
16
24
|
|
|
25
|
+
function recordingReporter(): {
|
|
26
|
+
readonly report: EscapeHatchReporter;
|
|
27
|
+
readonly calls: Array<{ kind: string; reason: string; target: EscapeHatchTarget | undefined }>;
|
|
28
|
+
} {
|
|
29
|
+
const calls: Array<{ kind: string; reason: string; target: EscapeHatchTarget | undefined }> = [];
|
|
30
|
+
return {
|
|
31
|
+
report: (kind, reason, target) => {
|
|
32
|
+
calls.push({ kind, reason, target });
|
|
33
|
+
},
|
|
34
|
+
calls,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
17
38
|
const TENANT = "00000000-0000-4000-8000-00000000ab01" as TenantId;
|
|
18
39
|
|
|
19
40
|
const normalUser: SessionUser = { id: crypto.randomUUID(), tenantId: TENANT, roles: ["User"] };
|
|
@@ -224,6 +245,69 @@ describe("createGatedIdentitySwitch", () => {
|
|
|
224
245
|
});
|
|
225
246
|
});
|
|
226
247
|
|
|
248
|
+
function makeUngatedProjectionReader(): {
|
|
249
|
+
readonly ungated: ProjectionReader;
|
|
250
|
+
readonly mockFn: ReturnType<typeof mock>;
|
|
251
|
+
} {
|
|
252
|
+
const mockFn = mock(async (_qn: string, _options?: { readonly unsafeAllTenants?: boolean }) => [
|
|
253
|
+
{ ok: true },
|
|
254
|
+
]);
|
|
255
|
+
return { ungated: mockFn as unknown as ProjectionReader, mockFn };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
describe("createGatedProjectionReader", () => {
|
|
259
|
+
test("without a grant, unsafeAllTenants: true throws AccessDeniedError and never calls ungated", async () => {
|
|
260
|
+
const { ungated, mockFn } = makeUngatedProjectionReader();
|
|
261
|
+
const gated = createGatedProjectionReader('handler "x"', false, ungated);
|
|
262
|
+
|
|
263
|
+
let caught: unknown;
|
|
264
|
+
try {
|
|
265
|
+
await gated("qp:projection:widgets", { unsafeAllTenants: true });
|
|
266
|
+
} catch (err) {
|
|
267
|
+
caught = err;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
expect(caught).toBeInstanceOf(AccessDeniedError);
|
|
271
|
+
const error = caught as AccessDeniedError;
|
|
272
|
+
expect(error.details).toEqual({ reason: FrameworkReasons.unsafeAllTenantsDenied });
|
|
273
|
+
expect(mockFn).not.toHaveBeenCalled();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("without the unsafeAllTenants option, calls ungated directly with no report", async () => {
|
|
277
|
+
const { ungated, mockFn } = makeUngatedProjectionReader();
|
|
278
|
+
const { report, calls } = recordingReporter();
|
|
279
|
+
const gated = createGatedProjectionReader('handler "x"', false, ungated, {
|
|
280
|
+
reason: "escapeHatch reason",
|
|
281
|
+
report,
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
await gated("qp:projection:widgets");
|
|
285
|
+
|
|
286
|
+
expect(mockFn).toHaveBeenCalledWith("qp:projection:widgets", undefined);
|
|
287
|
+
expect(calls).toEqual([]);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("with a grant, unsafeAllTenants: true calls ungated and reports exactly once", async () => {
|
|
291
|
+
const { ungated, mockFn } = makeUngatedProjectionReader();
|
|
292
|
+
const { report, calls } = recordingReporter();
|
|
293
|
+
const gated = createGatedProjectionReader('handler "x"', true, ungated, {
|
|
294
|
+
reason: 'r.systemScope() feature "widgets"',
|
|
295
|
+
report,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
await gated("qp:projection:widgets", { unsafeAllTenants: true });
|
|
299
|
+
|
|
300
|
+
expect(mockFn).toHaveBeenCalledWith("qp:projection:widgets", { unsafeAllTenants: true });
|
|
301
|
+
expect(calls).toEqual([
|
|
302
|
+
{
|
|
303
|
+
kind: "unsafe-all-tenants",
|
|
304
|
+
reason: 'r.systemScope() feature "widgets"',
|
|
305
|
+
target: undefined,
|
|
306
|
+
},
|
|
307
|
+
]);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
227
311
|
describe("withHookEscapeHatchGrant", () => {
|
|
228
312
|
test("hook without escapeHatch does NOT inherit the handler's SYSTEM grant", async () => {
|
|
229
313
|
const { ungated } = makeUngated();
|
|
@@ -403,4 +487,159 @@ describe("withHookEscapeHatchGrant", () => {
|
|
|
403
487
|
}) as { db: TenantDb };
|
|
404
488
|
expect(hookCtxWithEscapeHatch.db.unsafeRaw("test reason")).toBe(rawDb);
|
|
405
489
|
});
|
|
490
|
+
|
|
491
|
+
test("handler-granted queryProjection reader: hook without escapeHatch loses the grant for unsafeAllTenants", async () => {
|
|
492
|
+
const { ungated, mockFn } = makeUngatedProjectionReader();
|
|
493
|
+
const handlerCtx = {
|
|
494
|
+
queryProjection: createGatedProjectionReader('handler "outer"', true, ungated),
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
const hookCtx = withHookEscapeHatchGrant(handlerCtx, "hook", undefined) as {
|
|
498
|
+
queryProjection: ProjectionReader;
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
await expect(
|
|
502
|
+
hookCtx.queryProjection("qp:projection:widgets", { unsafeAllTenants: true }),
|
|
503
|
+
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
504
|
+
expect(mockFn).not.toHaveBeenCalled();
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("hook's own escapeHatch grants queryProjection's unsafeAllTenants and reports with the hook's reason", async () => {
|
|
508
|
+
const { ungated, mockFn } = makeUngatedProjectionReader();
|
|
509
|
+
const { report, calls } = recordingReporter();
|
|
510
|
+
const handlerCtx = {
|
|
511
|
+
queryProjection: createGatedProjectionReader('handler "outer"', false, ungated, {
|
|
512
|
+
reason: undefined,
|
|
513
|
+
report,
|
|
514
|
+
}),
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
const hookCtx = withHookEscapeHatchGrant(handlerCtx, "hook", {
|
|
518
|
+
reason: "hook's own grant",
|
|
519
|
+
}) as { queryProjection: ProjectionReader };
|
|
520
|
+
|
|
521
|
+
await hookCtx.queryProjection("qp:projection:widgets", { unsafeAllTenants: true });
|
|
522
|
+
|
|
523
|
+
expect(mockFn).toHaveBeenCalledWith("qp:projection:widgets", { unsafeAllTenants: true });
|
|
524
|
+
expect(calls).toEqual([
|
|
525
|
+
{ kind: "unsafe-all-tenants", reason: "hook's own grant", target: undefined },
|
|
526
|
+
]);
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
describe("createGatedIdentitySwitch — escape-hatch reporting", () => {
|
|
531
|
+
test("granted switch to SYSTEM reports identity-switch once with the target", async () => {
|
|
532
|
+
const { ungated } = makeUngated();
|
|
533
|
+
const { report, calls } = recordingReporter();
|
|
534
|
+
const gated = createGatedIdentitySwitch('handler "x"', normalUser, true, ungated, {
|
|
535
|
+
reason: 'r.systemScope() feature "widgets"',
|
|
536
|
+
report,
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
await gated.queryAs(systemUserById, "q", {});
|
|
540
|
+
|
|
541
|
+
expect(calls).toEqual([
|
|
542
|
+
{
|
|
543
|
+
kind: "identity-switch",
|
|
544
|
+
reason: 'r.systemScope() feature "widgets"',
|
|
545
|
+
target: { id: systemUserById.id, tenantId: systemUserById.tenantId },
|
|
546
|
+
},
|
|
547
|
+
]);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test("self-delegation with a grant reports nothing", async () => {
|
|
551
|
+
const { ungated } = makeUngated();
|
|
552
|
+
const { report, calls } = recordingReporter();
|
|
553
|
+
const gated = createGatedIdentitySwitch('handler "x"', normalUser, true, ungated, {
|
|
554
|
+
reason: "escapeHatch reason",
|
|
555
|
+
report,
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
await gated.queryAs(normalUser, "q", {});
|
|
559
|
+
|
|
560
|
+
expect(calls).toEqual([]);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test("a denied switch reports nothing", async () => {
|
|
564
|
+
const { ungated } = makeUngated();
|
|
565
|
+
const { report, calls } = recordingReporter();
|
|
566
|
+
const gated = createGatedIdentitySwitch('handler "x"', normalUser, false, ungated, {
|
|
567
|
+
reason: "escapeHatch reason",
|
|
568
|
+
report,
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
await expect(gated.queryAs(systemUserById, "q", {})).rejects.toBeInstanceOf(AccessDeniedError);
|
|
572
|
+
|
|
573
|
+
expect(calls).toEqual([]);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test("hook re-gating via withHookEscapeHatchGrant reports with the HOOK's reason", async () => {
|
|
577
|
+
const { ungated } = makeUngated();
|
|
578
|
+
const { report, calls } = recordingReporter();
|
|
579
|
+
const handlerCtx = {
|
|
580
|
+
...createGatedIdentitySwitch('handler "outer"', normalUser, true, ungated, {
|
|
581
|
+
reason: "handler's own grant",
|
|
582
|
+
report,
|
|
583
|
+
}),
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
const hookCtx = withHookEscapeHatchGrant(handlerCtx, "hook", { reason: "hook's own grant" });
|
|
587
|
+
await hookCtx.queryAs(systemUserById, "q", {});
|
|
588
|
+
|
|
589
|
+
expect(calls).toEqual([
|
|
590
|
+
{
|
|
591
|
+
kind: "identity-switch",
|
|
592
|
+
reason: "hook's own grant",
|
|
593
|
+
target: { id: systemUserById.id, tenantId: systemUserById.tenantId },
|
|
594
|
+
},
|
|
595
|
+
]);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test("hook re-gates and reports even when the handler itself has no grant (reason: undefined)", async () => {
|
|
599
|
+
const { ungated } = makeUngated();
|
|
600
|
+
const { report, calls } = recordingReporter();
|
|
601
|
+
// Handler has NO grant (hasGrant=false, reason: undefined) — the report fn
|
|
602
|
+
// must still be attached so a hook's own escapeHatch can use it.
|
|
603
|
+
const handlerCtx = {
|
|
604
|
+
...createGatedIdentitySwitch('handler "outer"', normalUser, false, ungated, {
|
|
605
|
+
reason: undefined,
|
|
606
|
+
report,
|
|
607
|
+
}),
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
const hookCtx = withHookEscapeHatchGrant(handlerCtx, "hook", { reason: "hook reason" });
|
|
611
|
+
await hookCtx.queryAs(systemUserById, "q", {});
|
|
612
|
+
|
|
613
|
+
expect(calls).toEqual([
|
|
614
|
+
{
|
|
615
|
+
kind: "identity-switch",
|
|
616
|
+
reason: "hook reason",
|
|
617
|
+
target: { id: systemUserById.id, tenantId: systemUserById.tenantId },
|
|
618
|
+
},
|
|
619
|
+
]);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
test("queryAsMember hook re-gates and reports even when the handler itself has no grant", async () => {
|
|
623
|
+
const ungatedReader: MemberReader = mock(async () => ({ ok: true }));
|
|
624
|
+
const { report, calls } = recordingReporter();
|
|
625
|
+
const gatedReader = createGatedMemberReader('handler "outer"', false, ungatedReader, {
|
|
626
|
+
reason: undefined,
|
|
627
|
+
report,
|
|
628
|
+
tenantId: TENANT,
|
|
629
|
+
});
|
|
630
|
+
const handlerCtx = { queryAsMember: gatedReader };
|
|
631
|
+
|
|
632
|
+
const hookCtx = withHookEscapeHatchGrant(handlerCtx, "hook", { reason: "hook reason" }) as {
|
|
633
|
+
queryAsMember: MemberReader;
|
|
634
|
+
};
|
|
635
|
+
await hookCtx.queryAsMember(systemUserById.id, "q", {});
|
|
636
|
+
|
|
637
|
+
expect(calls).toEqual([
|
|
638
|
+
{
|
|
639
|
+
kind: "identity-switch",
|
|
640
|
+
reason: "hook reason",
|
|
641
|
+
target: { id: systemUserById.id, tenantId: TENANT },
|
|
642
|
+
},
|
|
643
|
+
]);
|
|
644
|
+
});
|
|
406
645
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { DbRow, DbTx } from "../db/connection";
|
|
2
2
|
import { hasAccess } from "../engine/access";
|
|
3
3
|
import { filterReadFields } from "../engine/field-access";
|
|
4
|
-
import type { SessionUser } from "../engine/types";
|
|
4
|
+
import type { QueryHandlerDef, SessionUser } from "../engine/types";
|
|
5
5
|
import { AccessDeniedError, NotFoundError, validationErrorFromZod } from "../errors";
|
|
6
6
|
import { assertNoSecretLeak } from "../secrets";
|
|
7
7
|
import type { DispatchContext } from "./dispatch-shared";
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
ensureFeatureEnabled,
|
|
12
12
|
runHandlerInstrumented,
|
|
13
13
|
} from "./dispatch-shared";
|
|
14
|
+
import { runInMemberReadOnlyTransaction } from "./member-read-only-transaction";
|
|
14
15
|
|
|
15
16
|
// Standalone query execution — used by the public dispatcher.query() and
|
|
16
17
|
// by ctx.query/ctx.queryAs inside handlers. Runs the handler, applies
|
|
@@ -48,12 +49,11 @@ async function executeQueryInner(
|
|
|
48
49
|
// unauthorized callers must hit the cap too (otherwise the limit
|
|
49
50
|
// would be a free probe-detector for valid credentials). The
|
|
50
51
|
// resolver throws RateLimitError which the dispatcher's outer
|
|
51
|
-
// wrapper turns into a 429 response.
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
52
|
+
// wrapper turns into a 429 response. Apps that don't use L3 pay zero
|
|
53
|
+
// cost for non-systemScope handlers with no rateLimit declared;
|
|
54
|
+
// systemScope handlers pay one isHandlerSystemScoped lookup to check
|
|
55
|
+
// whether the default per-tenant limit applies.
|
|
56
|
+
await enforceRateLimit(ctx, handler.rateLimit, type, user, registry.isHandlerSystemScoped(type));
|
|
57
57
|
|
|
58
58
|
// Default-deny: missing access rule is treated as "no one has access".
|
|
59
59
|
// The registry boot-validator refuses to register handlers without one,
|
|
@@ -79,8 +79,27 @@ async function executeQueryInner(
|
|
|
79
79
|
typeof parsed.data === "object" &&
|
|
80
80
|
parsed.data !== null &&
|
|
81
81
|
(parsed.data as Record<string, unknown>)["includeDeleted"] === true; // @cast-boundary validated-payload
|
|
82
|
+
|
|
83
|
+
// A resolved member (ctx.queryAsMember) runs in a Postgres READ ONLY transaction, not just the ctx surface below.
|
|
84
|
+
return user.origin === "member-resolution"
|
|
85
|
+
? runInMemberReadOnlyTransaction(ctx, tx, (readOnlyTx) =>
|
|
86
|
+
runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, readOnlyTx),
|
|
87
|
+
)
|
|
88
|
+
: runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, tx);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function runQueryHandler(
|
|
92
|
+
ctx: DispatchContext,
|
|
93
|
+
type: string,
|
|
94
|
+
handler: QueryHandlerDef,
|
|
95
|
+
payload: unknown,
|
|
96
|
+
includeDeleted: boolean,
|
|
97
|
+
user: SessionUser,
|
|
98
|
+
tx: DbTx | undefined,
|
|
99
|
+
): Promise<unknown> {
|
|
100
|
+
const { registry } = ctx;
|
|
82
101
|
const handlerContext = await buildHandlerContext(ctx, type, user, tx, undefined, includeDeleted);
|
|
83
|
-
let result = await handler.handler({ type, payload
|
|
102
|
+
let result = await handler.handler({ type, payload, user }, handlerContext);
|
|
84
103
|
|
|
85
104
|
// postQuery-Hooks: fire BEFORE field-access-filter so hooks see raw data
|
|
86
105
|
// and can merge custom-fields/computed-counts/tags/etc. Each hook is
|
|
@@ -3,7 +3,12 @@ import type { SseBroker } from "../api/sse-broker";
|
|
|
3
3
|
import type { DbConnection, DbRunner, DbTx } from "../db/connection";
|
|
4
4
|
import { runInSavepoint, selectMany } from "../db/query";
|
|
5
5
|
import type { buildEntityTable } from "../db/table-builder";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
createTenantDb,
|
|
8
|
+
createUncheckedSystemDb,
|
|
9
|
+
hasTenantColumn,
|
|
10
|
+
type TenantDb,
|
|
11
|
+
} from "../db/tenant-db";
|
|
7
12
|
import { createDerivativesContext } from "../derivatives/derivatives-context";
|
|
8
13
|
import type { defineTransitions } from "../engine/state-machine";
|
|
9
14
|
import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
|
|
@@ -17,10 +22,13 @@ import type {
|
|
|
17
22
|
HandlerContext,
|
|
18
23
|
JobRunnerRef,
|
|
19
24
|
MemberReader,
|
|
25
|
+
RateLimitDeclaration,
|
|
26
|
+
RateLimitOption,
|
|
20
27
|
Registry,
|
|
21
28
|
SessionUser,
|
|
22
29
|
WriteResult,
|
|
23
30
|
} from "../engine/types";
|
|
31
|
+
import { isRateLimitDisabled } from "../engine/types";
|
|
24
32
|
import type { TenantId } from "../engine/types/identifiers";
|
|
25
33
|
import {
|
|
26
34
|
AccessDeniedError,
|
|
@@ -77,12 +85,16 @@ import {
|
|
|
77
85
|
dispatcherSpanAttributes,
|
|
78
86
|
isFailedWriteResult,
|
|
79
87
|
} from "./dispatcher-utils";
|
|
88
|
+
import { createEscapeHatchReporter, type EscapeHatchReportWindow } from "./escape-hatch-report";
|
|
80
89
|
import type { IdempotencyGuard } from "./idempotency";
|
|
81
90
|
import type { LifecycleHooks } from "./lifecycle-pipeline";
|
|
82
91
|
import { createMemberReaderFn } from "./member-reader";
|
|
83
92
|
import {
|
|
84
93
|
createGatedIdentitySwitch,
|
|
85
94
|
createGatedMemberReader,
|
|
95
|
+
createGatedProjectionReader,
|
|
96
|
+
isSystemIdentity,
|
|
97
|
+
type ProjectionReader,
|
|
86
98
|
systemIdentitySwitchDenied,
|
|
87
99
|
} from "./system-identity-switch";
|
|
88
100
|
import type { TenantTimezoneCache } from "./tenant-timezone-cache";
|
|
@@ -130,6 +142,7 @@ export type DispatchContext = {
|
|
|
130
142
|
tableCache: Map<string, ReturnType<typeof buildEntityTable>>;
|
|
131
143
|
transitionCache: Map<string, ReturnType<typeof defineTransitions>>;
|
|
132
144
|
tenantTimezoneCache: TenantTimezoneCache;
|
|
145
|
+
escapeHatchReportWindow: EscapeHatchReportWindow;
|
|
133
146
|
tracer: ReturnType<typeof getFallbackTracer>;
|
|
134
147
|
meter: ReturnType<typeof getFallbackMeter>;
|
|
135
148
|
// Qualified name of the membership-list query handler consulted by
|
|
@@ -206,10 +219,11 @@ function createSystemScopedDbGuard(
|
|
|
206
219
|
});
|
|
207
220
|
}
|
|
208
221
|
|
|
209
|
-
export function memberResolutionReadOnlyDenied(): AccessDeniedError {
|
|
222
|
+
export function memberResolutionReadOnlyDenied(cause?: unknown): AccessDeniedError {
|
|
210
223
|
return new AccessDeniedError({
|
|
211
224
|
message: "a resolved member principal (ctx.queryAsMember) cannot write — read-only",
|
|
212
225
|
details: { reason: FrameworkReasons.memberResolutionReadOnly },
|
|
226
|
+
...(cause instanceof Error && { cause }),
|
|
213
227
|
});
|
|
214
228
|
}
|
|
215
229
|
|
|
@@ -232,11 +246,12 @@ function denyingJobRunnerProxy(): JobRunnerRef {
|
|
|
232
246
|
function applyMemberResolutionReadOnly(handlerContext: HandlerContext): HandlerContext {
|
|
233
247
|
return {
|
|
234
248
|
...handlerContext,
|
|
235
|
-
// `db`
|
|
236
|
-
// raw ctx.db writes inside a queried handler are not blocked by this.
|
|
249
|
+
// `db` stays open — executeQuery runs the whole handler in a Postgres READ ONLY transaction.
|
|
237
250
|
dbOutsideTransaction: undefined,
|
|
238
251
|
write: denyMemberResolutionWrite,
|
|
239
252
|
writeAs: denyMemberResolutionWrite,
|
|
253
|
+
// A resolved member cannot switch identity either — the target would get a normal, writable context.
|
|
254
|
+
queryAs: denyMemberResolutionWrite,
|
|
240
255
|
appendEvent: denyMemberResolutionWrite as AppendEventFn, // @cast-boundary engine-bridge
|
|
241
256
|
unsafeAppendEvent: denyMemberResolutionWrite,
|
|
242
257
|
tryAppendEvent: denyMemberResolutionWrite,
|
|
@@ -281,6 +296,18 @@ export async function buildHandlerContext(
|
|
|
281
296
|
registry.getQueryHandler(type)?.escapeHatch ??
|
|
282
297
|
registry.getStreamHandler(type)?.escapeHatch;
|
|
283
298
|
const hasIdentitySwitchGrant = isSystem || handlerEscapeHatch !== undefined;
|
|
299
|
+
const featureName = registry.getHandlerFeature(type);
|
|
300
|
+
const reportEscapeHatch = createEscapeHatchReporter({
|
|
301
|
+
handler: type,
|
|
302
|
+
tenantId: user.tenantId,
|
|
303
|
+
actor: user.id,
|
|
304
|
+
sink: context._escapeHatchAuditSink,
|
|
305
|
+
log: context.log,
|
|
306
|
+
window: ctx.escapeHatchReportWindow,
|
|
307
|
+
});
|
|
308
|
+
const identitySwitchGrantReason =
|
|
309
|
+
handlerEscapeHatch?.reason ??
|
|
310
|
+
(isSystem ? `r.systemScope() feature "${featureName ?? "unknown"}"` : undefined);
|
|
284
311
|
const buildTenantScopedDb = (source: DbConnection | DbTx, signal: AbortSignal | undefined) =>
|
|
285
312
|
createTenantDb(
|
|
286
313
|
source,
|
|
@@ -289,7 +316,7 @@ export async function buildHandlerContext(
|
|
|
289
316
|
context.tracer,
|
|
290
317
|
context.meter,
|
|
291
318
|
signal,
|
|
292
|
-
{ globalWrites: writeEscapeHatch, unsafeRaw: handlerEscapeHatch },
|
|
319
|
+
{ globalWrites: writeEscapeHatch, unsafeRaw: handlerEscapeHatch, report: reportEscapeHatch },
|
|
293
320
|
);
|
|
294
321
|
// Propagate the request's AbortSignal so every TenantDb query throws when
|
|
295
322
|
// the client has disconnected — handlers with many sequential queries skip
|
|
@@ -306,7 +333,9 @@ export async function buildHandlerContext(
|
|
|
306
333
|
? buildTenantScopedDb(outsideTxSource, undefined)
|
|
307
334
|
: undefined;
|
|
308
335
|
const systemDb =
|
|
309
|
-
isSystem && db
|
|
336
|
+
isSystem && db
|
|
337
|
+
? createUncheckedSystemDb(db, rawDbOutsideTransaction, reportEscapeHatch)
|
|
338
|
+
: undefined;
|
|
310
339
|
// Exposed as ctx.db below — the internal `db` above stays the real,
|
|
311
340
|
// working TenantDb for this function's own use (config/derivatives
|
|
312
341
|
// accessors, systemDb construction).
|
|
@@ -366,7 +395,6 @@ export async function buildHandlerContext(
|
|
|
366
395
|
// so legacy internal handlers don't crash.
|
|
367
396
|
const tracer = context.tracer ?? getFallbackTracer();
|
|
368
397
|
const meter = context.meter;
|
|
369
|
-
const featureName = registry.getHandlerFeature(type);
|
|
370
398
|
const metrics =
|
|
371
399
|
meter && featureName ? createMetricsHandle(meter, featureName) : createNoopMetricsHandle();
|
|
372
400
|
// ctx.metricsFor(featureName) — shared/library code binds to a feature
|
|
@@ -383,6 +411,7 @@ export async function buildHandlerContext(
|
|
|
383
411
|
const scheduleAfterCommit = (hook: AfterCommitHook): void => {
|
|
384
412
|
bridgeSink.push(hook);
|
|
385
413
|
};
|
|
414
|
+
const identitySwitchAudit = { reason: identitySwitchGrantReason, report: reportEscapeHatch };
|
|
386
415
|
const identitySwitch = createGatedIdentitySwitch(
|
|
387
416
|
`handler "${type}"`,
|
|
388
417
|
user,
|
|
@@ -393,6 +422,7 @@ export async function buildHandlerContext(
|
|
|
393
422
|
writeAs: (asUser: SessionUser, targetType: string, payload: unknown) =>
|
|
394
423
|
executeWrite(ctx, targetType, payload, asUser, tx, bridgeSink),
|
|
395
424
|
},
|
|
425
|
+
identitySwitchAudit,
|
|
396
426
|
);
|
|
397
427
|
// Lazy — creates the reader (which fails closed on SYSTEM_TENANT_ID) only
|
|
398
428
|
// on first actual use, not on every HandlerContext build.
|
|
@@ -404,6 +434,48 @@ export async function buildHandlerContext(
|
|
|
404
434
|
ungatedMemberReader ??= createMemberReaderFn(ctx, user.tenantId, tx);
|
|
405
435
|
return ungatedMemberReader(userId, qn, payload);
|
|
406
436
|
},
|
|
437
|
+
{ ...identitySwitchAudit, tenantId: user.tenantId },
|
|
438
|
+
);
|
|
439
|
+
const ungatedQueryProjection: ProjectionReader = async <T = Record<string, unknown>>(
|
|
440
|
+
qualifiedName: string,
|
|
441
|
+
queryOptions?: { readonly unsafeAllTenants?: boolean },
|
|
442
|
+
): Promise<readonly T[]> => {
|
|
443
|
+
// queryProjection works against both single-stream and multi-stream
|
|
444
|
+
// projections. MSPs without a table cannot be queried — those are
|
|
445
|
+
// side-effect-only consumers (no state to read back).
|
|
446
|
+
const singleProj = registry.getAllProjections().get(qualifiedName);
|
|
447
|
+
const mspProj = registry.getAllMultiStreamProjections().get(qualifiedName);
|
|
448
|
+
const projTable = singleProj?.table ?? mspProj?.table;
|
|
449
|
+
if (!projTable) {
|
|
450
|
+
const singleNames = [...registry.getAllProjections().keys()];
|
|
451
|
+
const mspNames = [...registry.getAllMultiStreamProjections().keys()].filter(
|
|
452
|
+
(n) => registry.getAllMultiStreamProjections().get(n)?.table,
|
|
453
|
+
);
|
|
454
|
+
const all = [...singleNames, ...mspNames];
|
|
455
|
+
throw new InternalError({
|
|
456
|
+
message:
|
|
457
|
+
`ctx.queryProjection("${qualifiedName}") — projection not registered, or it is a ` +
|
|
458
|
+
`table-less MSP (side-effect-only). Known queryable projections: ${all.join(", ") || "(none)"}`,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
const dbSource = resolveDbSource(ctx, tx);
|
|
462
|
+
if (!dbSource) {
|
|
463
|
+
throw new InternalError({
|
|
464
|
+
message: `ctx.queryProjection("${qualifiedName}") requires a database connection — none is configured.`,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
const where =
|
|
468
|
+
hasTenantColumn(projTable) && queryOptions?.unsafeAllTenants !== true
|
|
469
|
+
? { tenantId: user.tenantId }
|
|
470
|
+
: undefined;
|
|
471
|
+
const rows = await selectMany<Record<string, unknown>>(dbSource, projTable, where);
|
|
472
|
+
return rows as readonly T[]; // @cast-boundary engine-payload
|
|
473
|
+
};
|
|
474
|
+
const queryProjection = createGatedProjectionReader(
|
|
475
|
+
`handler "${type}"`,
|
|
476
|
+
hasIdentitySwitchGrant,
|
|
477
|
+
ungatedQueryProjection,
|
|
478
|
+
identitySwitchAudit,
|
|
407
479
|
);
|
|
408
480
|
const bridge = {
|
|
409
481
|
query: (targetType: string, payload: unknown) =>
|
|
@@ -630,44 +702,7 @@ export async function buildHandlerContext(
|
|
|
630
702
|
},
|
|
631
703
|
);
|
|
632
704
|
},
|
|
633
|
-
queryProjection
|
|
634
|
-
qualifiedName: string,
|
|
635
|
-
queryOptions?: { readonly unsafeAllTenants?: boolean },
|
|
636
|
-
): Promise<readonly T[]> => {
|
|
637
|
-
// queryProjection works against both single-stream and multi-stream
|
|
638
|
-
// projections. MSPs without a table cannot be queried — those are
|
|
639
|
-
// side-effect-only consumers (no state to read back).
|
|
640
|
-
const singleProj = registry.getAllProjections().get(qualifiedName);
|
|
641
|
-
const mspProj = registry.getAllMultiStreamProjections().get(qualifiedName);
|
|
642
|
-
const projTable = singleProj?.table ?? mspProj?.table;
|
|
643
|
-
if (!projTable) {
|
|
644
|
-
const singleNames = [...registry.getAllProjections().keys()];
|
|
645
|
-
const mspNames = [...registry.getAllMultiStreamProjections().keys()].filter(
|
|
646
|
-
(n) => registry.getAllMultiStreamProjections().get(n)?.table,
|
|
647
|
-
);
|
|
648
|
-
const all = [...singleNames, ...mspNames];
|
|
649
|
-
throw new InternalError({
|
|
650
|
-
message:
|
|
651
|
-
`ctx.queryProjection("${qualifiedName}") — projection not registered, or it is a ` +
|
|
652
|
-
`table-less MSP (side-effect-only). Known queryable projections: ${all.join(", ") || "(none)"}`,
|
|
653
|
-
});
|
|
654
|
-
}
|
|
655
|
-
const dbSource = resolveDbSource(ctx, tx);
|
|
656
|
-
if (!dbSource) {
|
|
657
|
-
throw new InternalError({
|
|
658
|
-
message: `ctx.queryProjection("${qualifiedName}") requires a database connection — none is configured.`,
|
|
659
|
-
});
|
|
660
|
-
}
|
|
661
|
-
// Introspect for a tenant_id column on the projection table. Auto-
|
|
662
|
-
// filter keeps cross-tenant leaks out unless the handler explicitly
|
|
663
|
-
// opts in. Works with any drizzle-table whose tenant column is named
|
|
664
|
-
// tenantId on the JS side.
|
|
665
|
-
const tenantCol = (projTable as Record<string, unknown>)["tenantId"];
|
|
666
|
-
const where =
|
|
667
|
-
tenantCol && !queryOptions?.unsafeAllTenants ? { tenantId: user.tenantId } : undefined;
|
|
668
|
-
const rows = await selectMany<Record<string, unknown>>(dbSource, projTable, where);
|
|
669
|
-
return rows as readonly T[]; // @cast-boundary engine-payload
|
|
670
|
-
},
|
|
705
|
+
queryProjection,
|
|
671
706
|
// Thin pass-through: one resolve impl lives on the dispatcher, the
|
|
672
707
|
// handler surface just forwards the call so both entry points (login
|
|
673
708
|
// handler via ctx.resolveAuthClaims, switch-tenant route via
|
|
@@ -1009,28 +1044,60 @@ export async function ensureFeatureEnabled(
|
|
|
1009
1044
|
if (err) throw err;
|
|
1010
1045
|
}
|
|
1011
1046
|
|
|
1047
|
+
// systemScope handlers get a default per-tenant limit unless the app declared its own or
|
|
1048
|
+
// opted out — but ONLY when a resolver is actually configured (an app without the
|
|
1049
|
+
// rate-limiting feature must not start throwing InternalError on every systemScope call
|
|
1050
|
+
// just because it never opted into L3), and never for a SYSTEM-identity caller (jobs,
|
|
1051
|
+
// replay, internal queryAs(SYSTEM,...) — those are not the tenant-facing traffic this
|
|
1052
|
+
// default protects against).
|
|
1053
|
+
// per: "tenant+handler" (not "tenant") — a per-handler bucket so one hot systemScope
|
|
1054
|
+
// handler cannot starve every other systemScope handler's shared tenant quota.
|
|
1055
|
+
const DEFAULT_SYSTEM_SCOPE_RATE_LIMIT: RateLimitOption = {
|
|
1056
|
+
per: "tenant+handler",
|
|
1057
|
+
limit: 600,
|
|
1058
|
+
windowSeconds: 60,
|
|
1059
|
+
};
|
|
1060
|
+
|
|
1061
|
+
function resolveEffectiveRateLimit(
|
|
1062
|
+
rateLimit: RateLimitOption | undefined,
|
|
1063
|
+
isSystemScope: boolean,
|
|
1064
|
+
user: SessionUser,
|
|
1065
|
+
resolver: AppContext["rateLimit"],
|
|
1066
|
+
): RateLimitOption | undefined {
|
|
1067
|
+
if (rateLimit) return rateLimit;
|
|
1068
|
+
if (!isSystemScope) return undefined;
|
|
1069
|
+
if (!resolver) return undefined;
|
|
1070
|
+
if (isSystemIdentity(user)) return undefined;
|
|
1071
|
+
return DEFAULT_SYSTEM_SCOPE_RATE_LIMIT;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1012
1074
|
// L3 rate limit gate. Called by both query and write paths before
|
|
1013
1075
|
// access-check. Reasoning:
|
|
1014
|
-
// - handler without rateLimit → no-op
|
|
1076
|
+
// - handler without rateLimit and not systemScope → no-op
|
|
1077
|
+
// - systemScope handler without rateLimit → DEFAULT_SYSTEM_SCOPE_RATE_LIMIT
|
|
1078
|
+
// applies, unless no resolver is configured or the caller is SYSTEM
|
|
1015
1079
|
// - app booted without rateLimit resolver → InternalError so the
|
|
1016
|
-
// misconfig surfaces immediately, not on first 429
|
|
1080
|
+
// misconfig surfaces immediately, not on first 429 (only reachable once
|
|
1081
|
+
// an effective rate limit was actually resolved above)
|
|
1017
1082
|
// - bucket builder returns "skip" (e.g. ip-based but no client IP):
|
|
1018
1083
|
// pass through. ip-modes are commonly used at L1/L2 middleware
|
|
1019
1084
|
// where the IP comes from Hono directly; falling back to "skip"
|
|
1020
1085
|
// here keeps non-HTTP entry-points (jobs, MSPs) functional.
|
|
1021
1086
|
export async function enforceRateLimit(
|
|
1022
1087
|
ctx: DispatchContext,
|
|
1023
|
-
rateLimit:
|
|
1088
|
+
rateLimit: RateLimitDeclaration | undefined,
|
|
1024
1089
|
handlerName: string,
|
|
1025
1090
|
user: SessionUser,
|
|
1091
|
+
isSystemScope: boolean,
|
|
1026
1092
|
): Promise<void> {
|
|
1027
1093
|
const { appContext: context } = ctx;
|
|
1028
|
-
// skip:
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1094
|
+
// skip: handler explicitly opts out via rateLimit: { disabled: true }
|
|
1095
|
+
if (isRateLimitDisabled(rateLimit)) return;
|
|
1096
|
+
const effective = resolveEffectiveRateLimit(rateLimit, isSystemScope, user, context.rateLimit);
|
|
1097
|
+
// skip: no rateLimit declared and not systemScope (or resolved to no-op) — nothing to enforce
|
|
1098
|
+
if (!effective) return;
|
|
1032
1099
|
const reqCtx = requestContext.get();
|
|
1033
|
-
const bucket = buildBucketKey(
|
|
1100
|
+
const bucket = buildBucketKey(effective, {
|
|
1034
1101
|
handlerName,
|
|
1035
1102
|
user,
|
|
1036
1103
|
ip: reqCtx?.ip,
|
|
@@ -1044,9 +1111,9 @@ export async function enforceRateLimit(
|
|
|
1044
1111
|
});
|
|
1045
1112
|
}
|
|
1046
1113
|
await context.rateLimit.enforce(bucket.key, {
|
|
1047
|
-
limit:
|
|
1048
|
-
windowSeconds:
|
|
1049
|
-
cost:
|
|
1114
|
+
limit: effective.limit,
|
|
1115
|
+
windowSeconds: effective.windowSeconds,
|
|
1116
|
+
cost: effective.cost,
|
|
1050
1117
|
});
|
|
1051
1118
|
}
|
|
1052
1119
|
|
|
@@ -1063,7 +1130,24 @@ function buildAuthClaimsContext(ctx: DispatchContext, user: SessionUser): AuthCl
|
|
|
1063
1130
|
message: "dispatcher.resolveAuthClaims requires a database connection — none is configured.",
|
|
1064
1131
|
});
|
|
1065
1132
|
}
|
|
1066
|
-
const db = createTenantDb(
|
|
1133
|
+
const db = createTenantDb(
|
|
1134
|
+
dbSource,
|
|
1135
|
+
user.tenantId,
|
|
1136
|
+
"tenant",
|
|
1137
|
+
context.tracer,
|
|
1138
|
+
context.meter,
|
|
1139
|
+
undefined,
|
|
1140
|
+
{
|
|
1141
|
+
report: createEscapeHatchReporter({
|
|
1142
|
+
handler: "r.authClaims hook",
|
|
1143
|
+
tenantId: user.tenantId,
|
|
1144
|
+
actor: user.id,
|
|
1145
|
+
sink: context._escapeHatchAuditSink,
|
|
1146
|
+
log: context.log,
|
|
1147
|
+
window: ctx.escapeHatchReportWindow,
|
|
1148
|
+
}),
|
|
1149
|
+
},
|
|
1150
|
+
);
|
|
1067
1151
|
const configAccessor = context._configAccessorFactory
|
|
1068
1152
|
? context._configAccessorFactory({
|
|
1069
1153
|
user: { id: user.id, tenantId: user.tenantId },
|