@cosmicdrift/kumiko-framework 0.270.0 → 0.274.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/__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__/migrate-cross-tenant-codemod.test.ts +106 -0
- package/src/__tests__/related-list-parent-filter.integration.test.ts +103 -0
- package/src/api/__tests__/nested-write-ownership.integration.test.ts +268 -0
- 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 +105 -0
- package/src/db/__tests__/build-filter-where.test.ts +8 -0
- package/src/db/__tests__/event-store-executor-list.integration.test.ts +69 -0
- package/src/db/__tests__/event-store-executor-reference-search.integration.test.ts +266 -2
- 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__/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/entity-field-encryption.ts +18 -0
- package/src/db/event-store-executor-context.ts +12 -5
- package/src/db/event-store-executor-read.ts +125 -37
- 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.test.ts +0 -1
- package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
- package/src/engine/__tests__/boot-validator-projection-list.test.ts +91 -0
- package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +143 -0
- package/src/engine/__tests__/boot-validator.test.ts +71 -1
- package/src/engine/__tests__/engine.test.ts +86 -0
- 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 +28 -0
- 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__/read-steps-tenant-filter.integration.test.ts +215 -0
- package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
- package/src/engine/__tests__/screen-filter-ops.test.ts +1 -1
- package/src/engine/__tests__/screen.test.ts +150 -0
- 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__/global-tenancy.test.ts +16 -1
- package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
- 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/parent-ref.ts +1 -1
- package/src/engine/boot-validator/projection-list-screens.ts +68 -3
- 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 +7 -4
- package/src/engine/entity-handlers.ts +74 -22
- package/src/engine/feature-entity-handlers.ts +4 -4
- package/src/engine/index.ts +5 -0
- package/src/engine/registry-validate.ts +36 -6
- package/src/engine/schema-builder.ts +6 -0
- package/src/engine/screen-filter-ops.ts +10 -2
- 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/errors/i18n/de.yaml +14 -0
- package/src/errors/i18n/en.yaml +13 -0
- package/src/errors/reasons.ts +4 -0
- package/src/i18n/required-surface-keys.ts +6 -2
- package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
- package/src/jobs/__tests__/jobs.integration.test.ts +41 -0
- package/src/jobs/job-runner.ts +35 -5
- 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__/msp-rebuild.integration.test.ts +28 -0
- 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 +169 -14
- 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 +2 -1
- 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
package/src/pipeline/index.ts
CHANGED
|
@@ -7,6 +7,14 @@ export type { DistributedLock } from "./distributed-lock";
|
|
|
7
7
|
export { createDistributedLock } from "./distributed-lock";
|
|
8
8
|
export type { EntityCache, EntityCacheOptions } from "./entity-cache";
|
|
9
9
|
export { createEntityCache } from "./entity-cache";
|
|
10
|
+
export type { EscapeHatchReportWindow } from "./escape-hatch-report";
|
|
11
|
+
export {
|
|
12
|
+
createEscapeHatchReporter,
|
|
13
|
+
createEscapeHatchReportWindow,
|
|
14
|
+
ESCAPE_HATCH_USED_SIGNAL,
|
|
15
|
+
fallbackEscapeHatchReporter,
|
|
16
|
+
reportEscapeHatchUse,
|
|
17
|
+
} from "./escape-hatch-report";
|
|
10
18
|
export type { ConsumerStatus } from "./event-consumer-state";
|
|
11
19
|
export {
|
|
12
20
|
ConsumerStatuses,
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { DbTx } from "../db/connection";
|
|
2
|
+
import { extractPgError } from "../db/pg-error";
|
|
3
|
+
import { asRawClient, runInSavepoint, transaction } from "../db/query";
|
|
4
|
+
import { AccessDeniedError, InternalError } from "../errors";
|
|
5
|
+
import {
|
|
6
|
+
type DispatchContext,
|
|
7
|
+
memberResolutionReadOnlyDenied,
|
|
8
|
+
resolveDbSource,
|
|
9
|
+
} from "./dispatch-shared";
|
|
10
|
+
|
|
11
|
+
const PG_READ_ONLY_SQLSTATE = "25006";
|
|
12
|
+
|
|
13
|
+
// Bun.SQL surfaces the PG SQLSTATE as `errno` (code stays "ERR_POSTGRES_SERVER_ERROR");
|
|
14
|
+
// postgres.js surfaces it as `code`, possibly one `.cause` layer down (DrizzleQueryError-style wrapping).
|
|
15
|
+
function isReadOnlyTransactionViolation(e: unknown): boolean {
|
|
16
|
+
if (extractPgError(e)?.code === PG_READ_ONLY_SQLSTATE) return true;
|
|
17
|
+
if (typeof e !== "object" || e === null) return false;
|
|
18
|
+
// @cast-boundary error-details — Bun.SQL error shape (errno)
|
|
19
|
+
if ((e as { errno?: unknown }).errno === PG_READ_ONLY_SQLSTATE) return true;
|
|
20
|
+
// @cast-boundary error-details — Bun.SQL error shape (errno) under .cause
|
|
21
|
+
const cause = (e as { cause?: unknown }).cause;
|
|
22
|
+
if (typeof cause !== "object" || cause === null) return false;
|
|
23
|
+
return (cause as { errno?: unknown }).errno === PG_READ_ONLY_SQLSTATE;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function runInDiscardedReadOnlySavepoint<T>(
|
|
27
|
+
tx: DbTx,
|
|
28
|
+
fn: (readOnlyTx: DbTx) => Promise<T>,
|
|
29
|
+
): Promise<T> {
|
|
30
|
+
const discardMarker = {};
|
|
31
|
+
const settled: { outcome?: { readonly value: T } } = {};
|
|
32
|
+
try {
|
|
33
|
+
await runInSavepoint(tx, async (sp) => {
|
|
34
|
+
// kumiko-lint-ignore raw-sql transaction characteristic, no query helper
|
|
35
|
+
await asRawClient(sp).unsafe("SET TRANSACTION READ ONLY");
|
|
36
|
+
// @cast-boundary driver savepoint handle — structurally a DbTx, same shape runInSavepoint's caller relies on
|
|
37
|
+
settled.outcome = { value: await fn(sp as DbTx) };
|
|
38
|
+
// Always discarded: RELEASE SAVEPOINT would leave the outer tx read-only too.
|
|
39
|
+
throw discardMarker;
|
|
40
|
+
});
|
|
41
|
+
} catch (e) {
|
|
42
|
+
if (e !== discardMarker) throw e;
|
|
43
|
+
}
|
|
44
|
+
if (!settled.outcome) {
|
|
45
|
+
throw new InternalError({
|
|
46
|
+
message: "runInDiscardedReadOnlySavepoint: savepoint ended without settling — this is a bug.",
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return settled.outcome.value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function runInMemberReadOnlyTransaction<T>(
|
|
53
|
+
ctx: DispatchContext,
|
|
54
|
+
tx: DbTx | undefined,
|
|
55
|
+
fn: (readOnlyTx: DbTx) => Promise<T>,
|
|
56
|
+
): Promise<T> {
|
|
57
|
+
try {
|
|
58
|
+
if (tx) return await runInDiscardedReadOnlySavepoint(tx, fn);
|
|
59
|
+
const pool = resolveDbSource(ctx, undefined);
|
|
60
|
+
if (!pool) {
|
|
61
|
+
throw new InternalError({
|
|
62
|
+
message: "ctx.queryAsMember requires a database connection — none is configured.",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
// Savepoint even on the pool path: Postgres rejects a READ WRITE reset (SQLSTATE 25001) inside a subtransaction.
|
|
66
|
+
return await transaction(pool, (outer) =>
|
|
67
|
+
// @cast-boundary driver begin() handle — structurally a DbTx, same shape runInSavepoint's caller relies on
|
|
68
|
+
runInDiscardedReadOnlySavepoint(outer as DbTx, fn),
|
|
69
|
+
);
|
|
70
|
+
} catch (e) {
|
|
71
|
+
if (e instanceof AccessDeniedError) throw e;
|
|
72
|
+
if (isReadOnlyTransactionViolation(e)) throw memberResolutionReadOnlyDenied(e);
|
|
73
|
+
throw e;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
} from "../db/queries/event-consumer";
|
|
8
8
|
import {
|
|
9
9
|
assertLiveColumnsMatchMeta,
|
|
10
|
+
assertLiveTableHasNoRowLevelSecurity,
|
|
10
11
|
buildShadowTable,
|
|
11
12
|
ensureRebuildSchema,
|
|
12
13
|
rebuildMetaOrThrow,
|
|
@@ -149,6 +150,7 @@ export async function rebuildMultiStreamProjection(
|
|
|
149
150
|
await selectConsumerForUpdate(tx, mspName, SHARED_INSTANCE_SENTINEL);
|
|
150
151
|
|
|
151
152
|
await assertLiveColumnsMatchMeta(tx, meta, mspName);
|
|
153
|
+
await assertLiveTableHasNoRowLevelSecurity(tx, meta.tableName, mspName);
|
|
152
154
|
await buildShadowTable(tx, meta);
|
|
153
155
|
|
|
154
156
|
const subscribedTypes = Object.keys(msp.apply);
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "../db/queries/projection-rebuild";
|
|
9
9
|
import {
|
|
10
10
|
assertLiveColumnsMatchMeta,
|
|
11
|
+
assertLiveTableHasNoRowLevelSecurity,
|
|
11
12
|
assertNoUnreachableLiveRows,
|
|
12
13
|
buildShadowTable,
|
|
13
14
|
type ColumnDriftResult,
|
|
@@ -292,6 +293,7 @@ export async function rebuildProjection(
|
|
|
292
293
|
await db.begin(async (tx: DbTx) => {
|
|
293
294
|
await markProjectionRebuilding(tx, projectionName);
|
|
294
295
|
await assertLiveColumnsMatchMeta(tx, meta, projectionName);
|
|
296
|
+
await assertLiveTableHasNoRowLevelSecurity(tx, meta.tableName, projectionName);
|
|
295
297
|
await buildShadowTable(tx, meta);
|
|
296
298
|
deps.__test_onBuildShadowTable?.();
|
|
297
299
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DbRunner } from "../db";
|
|
2
|
-
import type {
|
|
2
|
+
import type { LifecycleResult, Registry } from "../engine/types";
|
|
3
|
+
import { InternalError } from "../errors";
|
|
3
4
|
import type { StoredEvent } from "../event-store";
|
|
4
5
|
|
|
5
6
|
// Run custom projections for a save or delete result. Lives INSIDE the
|
|
@@ -18,23 +19,24 @@ import type { StoredEvent } from "../event-store";
|
|
|
18
19
|
// - Projections receive the exact StoredEvent from the executor. If you
|
|
19
20
|
// hand-craft a SaveContext (tests, non-executor writes), just don't set
|
|
20
21
|
// `event` and the runner no-ops.
|
|
21
|
-
// - `
|
|
22
|
-
//
|
|
22
|
+
// - `runner` is the caller's already-resolved DbRunner — projections-runner
|
|
23
|
+
// has no TenantDb access of its own to derive one from.
|
|
23
24
|
// - Apply-function throws bubble up unchanged. The dispatcher wraps the
|
|
24
25
|
// whole lifecycle in a try/catch that rolls the tx back; the event is
|
|
25
26
|
// gone from the events table just like a rolled-back state change.
|
|
26
|
-
export async function runProjections(
|
|
27
|
+
export async function runProjections(
|
|
28
|
+
result: LifecycleResult,
|
|
29
|
+
registry: Registry,
|
|
30
|
+
runner: DbRunner | undefined,
|
|
31
|
+
): Promise<void> {
|
|
27
32
|
// skip: hand-crafted result with no event — nothing to project
|
|
28
33
|
if (!result.event) return;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
).raw
|
|
36
|
-
: ctx.db.raw;
|
|
37
|
-
await runProjectionsForEvent(result.event, ctx.registry, tx);
|
|
34
|
+
if (!runner) {
|
|
35
|
+
throw new InternalError({
|
|
36
|
+
message: `runProjections("${result.event.aggregateType}") requires a database connection — none is configured.`,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
await runProjectionsForEvent(result.event, registry, runner);
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
// Fire every projection whose source matches the event's aggregate type AND
|
|
@@ -3,6 +3,8 @@ import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user";
|
|
|
3
3
|
import type {
|
|
4
4
|
ActiveMembershipResult,
|
|
5
5
|
EscapeHatchDeclaration,
|
|
6
|
+
EscapeHatchReporter,
|
|
7
|
+
HandlerContext,
|
|
6
8
|
LifecycleHookFn,
|
|
7
9
|
MemberReader,
|
|
8
10
|
SessionUser,
|
|
@@ -17,7 +19,12 @@ export type ResolveActiveMembershipFn = (
|
|
|
17
19
|
userId: string,
|
|
18
20
|
tenantId: TenantId,
|
|
19
21
|
) => Promise<ActiveMembershipResult>;
|
|
22
|
+
export type ProjectionReader = HandlerContext["queryProjection"];
|
|
20
23
|
export type IdentitySwitch = { readonly queryAs: QueryAsFn; readonly writeAs: WriteAsFn };
|
|
24
|
+
export type IdentitySwitchAudit = {
|
|
25
|
+
readonly reason: string | undefined;
|
|
26
|
+
readonly report: EscapeHatchReporter;
|
|
27
|
+
};
|
|
21
28
|
|
|
22
29
|
export function isSystemIdentity(user: SessionUser): boolean {
|
|
23
30
|
return user.id === SYSTEM_USER_ID || user.roles.includes(SYSTEM_ROLE);
|
|
@@ -76,36 +83,69 @@ export function identitySwitchDenied(callerLabel: string, asUser: SessionUser):
|
|
|
76
83
|
});
|
|
77
84
|
}
|
|
78
85
|
|
|
86
|
+
export function unsafeAllTenantsDenied(callerLabel: string): AccessDeniedError {
|
|
87
|
+
return new AccessDeniedError({
|
|
88
|
+
message:
|
|
89
|
+
`${callerLabel} may not call ctx.queryProjection(..., { unsafeAllTenants: true }) — declare ` +
|
|
90
|
+
"r.systemScope() or escapeHatch: { reason } on it",
|
|
91
|
+
details: { reason: FrameworkReasons.unsafeAllTenantsDenied },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
79
95
|
type GatedIdentitySwitchSource = {
|
|
80
96
|
readonly ungated: IdentitySwitch;
|
|
81
97
|
readonly caller: SessionUser | undefined;
|
|
98
|
+
readonly report?: EscapeHatchReporter;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
type MemberReaderAudit = IdentitySwitchAudit & { readonly tenantId: TenantId };
|
|
102
|
+
|
|
103
|
+
type GatedMemberReaderSource = {
|
|
104
|
+
readonly ungated: MemberReader;
|
|
105
|
+
readonly audit?: MemberReaderAudit;
|
|
82
106
|
};
|
|
83
107
|
|
|
84
108
|
// Reverse-lookup so withHookEscapeHatchGrant can re-gate the SAME ungated pair (and caller) under a narrower grant.
|
|
85
109
|
const sourceByGated = new WeakMap<QueryAsFn | WriteAsFn, GatedIdentitySwitchSource>();
|
|
86
110
|
// Same idea, for ctx.queryAsMember — a single function rather than a pair.
|
|
87
|
-
const ungatedMemberReaderByGated = new WeakMap<MemberReader,
|
|
111
|
+
const ungatedMemberReaderByGated = new WeakMap<MemberReader, GatedMemberReaderSource>();
|
|
112
|
+
|
|
113
|
+
function reportGrantedSwitch(
|
|
114
|
+
caller: SessionUser | undefined,
|
|
115
|
+
asUser: SessionUser,
|
|
116
|
+
hasGrant: boolean,
|
|
117
|
+
audit: IdentitySwitchAudit | undefined,
|
|
118
|
+
): void {
|
|
119
|
+
// skip: no audit wired, switch wasn't grant-gated, or grant carries no reason to report
|
|
120
|
+
if (!audit || !hasGrant || audit.reason === undefined) return;
|
|
121
|
+
// skip: caller switching to themselves isn't a privilege escalation worth auditing
|
|
122
|
+
if (caller !== undefined && isSelfDelegation(caller, asUser)) return;
|
|
123
|
+
audit.report("identity-switch", audit.reason, { id: asUser.id, tenantId: asUser.tenantId });
|
|
124
|
+
}
|
|
88
125
|
|
|
89
126
|
export function createGatedIdentitySwitch(
|
|
90
127
|
callerLabel: string,
|
|
91
128
|
caller: SessionUser | undefined,
|
|
92
129
|
hasGrant: boolean,
|
|
93
130
|
ungated: IdentitySwitch,
|
|
131
|
+
audit?: IdentitySwitchAudit,
|
|
94
132
|
): IdentitySwitch {
|
|
95
133
|
const queryAs: QueryAsFn = async (asUser, qn, payload) => {
|
|
96
134
|
if (!isIdentitySwitchAllowed(caller, asUser, hasGrant)) {
|
|
97
135
|
throw identitySwitchDenied(callerLabel, asUser);
|
|
98
136
|
}
|
|
137
|
+
reportGrantedSwitch(caller, asUser, hasGrant, audit);
|
|
99
138
|
return ungated.queryAs(asUser, qn, payload);
|
|
100
139
|
};
|
|
101
140
|
const writeAs: WriteAsFn = async (asUser, qn, payload) => {
|
|
102
141
|
if (!isIdentitySwitchAllowed(caller, asUser, hasGrant)) {
|
|
103
142
|
throw identitySwitchDenied(callerLabel, asUser);
|
|
104
143
|
}
|
|
144
|
+
reportGrantedSwitch(caller, asUser, hasGrant, audit);
|
|
105
145
|
return ungated.writeAs(asUser, qn, payload);
|
|
106
146
|
};
|
|
107
147
|
const gated: IdentitySwitch = { queryAs, writeAs };
|
|
108
|
-
const source: GatedIdentitySwitchSource = { ungated, caller };
|
|
148
|
+
const source: GatedIdentitySwitchSource = { ungated, caller, report: audit?.report };
|
|
109
149
|
sourceByGated.set(queryAs, source);
|
|
110
150
|
sourceByGated.set(writeAs, source);
|
|
111
151
|
return gated;
|
|
@@ -117,20 +157,56 @@ export function createGatedMemberReader(
|
|
|
117
157
|
callerLabel: string,
|
|
118
158
|
allowSystemIdentity: boolean,
|
|
119
159
|
ungated: MemberReader,
|
|
160
|
+
audit?: MemberReaderAudit,
|
|
120
161
|
): MemberReader {
|
|
121
162
|
const gated: MemberReader = async (userId, qn, payload) => {
|
|
122
163
|
if (!allowSystemIdentity) throw systemIdentitySwitchDenied(callerLabel);
|
|
164
|
+
if (audit?.reason !== undefined) {
|
|
165
|
+
audit.report("identity-switch", audit.reason, { id: userId, tenantId: audit.tenantId });
|
|
166
|
+
}
|
|
123
167
|
return ungated(userId, qn, payload);
|
|
124
168
|
};
|
|
125
|
-
ungatedMemberReaderByGated.set(gated, ungated);
|
|
169
|
+
ungatedMemberReaderByGated.set(gated, { ungated, audit });
|
|
170
|
+
return gated;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
type GatedProjectionReaderSource = {
|
|
174
|
+
readonly ungated: ProjectionReader;
|
|
175
|
+
readonly report?: EscapeHatchReporter;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// Same idea, for ctx.queryProjection — a single function rather than a pair.
|
|
179
|
+
const ungatedProjectionReaderByGated = new WeakMap<ProjectionReader, GatedProjectionReaderSource>();
|
|
180
|
+
|
|
181
|
+
// ctx.queryProjection's gate: only { unsafeAllTenants: true } is grant-checked —
|
|
182
|
+
// a plain call always passes through, tenant-scoped by the projection's own table.
|
|
183
|
+
export function createGatedProjectionReader(
|
|
184
|
+
callerLabel: string,
|
|
185
|
+
hasGrant: boolean,
|
|
186
|
+
ungated: ProjectionReader,
|
|
187
|
+
audit?: IdentitySwitchAudit,
|
|
188
|
+
): ProjectionReader {
|
|
189
|
+
const gated = async <T = Record<string, unknown>>(
|
|
190
|
+
qualifiedName: string,
|
|
191
|
+
options?: { readonly unsafeAllTenants?: boolean },
|
|
192
|
+
): Promise<readonly T[]> => {
|
|
193
|
+
if (options?.unsafeAllTenants === true) {
|
|
194
|
+
if (!hasGrant) throw unsafeAllTenantsDenied(callerLabel);
|
|
195
|
+
if (audit?.reason !== undefined) {
|
|
196
|
+
audit.report("unsafe-all-tenants", audit.reason);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return ungated<T>(qualifiedName, options);
|
|
200
|
+
};
|
|
201
|
+
ungatedProjectionReaderByGated.set(gated, { ungated, report: audit?.report });
|
|
126
202
|
return gated;
|
|
127
203
|
}
|
|
128
204
|
|
|
129
205
|
function readIdentitySwitchFn<
|
|
130
|
-
TFn extends QueryAsFn | WriteAsFn | ResolveActiveMembershipFn | MemberReader,
|
|
206
|
+
TFn extends QueryAsFn | WriteAsFn | ResolveActiveMembershipFn | MemberReader | ProjectionReader,
|
|
131
207
|
>(
|
|
132
208
|
context: object,
|
|
133
|
-
key: "queryAs" | "writeAs" | "resolveActiveMembership" | "queryAsMember",
|
|
209
|
+
key: "queryAs" | "writeAs" | "resolveActiveMembership" | "queryAsMember" | "queryProjection",
|
|
134
210
|
): TFn | undefined {
|
|
135
211
|
if (!(key in context)) return undefined;
|
|
136
212
|
const value = (context as Record<string, unknown>)[key];
|
|
@@ -195,7 +271,15 @@ function gatedIdentitySwitchFields(
|
|
|
195
271
|
const ungatedWriteAs = ctxWriteAs && (writeSource?.ungated.writeAs ?? ctxWriteAs);
|
|
196
272
|
const ungated = fallbackUngatedIdentitySwitch(callerLabel, ungatedQueryAs, ungatedWriteAs);
|
|
197
273
|
const caller = resolveDispatcherCaller(querySource, writeSource);
|
|
198
|
-
const
|
|
274
|
+
const report = (querySource ?? writeSource)?.report;
|
|
275
|
+
const audit = report ? { reason: escapeHatch?.reason, report } : undefined;
|
|
276
|
+
const gated = createGatedIdentitySwitch(
|
|
277
|
+
callerLabel,
|
|
278
|
+
caller,
|
|
279
|
+
escapeHatch !== undefined,
|
|
280
|
+
ungated,
|
|
281
|
+
audit,
|
|
282
|
+
);
|
|
199
283
|
return {
|
|
200
284
|
...(ctxQueryAs && { queryAs: gated.queryAs }),
|
|
201
285
|
...(ctxWriteAs && { writeAs: gated.writeAs }),
|
|
@@ -210,9 +294,34 @@ function gatedMemberReaderField(
|
|
|
210
294
|
ctxQueryAsMember: MemberReader | undefined,
|
|
211
295
|
): { queryAsMember?: MemberReader } {
|
|
212
296
|
if (!ctxQueryAsMember) return {};
|
|
213
|
-
const
|
|
297
|
+
const source = ungatedMemberReaderByGated.get(ctxQueryAsMember);
|
|
298
|
+
const ungated = source?.ungated ?? ctxQueryAsMember;
|
|
299
|
+
const audit = source?.audit
|
|
300
|
+
? { reason: escapeHatch?.reason, report: source.audit.report, tenantId: source.audit.tenantId }
|
|
301
|
+
: undefined;
|
|
302
|
+
return {
|
|
303
|
+
queryAsMember: createGatedMemberReader(callerLabel, escapeHatch !== undefined, ungated, audit),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Re-gates ctx.queryProjection under the hook's own escapeHatch, same
|
|
308
|
+
// re-gating rule as the other fields below — see withHookEscapeHatchGrant.
|
|
309
|
+
function gatedProjectionReaderField(
|
|
310
|
+
callerLabel: string,
|
|
311
|
+
escapeHatch: EscapeHatchDeclaration | undefined,
|
|
312
|
+
ctxQueryProjection: ProjectionReader | undefined,
|
|
313
|
+
): { queryProjection?: ProjectionReader } {
|
|
314
|
+
if (!ctxQueryProjection) return {};
|
|
315
|
+
const source = ungatedProjectionReaderByGated.get(ctxQueryProjection);
|
|
316
|
+
const ungated = source?.ungated ?? ctxQueryProjection;
|
|
317
|
+
const audit = source?.report ? { reason: escapeHatch?.reason, report: source.report } : undefined;
|
|
214
318
|
return {
|
|
215
|
-
|
|
319
|
+
queryProjection: createGatedProjectionReader(
|
|
320
|
+
callerLabel,
|
|
321
|
+
escapeHatch !== undefined,
|
|
322
|
+
ungated,
|
|
323
|
+
audit,
|
|
324
|
+
),
|
|
216
325
|
};
|
|
217
326
|
}
|
|
218
327
|
|
|
@@ -228,6 +337,7 @@ export function withHookEscapeHatchGrant<TContext extends object>(
|
|
|
228
337
|
"resolveActiveMembership",
|
|
229
338
|
);
|
|
230
339
|
const ctxQueryAsMember = readIdentitySwitchFn<MemberReader>(context, "queryAsMember");
|
|
340
|
+
const ctxQueryProjection = readIdentitySwitchFn<ProjectionReader>(context, "queryProjection");
|
|
231
341
|
const ctxDb = readDbLikeValue(context, "db");
|
|
232
342
|
const ctxDbOutsideTransaction = readDbLikeValue(context, "dbOutsideTransaction");
|
|
233
343
|
if (
|
|
@@ -235,6 +345,7 @@ export function withHookEscapeHatchGrant<TContext extends object>(
|
|
|
235
345
|
!ctxWriteAs &&
|
|
236
346
|
!ctxResolveActiveMembership &&
|
|
237
347
|
!ctxQueryAsMember &&
|
|
348
|
+
!ctxQueryProjection &&
|
|
238
349
|
!ctxDb &&
|
|
239
350
|
!ctxDbOutsideTransaction
|
|
240
351
|
) {
|
|
@@ -249,6 +360,7 @@ export function withHookEscapeHatchGrant<TContext extends object>(
|
|
|
249
360
|
resolveActiveMembership: deniedResolveActiveMembership(callerLabel),
|
|
250
361
|
}),
|
|
251
362
|
...gatedMemberReaderField(callerLabel, escapeHatch, ctxQueryAsMember),
|
|
363
|
+
...gatedProjectionReaderField(callerLabel, escapeHatch, ctxQueryProjection),
|
|
252
364
|
// @cast-boundary engine-bridge — withUnsafeRawGrant passes non-TenantDb values (e.g. a guard Proxy) through unchanged.
|
|
253
365
|
...(ctxDb && { db: withUnsafeRawGrant(ctxDb as TenantDb, escapeHatch) }),
|
|
254
366
|
...(ctxDbOutsideTransaction && {
|
|
@@ -257,7 +369,7 @@ export function withHookEscapeHatchGrant<TContext extends object>(
|
|
|
257
369
|
};
|
|
258
370
|
}
|
|
259
371
|
|
|
260
|
-
// Re-gates a hook's own ctx.queryAs/ctx.writeAs/ctx.db/ctx.dbOutsideTransaction instead of inheriting the handler's grant.
|
|
372
|
+
// Re-gates a hook's own ctx.queryAs/ctx.writeAs/ctx.queryProjection/ctx.db/ctx.dbOutsideTransaction instead of inheriting the handler's grant.
|
|
261
373
|
export function bindHookEscapeHatchGrant(
|
|
262
374
|
fn: LifecycleHookFn,
|
|
263
375
|
label: string,
|