@cosmicdrift/kumiko-framework 0.270.0 → 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/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 +91 -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__/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.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 +51 -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.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 +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 +7 -4
- package/src/engine/entity-handlers.ts +71 -19
- package/src/engine/feature-entity-handlers.ts +4 -4
- package/src/engine/index.ts +5 -0
- 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/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
|
@@ -44,9 +44,7 @@ async function* executeStreamInner(
|
|
|
44
44
|
|
|
45
45
|
await ensureFeatureEnabled(ctx, type, user.tenantId);
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
await enforceRateLimit(ctx, handler.rateLimit, type, user);
|
|
49
|
-
}
|
|
47
|
+
await enforceRateLimit(ctx, handler.rateLimit, type, user, registry.isHandlerSystemScoped(type));
|
|
50
48
|
|
|
51
49
|
if (!hasAccess(user, handler.access)) {
|
|
52
50
|
throw new AccessDeniedError({
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import type { DbRow, DbTx } from "../db/connection";
|
|
1
|
+
import type { DbRow, DbRunner, DbTx } from "../db/connection";
|
|
2
2
|
import { selectRowForUpdateById } from "../db/queries/entity-read";
|
|
3
3
|
import { asEntityTableMeta, selectMany } from "../db/query";
|
|
4
4
|
import { buildEntityTable, toSnakeCase } from "../db/table-builder";
|
|
5
|
+
import { tenantDbRunner } from "../db/tenant-db-runner";
|
|
5
6
|
import { hasAccess } from "../engine/access";
|
|
6
7
|
import { ConfigScopes } from "../engine/constants";
|
|
7
|
-
import { checkWriteFieldRoles } from "../engine/field-access";
|
|
8
|
+
import { checkWriteFieldOwnership, checkWriteFieldRoles } from "../engine/field-access";
|
|
8
9
|
import { defineTransitions, guardTransition } from "../engine/state-machine";
|
|
9
10
|
import type { HandlerContext, SessionUser, WriteResult } from "../engine/types";
|
|
10
11
|
import { HookPhases } from "../engine/types";
|
|
@@ -28,6 +29,7 @@ import {
|
|
|
28
29
|
checkFeatureEnabled,
|
|
29
30
|
enforceRateLimit,
|
|
30
31
|
memberResolutionReadOnlyDenied,
|
|
32
|
+
resolveDbSource,
|
|
31
33
|
runHandlerInstrumented,
|
|
32
34
|
TENANT_TIMEZONE_CONFIG_KEY,
|
|
33
35
|
} from "./dispatch-shared";
|
|
@@ -127,6 +129,7 @@ async function runLifecycle(
|
|
|
127
129
|
handlerContext: HandlerContext,
|
|
128
130
|
user: SessionUser,
|
|
129
131
|
afterCommitHooks: AfterCommitHook[],
|
|
132
|
+
runner: DbRunner | undefined,
|
|
130
133
|
): Promise<void> {
|
|
131
134
|
const { lifecycle } = ctx;
|
|
132
135
|
if (!lifecycle) {
|
|
@@ -143,7 +146,7 @@ async function runLifecycle(
|
|
|
143
146
|
// hooks. If a projection apply() throws, the whole tx rolls back — the
|
|
144
147
|
// event and the auto-projection row go with it. Running before the hooks
|
|
145
148
|
// keeps projection state consistent with what the hooks observe.
|
|
146
|
-
await runProjections(result, handlerContext);
|
|
149
|
+
await runProjections(result, handlerContext.registry, runner);
|
|
147
150
|
|
|
148
151
|
if (result.kind === "save") {
|
|
149
152
|
await lifecycle.runPostSave(type, result, handlerContext, HookPhases.inTransaction);
|
|
@@ -196,6 +199,14 @@ export async function executeWrite(
|
|
|
196
199
|
);
|
|
197
200
|
}
|
|
198
201
|
|
|
202
|
+
function isForeignTenantParentRow(
|
|
203
|
+
parentRow: Readonly<Record<string, unknown>>,
|
|
204
|
+
user: SessionUser,
|
|
205
|
+
): boolean {
|
|
206
|
+
const rowTenantId = parentRow["tenantId"];
|
|
207
|
+
return typeof rowTenantId === "string" && rowTenantId !== user.tenantId;
|
|
208
|
+
}
|
|
209
|
+
|
|
199
210
|
// Nested-write orchestration (v1: depth=1, create-only, hasMany-only).
|
|
200
211
|
//
|
|
201
212
|
// When a parent `:create` handler's payload carries values under keys
|
|
@@ -286,6 +297,36 @@ export async function executeNestedWrite(
|
|
|
286
297
|
);
|
|
287
298
|
}
|
|
288
299
|
|
|
300
|
+
// A custom create handler may return an existing foreign row; verify it before attaching children.
|
|
301
|
+
if (!registry.isHandlerSystemScoped(type) && isForeignTenantParentRow(parentRow, user)) {
|
|
302
|
+
return writeFailure(
|
|
303
|
+
new AccessDeniedError({
|
|
304
|
+
message: `nested-write: parent row belongs to another tenant — refusing to attach children to "${type}"`,
|
|
305
|
+
details: { reason: FrameworkReasons.fieldAccessDenied, handler: type },
|
|
306
|
+
}),
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
const parentEntityName = registry.getHandlerEntity(type);
|
|
310
|
+
if (parentEntityName) {
|
|
311
|
+
const parentEntity = registry.getEntity(parentEntityName);
|
|
312
|
+
if (parentEntity) {
|
|
313
|
+
const deniedField = checkWriteFieldOwnership(parentEntity, parentRow, user);
|
|
314
|
+
if (deniedField) {
|
|
315
|
+
return writeFailure(
|
|
316
|
+
new AccessDeniedError({
|
|
317
|
+
message: `nested-write: parent row ownership check failed on field "${deniedField}" of "${type}" — refusing to attach children to a parent row the caller does not own`,
|
|
318
|
+
i18nKey: "errors.access.fieldDenied",
|
|
319
|
+
details: {
|
|
320
|
+
reason: FrameworkReasons.fieldAccessDenied,
|
|
321
|
+
field: deniedField,
|
|
322
|
+
handler: type,
|
|
323
|
+
},
|
|
324
|
+
}),
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
289
330
|
for (const spec of nested.specs) {
|
|
290
331
|
const subRows: Record<string, unknown>[] = [];
|
|
291
332
|
for (let i = 0; i < spec.items.length; i++) {
|
|
@@ -341,15 +382,21 @@ async function executeWriteInner(
|
|
|
341
382
|
|
|
342
383
|
// Rate-limit gate before access (same reasoning as in executeQueryInner).
|
|
343
384
|
// Throws RateLimitError; the outer wrapper turns it into a 429
|
|
344
|
-
// WriteFailure via toWriteErrorInfo.
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
385
|
+
// WriteFailure via toWriteErrorInfo. Apps that don't use L3 pay zero cost
|
|
386
|
+
// for non-systemScope handlers with no rateLimit declared; systemScope
|
|
387
|
+
// handlers pay one isHandlerSystemScoped lookup to check whether the
|
|
388
|
+
// default per-tenant limit applies.
|
|
389
|
+
try {
|
|
390
|
+
await enforceRateLimit(
|
|
391
|
+
ctx,
|
|
392
|
+
handler.rateLimit,
|
|
393
|
+
type,
|
|
394
|
+
user,
|
|
395
|
+
registry.isHandlerSystemScoped(type),
|
|
396
|
+
);
|
|
397
|
+
} catch (e) {
|
|
398
|
+
if (isKumikoError(e)) return writeFailure(e);
|
|
399
|
+
throw e;
|
|
353
400
|
}
|
|
354
401
|
|
|
355
402
|
// Default-deny: missing access rule is treated as "no one has access".
|
|
@@ -447,7 +494,7 @@ async function executeWriteInner(
|
|
|
447
494
|
// active (tests without a DB connection).
|
|
448
495
|
const tableName = asEntityTableMeta(table)?.tableName ?? "";
|
|
449
496
|
const rows = tx
|
|
450
|
-
? await selectRowForUpdateById(transitionGuardDb, tableName, id)
|
|
497
|
+
? await selectRowForUpdateById(tenantDbRunner(transitionGuardDb), tableName, id)
|
|
451
498
|
: await selectMany(transitionGuardDb, table, { id });
|
|
452
499
|
const row = rows[0];
|
|
453
500
|
|
|
@@ -504,7 +551,8 @@ async function executeWriteInner(
|
|
|
504
551
|
|
|
505
552
|
if (result.isSuccess) {
|
|
506
553
|
try {
|
|
507
|
-
|
|
554
|
+
const runner = resolveDbSource(ctx, tx);
|
|
555
|
+
await runLifecycle(ctx, type, result.data, handlerContext, user, afterCommitHooks, runner);
|
|
508
556
|
} catch (e) {
|
|
509
557
|
return writeFailure(wrapToKumiko(e));
|
|
510
558
|
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { SseBroker } from "../api/sse-broker";
|
|
2
2
|
import type { buildEntityTable } from "../db/table-builder";
|
|
3
|
+
import {
|
|
4
|
+
DEPRECATED_CROSS_TENANT_SIGNAL,
|
|
5
|
+
isDeprecatedCrossTenantHandler,
|
|
6
|
+
} from "../engine/entity-handlers";
|
|
3
7
|
import { TENANT_MEMBERSHIPS_QUERY } from "../engine/extension-names";
|
|
4
8
|
import type { defineTransitions } from "../engine/state-machine";
|
|
5
9
|
import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
|
|
@@ -23,6 +27,7 @@ import type { BatchCommand, BatchResult, DispatchContext } from "./dispatch-shar
|
|
|
23
27
|
import { resolveAuthClaimsFn } from "./dispatch-shared";
|
|
24
28
|
import { executeStream } from "./dispatch-stream";
|
|
25
29
|
import { type HandlerType, resolveType } from "./dispatcher-utils";
|
|
30
|
+
import { createEscapeHatchReportWindow } from "./escape-hatch-report";
|
|
26
31
|
import type { IdempotencyGuard } from "./idempotency";
|
|
27
32
|
import type { LifecycleHooks } from "./lifecycle-pipeline";
|
|
28
33
|
import { createMemberReaderFn } from "./member-reader";
|
|
@@ -129,6 +134,7 @@ export function createDispatcher(
|
|
|
129
134
|
// One per dispatcher instance (not a module-level singleton) so caches
|
|
130
135
|
// never leak across separately-booted apps or test stacks.
|
|
131
136
|
const tenantTimezoneCache = createTenantTimezoneCache();
|
|
137
|
+
const escapeHatchReportWindow = createEscapeHatchReportWindow();
|
|
132
138
|
|
|
133
139
|
const dispatcherTracer = context.tracer ?? getFallbackTracer();
|
|
134
140
|
const dispatcherMeter = context.meter ?? getFallbackMeter();
|
|
@@ -136,6 +142,19 @@ export function createDispatcher(
|
|
|
136
142
|
// Idempotent: buildServer may have registered them already.
|
|
137
143
|
registerStandardMetrics(dispatcherMeter);
|
|
138
144
|
|
|
145
|
+
for (const def of [
|
|
146
|
+
...registry.getAllWriteHandlers().values(),
|
|
147
|
+
...registry.getAllQueryHandlers().values(),
|
|
148
|
+
]) {
|
|
149
|
+
if (isDeprecatedCrossTenantHandler(def.handler)) {
|
|
150
|
+
context.log?.warn(DEPRECATED_CROSS_TENANT_SIGNAL, {
|
|
151
|
+
handler: def.name,
|
|
152
|
+
migration:
|
|
153
|
+
'replace crossTenant: true with escapeHatch: { reason: "<why this operator handler reads/writes every tenant>" } (bun scripts/codemod/migrate-cross-tenant.ts)',
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
139
158
|
const ctx: DispatchContext = {
|
|
140
159
|
registry,
|
|
141
160
|
appContext: context,
|
|
@@ -147,6 +166,7 @@ export function createDispatcher(
|
|
|
147
166
|
tableCache,
|
|
148
167
|
transitionCache,
|
|
149
168
|
tenantTimezoneCache,
|
|
169
|
+
escapeHatchReportWindow,
|
|
150
170
|
tracer: dispatcherTracer,
|
|
151
171
|
meter: dispatcherMeter,
|
|
152
172
|
membershipQuery,
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
EscapeHatchAuditSink,
|
|
3
|
+
EscapeHatchKind,
|
|
4
|
+
EscapeHatchReporter,
|
|
5
|
+
EscapeHatchTarget,
|
|
6
|
+
EscapeHatchUseEvent,
|
|
7
|
+
TenantId,
|
|
8
|
+
} from "../engine/types";
|
|
9
|
+
import type { Logger } from "../logging/types";
|
|
10
|
+
|
|
11
|
+
export const ESCAPE_HATCH_USED_SIGNAL = "security:escape-hatch-used";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_WINDOW_MS = 60_000;
|
|
14
|
+
const DEFAULT_MAX_ENTRIES = 1_000;
|
|
15
|
+
|
|
16
|
+
export type EscapeHatchReportWindow = {
|
|
17
|
+
shouldReport(key: string, nowMs: number): boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Dedups identical (handler,kind,reason,tenant,actor,target) reports within the window so hot systemScope queries don't write one row per request.
|
|
21
|
+
export function createEscapeHatchReportWindow(opts?: {
|
|
22
|
+
readonly windowMs?: number;
|
|
23
|
+
readonly maxEntries?: number;
|
|
24
|
+
}): EscapeHatchReportWindow {
|
|
25
|
+
const windowMs = opts?.windowMs ?? DEFAULT_WINDOW_MS;
|
|
26
|
+
const maxEntries = opts?.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
27
|
+
// Map insertion order doubles as recency order: a re-set moves the key to the end.
|
|
28
|
+
const entries = new Map<string, number>();
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
shouldReport(key, nowMs) {
|
|
32
|
+
const expiresAt = entries.get(key);
|
|
33
|
+
if (expiresAt !== undefined && expiresAt > nowMs) return false;
|
|
34
|
+
entries.delete(key);
|
|
35
|
+
entries.set(key, nowMs + windowMs);
|
|
36
|
+
while (entries.size > maxEntries) {
|
|
37
|
+
const oldestKey = entries.keys().next().value;
|
|
38
|
+
if (oldestKey === undefined) break;
|
|
39
|
+
entries.delete(oldestKey);
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Default window for createEscapeHatchReporter callers that don't pass their own.
|
|
47
|
+
const fallbackReportWindow = createEscapeHatchReportWindow();
|
|
48
|
+
|
|
49
|
+
const consoleLogger: Logger = {
|
|
50
|
+
info() {},
|
|
51
|
+
warn(msg, data) {
|
|
52
|
+
console.warn(msg, data);
|
|
53
|
+
},
|
|
54
|
+
error(msg, data) {
|
|
55
|
+
console.error(msg, data);
|
|
56
|
+
},
|
|
57
|
+
debug() {},
|
|
58
|
+
child() {
|
|
59
|
+
return consoleLogger;
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function eventFields(event: EscapeHatchUseEvent): Record<string, unknown> {
|
|
64
|
+
return {
|
|
65
|
+
handler: event.handler,
|
|
66
|
+
kind: event.kind,
|
|
67
|
+
reason: event.reason,
|
|
68
|
+
tenantId: event.tenantId,
|
|
69
|
+
actor: event.actor,
|
|
70
|
+
...(event.target && { targetId: event.target.id, targetTenantId: event.target.tenantId }),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function reportEscapeHatchUse(
|
|
75
|
+
event: EscapeHatchUseEvent,
|
|
76
|
+
deps: { readonly sink?: EscapeHatchAuditSink; readonly log?: Logger },
|
|
77
|
+
): void {
|
|
78
|
+
const logger = deps.log ?? consoleLogger;
|
|
79
|
+
const fields = eventFields(event);
|
|
80
|
+
if (deps.sink) {
|
|
81
|
+
void deps.sink(event).catch((err: unknown) => {
|
|
82
|
+
logger.error(`${ESCAPE_HATCH_USED_SIGNAL}: audit sink failed`, {
|
|
83
|
+
...fields,
|
|
84
|
+
error: err instanceof Error ? err.message : String(err),
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
logger.warn(ESCAPE_HATCH_USED_SIGNAL, fields);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function createEscapeHatchReporter(opts: {
|
|
93
|
+
readonly handler: string;
|
|
94
|
+
readonly tenantId: TenantId;
|
|
95
|
+
readonly actor: string;
|
|
96
|
+
readonly sink?: EscapeHatchAuditSink;
|
|
97
|
+
readonly log?: Logger;
|
|
98
|
+
readonly window?: EscapeHatchReportWindow;
|
|
99
|
+
}): EscapeHatchReporter {
|
|
100
|
+
const window = opts.window ?? fallbackReportWindow;
|
|
101
|
+
|
|
102
|
+
return (kind: EscapeHatchKind, reason: string, target?: EscapeHatchTarget) => {
|
|
103
|
+
const key = JSON.stringify([
|
|
104
|
+
opts.handler,
|
|
105
|
+
kind,
|
|
106
|
+
reason,
|
|
107
|
+
opts.tenantId,
|
|
108
|
+
opts.actor,
|
|
109
|
+
target?.id ?? null,
|
|
110
|
+
target?.tenantId ?? null,
|
|
111
|
+
]);
|
|
112
|
+
// skip: same (handler, kind, reason, target) already reported within the window — dedup
|
|
113
|
+
if (!window.shouldReport(key, Date.now())) return;
|
|
114
|
+
reportEscapeHatchUse(
|
|
115
|
+
{ handler: opts.handler, kind, reason, tenantId: opts.tenantId, actor: opts.actor, target },
|
|
116
|
+
{ sink: opts.sink, log: opts.log },
|
|
117
|
+
);
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function fallbackEscapeHatchReporter(tenantId: TenantId): EscapeHatchReporter {
|
|
122
|
+
return createEscapeHatchReporter({
|
|
123
|
+
handler: "<unattributed>",
|
|
124
|
+
tenantId,
|
|
125
|
+
actor: "<unattributed>",
|
|
126
|
+
});
|
|
127
|
+
}
|
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
|