@cosmicdrift/kumiko-framework 0.266.0 → 0.269.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/api/__tests__/api.test.ts +6 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +6 -0
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +6 -0
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +6 -0
- package/src/api/__tests__/jwt.test.ts +8 -0
- package/src/api/__tests__/stream-escape-hatch.integration.test.ts +203 -0
- package/src/api/jwt.ts +8 -0
- package/src/changes.json +28 -0
- package/src/db/__tests__/event-store-executor-global-tenancy.integration.test.ts +73 -0
- package/src/db/__tests__/tenant-db-global.integration.test.ts +63 -26
- package/src/db/__tests__/tenant-db-global.test.ts +95 -5
- package/src/db/entity-table-meta.ts +7 -0
- package/src/db/event-store-executor-context.ts +19 -0
- package/src/db/event-store-executor-write.ts +16 -0
- package/src/db/index.ts +3 -1
- package/src/db/table-builder.ts +27 -0
- package/src/db/tenant-db.ts +83 -10
- package/src/engine/__tests__/extends-registrar.test.ts +1 -0
- package/src/engine/__tests__/hook-identity-switch-grant.test.ts +4 -2
- package/src/engine/active-membership.ts +15 -1
- package/src/engine/boot-validator/__tests__/access-declarations.test.ts +37 -0
- package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +58 -0
- package/src/engine/boot-validator/access-declarations.ts +6 -1
- package/src/engine/boot-validator/global-tenancy.ts +17 -0
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/feature-entity-handlers.ts +7 -1
- package/src/engine/feature-ui-extensions.ts +2 -2
- package/src/engine/index.ts +3 -0
- package/src/engine/registry-validate.ts +6 -6
- package/src/engine/steps/call-feature.ts +1 -1
- package/src/engine/types/index.ts +2 -0
- package/src/entrypoint/index.ts +2 -13
- package/src/errors/i18n/de.yaml +36 -0
- package/src/errors/i18n/en.yaml +34 -0
- package/src/errors/reasons.ts +13 -0
- package/src/jobs/job-runner.ts +14 -0
- package/src/pipeline/__tests__/dispatcher-utils.test.ts +17 -0
- package/src/pipeline/__tests__/escape-hatch.integration.test.ts +0 -3
- package/src/pipeline/__tests__/system-identity-switch.integration.test.ts +201 -8
- package/src/pipeline/__tests__/system-identity-switch.test.ts +244 -33
- package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +103 -0
- package/src/pipeline/active-membership.ts +3 -1
- package/src/pipeline/dispatch-shared.ts +107 -20
- package/src/pipeline/dispatch-stream.ts +9 -5
- package/src/pipeline/dispatch-write.ts +7 -0
- package/src/pipeline/dispatcher-utils.ts +1 -0
- package/src/pipeline/dispatcher.ts +18 -0
- package/src/pipeline/index.ts +1 -1
- package/src/pipeline/member-reader.ts +108 -0
- package/src/pipeline/system-identity-switch.ts +142 -25
- package/src/stack/test-stack.ts +10 -1
- package/src/testing/handler-context.ts +3 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { FeatureDefinition } from "../types";
|
|
2
|
+
|
|
3
|
+
// Boot-time twin of event-store-executor-context.ts's buildExecutorContext check —
|
|
4
|
+
// surfaces the systemStream invariant once across all features before any dispatch.
|
|
5
|
+
export function validateGlobalTenancyEntities(features: readonly FeatureDefinition[]): void {
|
|
6
|
+
for (const feature of features) {
|
|
7
|
+
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
8
|
+
if (entity.tenancy === "global" && entity.systemStream !== true) {
|
|
9
|
+
throw new Error(
|
|
10
|
+
`Feature "${feature.name}" entity "${entityName}": tenancy: "global" entities must ` +
|
|
11
|
+
"also declare systemStream: true — a global aggregate's event stream must live on " +
|
|
12
|
+
"the system tenant, not the creating tenant's.",
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
} from "./entity-handler";
|
|
37
37
|
import { validateEntityListScreens } from "./entity-list-screens";
|
|
38
38
|
import { validateGdprStoragePersistence } from "./gdpr-storage";
|
|
39
|
+
import { validateGlobalTenancyEntities } from "./global-tenancy";
|
|
39
40
|
import { validateI18nSurfaceKeys } from "./i18n-keys";
|
|
40
41
|
import {
|
|
41
42
|
collectKnownRoles,
|
|
@@ -238,6 +239,7 @@ export function validateBoot(
|
|
|
238
239
|
validateRelatedListSectionQueries(features);
|
|
239
240
|
validateExtensionPreSaveWiring(features);
|
|
240
241
|
validateGdprStoragePersistence(features);
|
|
242
|
+
validateGlobalTenancyEntities(features);
|
|
241
243
|
validateFeatureBootChecks(features);
|
|
242
244
|
|
|
243
245
|
if (hasEncryptedFields) {
|
|
@@ -228,7 +228,11 @@ export function buildEntityHandlerMethods<TName extends string>(
|
|
|
228
228
|
nameOrDef: string | StreamHandlerDefinition<TName, TSchema>,
|
|
229
229
|
schema?: TSchema,
|
|
230
230
|
handler?: StreamHandlerFn<z.infer<TSchema>>,
|
|
231
|
-
options?: {
|
|
231
|
+
options?: {
|
|
232
|
+
access: AccessRule;
|
|
233
|
+
rateLimit?: RateLimitOption;
|
|
234
|
+
escapeHatch?: EscapeHatchDeclaration;
|
|
235
|
+
},
|
|
232
236
|
): HandlerRef {
|
|
233
237
|
if (typeof nameOrDef === "object") {
|
|
234
238
|
const def = nameOrDef;
|
|
@@ -239,6 +243,7 @@ export function buildEntityHandlerMethods<TName extends string>(
|
|
|
239
243
|
handler: def.handler as StreamHandlerFn, // @cast-boundary engine-bridge
|
|
240
244
|
access: def.access,
|
|
241
245
|
...(def.rateLimit && { rateLimit: def.rateLimit }),
|
|
246
|
+
...(def.escapeHatch && { escapeHatch: def.escapeHatch }),
|
|
242
247
|
};
|
|
243
248
|
return { name: def.name };
|
|
244
249
|
}
|
|
@@ -249,6 +254,7 @@ export function buildEntityHandlerMethods<TName extends string>(
|
|
|
249
254
|
handler: inline.handler as StreamHandlerFn, // @cast-boundary engine-bridge
|
|
250
255
|
access: inline.options.access,
|
|
251
256
|
...(inline.options.rateLimit && { rateLimit: inline.options.rateLimit }),
|
|
257
|
+
...(inline.options.escapeHatch && { escapeHatch: inline.options.escapeHatch }),
|
|
252
258
|
};
|
|
253
259
|
return { name: nameOrDef };
|
|
254
260
|
},
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { EntityTableMeta } from "../db/entity-table-meta";
|
|
2
|
-
import {
|
|
2
|
+
import { bindHookEscapeHatchGrant } from "../pipeline/system-identity-switch";
|
|
3
3
|
import { LifecycleHookTypes } from "./constants";
|
|
4
4
|
import type { FeatureBuilderState } from "./feature-builder-state";
|
|
5
5
|
import { resolveName } from "./handler-helpers";
|
|
@@ -143,7 +143,7 @@ export function buildUiExtensionsMethods<TName extends string>(
|
|
|
143
143
|
const wrapped: LifecycleHookFn | ValidationHookFn =
|
|
144
144
|
type === "validation"
|
|
145
145
|
? fn
|
|
146
|
-
:
|
|
146
|
+
: bindHookEscapeHatchGrant(fn as LifecycleHookFn, hookLabel, options?.escapeHatch); // @cast-boundary engine-bridge
|
|
147
147
|
|
|
148
148
|
// Entity-wide target ("all write/query handlers of this entity") —
|
|
149
149
|
// replaces the old r.entityHook(type, entity, fn).
|
package/src/engine/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { hasAccess } from "./access";
|
|
|
4
4
|
export {
|
|
5
5
|
isPrincipalStatusPlugin,
|
|
6
6
|
isTenantLifecycleStatusPlugin,
|
|
7
|
+
type PrincipalProfile,
|
|
7
8
|
type PrincipalStatus,
|
|
8
9
|
type PrincipalStatusPlugin,
|
|
9
10
|
TENANT_TEARDOWN_STATUSES,
|
|
@@ -410,6 +411,7 @@ export type {
|
|
|
410
411
|
ListSortSpec,
|
|
411
412
|
LongTextFindability,
|
|
412
413
|
ManyToManyRelation,
|
|
414
|
+
MemberReader,
|
|
413
415
|
MetricNavigate,
|
|
414
416
|
MetricSpec,
|
|
415
417
|
MspErrorMode,
|
|
@@ -478,6 +480,7 @@ export type {
|
|
|
478
480
|
SecretsEditSection,
|
|
479
481
|
SelectFieldDef,
|
|
480
482
|
SessionUser,
|
|
483
|
+
SessionUserOrigin,
|
|
481
484
|
StreamHandlerDef,
|
|
482
485
|
StreamHandlerFn,
|
|
483
486
|
Subscribe,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { configureEventPiiCatalog } from "../crypto/event-pii";
|
|
2
|
-
import {
|
|
2
|
+
import { bindHookEscapeHatchGrant } from "../pipeline/system-identity-switch";
|
|
3
3
|
import { resolveName } from "./handler-helpers";
|
|
4
4
|
import type {
|
|
5
5
|
RegistryState,
|
|
@@ -126,10 +126,10 @@ export function applyExtensionUsages(state: RegistryState): void {
|
|
|
126
126
|
// RegistrarExtensionRegistration and use that here.
|
|
127
127
|
const extOwner = "*";
|
|
128
128
|
// Extensions have no declaration site for escapeHatch, so SYSTEM is always denied
|
|
129
|
-
// (system-identity-switch.ts) —
|
|
129
|
+
// (system-identity-switch.ts) — bindHookEscapeHatchGrant(..., undefined) below.
|
|
130
130
|
if (ext.hooks) {
|
|
131
131
|
if (ext.hooks.postSave) {
|
|
132
|
-
const wrapped =
|
|
132
|
+
const wrapped = bindHookEscapeHatchGrant(
|
|
133
133
|
ext.hooks.postSave,
|
|
134
134
|
`postSave hook of extension "${usage.extensionName}"`,
|
|
135
135
|
undefined,
|
|
@@ -143,7 +143,7 @@ export function applyExtensionUsages(state: RegistryState): void {
|
|
|
143
143
|
state.entityPostSaveHooks.set(usage.entityName, existing);
|
|
144
144
|
}
|
|
145
145
|
if (ext.hooks.preDelete) {
|
|
146
|
-
const wrapped =
|
|
146
|
+
const wrapped = bindHookEscapeHatchGrant(
|
|
147
147
|
ext.hooks.preDelete,
|
|
148
148
|
`preDelete hook of extension "${usage.extensionName}"`,
|
|
149
149
|
undefined,
|
|
@@ -157,7 +157,7 @@ export function applyExtensionUsages(state: RegistryState): void {
|
|
|
157
157
|
state.entityPreDeleteHooks.set(usage.entityName, existing);
|
|
158
158
|
}
|
|
159
159
|
if (ext.hooks.postDelete) {
|
|
160
|
-
const wrapped =
|
|
160
|
+
const wrapped = bindHookEscapeHatchGrant(
|
|
161
161
|
ext.hooks.postDelete,
|
|
162
162
|
`postDelete hook of extension "${usage.extensionName}"`,
|
|
163
163
|
undefined,
|
|
@@ -172,7 +172,7 @@ export function applyExtensionUsages(state: RegistryState): void {
|
|
|
172
172
|
}
|
|
173
173
|
// preSave on extensions: store as handler hook for all CRUD handlers of this entity
|
|
174
174
|
if (ext.hooks.preSave) {
|
|
175
|
-
const wrapped =
|
|
175
|
+
const wrapped = bindHookEscapeHatchGrant(
|
|
176
176
|
ext.hooks.preSave,
|
|
177
177
|
`preSave hook of extension "${usage.extensionName}"`,
|
|
178
178
|
undefined,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// r.step.callFeature — typed sub-command on another Kumiko feature.
|
|
2
2
|
// Tier-2: requires r.requires.step("callFeature"). Sync (no dispatcher).
|
|
3
|
-
//
|
|
3
|
+
// opts.as other than the caller itself needs escapeHatch on the handler (system-identity-switch.ts).
|
|
4
4
|
|
|
5
5
|
import { defineStep } from "../define-step";
|
|
6
6
|
import type { SessionUser } from "../types";
|
|
@@ -171,6 +171,7 @@ export type {
|
|
|
171
171
|
HandlerRef,
|
|
172
172
|
JobContext,
|
|
173
173
|
JobRunnerRef,
|
|
174
|
+
MemberReader,
|
|
174
175
|
NameOrRef,
|
|
175
176
|
NotifyFactory,
|
|
176
177
|
NotifyFn,
|
|
@@ -186,6 +187,7 @@ export type {
|
|
|
186
187
|
RateLimitOption,
|
|
187
188
|
RateLimitPer,
|
|
188
189
|
SessionUser,
|
|
190
|
+
SessionUserOrigin,
|
|
189
191
|
StreamHandlerDef,
|
|
190
192
|
StreamHandlerFn,
|
|
191
193
|
TryAppendEventFn,
|
package/src/entrypoint/index.ts
CHANGED
|
@@ -38,7 +38,7 @@ import { buildServer, withFileProviderResolver } from "../api/server";
|
|
|
38
38
|
import type { SseBroker } from "../api/sse-broker";
|
|
39
39
|
import type { PgClient } from "../db/connection";
|
|
40
40
|
import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
|
|
41
|
-
import type { AppContext,
|
|
41
|
+
import type { AppContext, JobRunIn, Registry, RunIn } from "../engine/types";
|
|
42
42
|
import type { JobRunner, JobRunnerOptions } from "../jobs/job-runner";
|
|
43
43
|
import { createJobRunner } from "../jobs/job-runner";
|
|
44
44
|
import type { Lifecycle } from "../lifecycle";
|
|
@@ -47,6 +47,7 @@ import type { ObservabilityOptions, ObservabilityProvider } from "../observabili
|
|
|
47
47
|
import { createNoopProvider } from "../observability";
|
|
48
48
|
import type { EventDedup, EventDispatcher } from "../pipeline";
|
|
49
49
|
import type { Dispatcher, DispatcherOptions } from "../pipeline/dispatcher";
|
|
50
|
+
import { dispatcherToWriteRef } from "../pipeline/dispatcher";
|
|
50
51
|
import type { SystemHooks } from "../pipeline/lifecycle-pipeline";
|
|
51
52
|
|
|
52
53
|
// Shared fields across all three modes. A caller that swaps between
|
|
@@ -206,18 +207,6 @@ function contextWithObservability(
|
|
|
206
207
|
};
|
|
207
208
|
}
|
|
208
209
|
|
|
209
|
-
// Adapts the command-dispatcher's positional (type, payload, user) calls to
|
|
210
|
-
// DispatchWriteRef's (user, qn, payload) shape — JobContext.write/queryAs
|
|
211
|
-
// pass identity explicitly per call (boot-time singleton), while Dispatcher
|
|
212
|
-
// takes it last (request-scoped closure caller). Same underlying pipeline,
|
|
213
|
-
// different argument order.
|
|
214
|
-
function dispatcherToWriteRef(dispatcher: Dispatcher): DispatchWriteRef {
|
|
215
|
-
return {
|
|
216
|
-
write: (user, qn, payload) => dispatcher.write(qn, payload, user),
|
|
217
|
-
queryAs: (user, qn, payload) => dispatcher.query(qn, payload, user),
|
|
218
|
-
};
|
|
219
|
-
}
|
|
220
|
-
|
|
221
210
|
// buildApiServer shapes ServerOptions from API-mode caller-options.
|
|
222
211
|
// AllInOneEntrypointOptions extends ApiEntrypointOptions, so structural
|
|
223
212
|
// subtyping makes the all-in-one path a valid caller without an explicit
|
package/src/errors/i18n/de.yaml
CHANGED
|
@@ -97,6 +97,42 @@ system_identity_switch_denied:
|
|
|
97
97
|
(oder am `r.hook(...)`-Aufruf, wenn der Wechsel in einem Hook passiert),
|
|
98
98
|
oder verlagere die Logik in ein `r.systemScope()`-Feature oder einen Job.
|
|
99
99
|
|
|
100
|
+
identity_switch_denied:
|
|
101
|
+
endUser: |
|
|
102
|
+
Diese Aktion ist nicht erlaubt.
|
|
103
|
+
Wende dich an einen Administrator wenn du glaubst, dass das ein Fehler ist.
|
|
104
|
+
developer: |
|
|
105
|
+
`AccessDeniedError`: ein Handler oder Hook hat `ctx.queryAs`/`ctx.writeAs`
|
|
106
|
+
mit einer anderen Identität als dem eigenen Caller aufgerufen — andere
|
|
107
|
+
User-ID, anderer Tenant, andere Claims oder Origin, oder Rollen, die der
|
|
108
|
+
Caller nicht hat — ohne `r.systemScope()`-Handler, Job zu sein oder
|
|
109
|
+
`escapeHatch: { reason }` zu deklarieren. Der Wechsel auf den Caller selbst
|
|
110
|
+
oder eine Teilmenge seiner Rollen bleibt frei.
|
|
111
|
+
|
|
112
|
+
Deklariere `escapeHatch: { reason: "..." }` am Write- oder Query-Handler
|
|
113
|
+
(oder am `r.hook(...)`-Aufruf, wenn der Wechsel in einem Hook passiert),
|
|
114
|
+
oder verlagere die Logik in ein `r.systemScope()`-Feature oder einen Job.
|
|
115
|
+
|
|
116
|
+
member_resolution_denied:
|
|
117
|
+
endUser: |
|
|
118
|
+
Diese Aktion ist nicht erlaubt.
|
|
119
|
+
Wende dich an einen Administrator wenn du glaubst, dass das ein Fehler ist.
|
|
120
|
+
developer: |
|
|
121
|
+
`AccessDeniedError`: `ctx.queryAsMember(userId, ...)` konnte für `userId`
|
|
122
|
+
keine aktive Mitgliedschaft im Tenant des Aufrufers auflösen (kein
|
|
123
|
+
Mitglied, Principal gesperrt, oder der Tenant befindet sich im Teardown).
|
|
124
|
+
Bewusst generisch — `details` verrät nie, welche Prüfung fehlgeschlagen ist.
|
|
125
|
+
|
|
126
|
+
member_resolution_read_only:
|
|
127
|
+
endUser: |
|
|
128
|
+
Diese Aktion ist nicht erlaubt.
|
|
129
|
+
Wende dich an einen Administrator wenn du glaubst, dass das ein Fehler ist.
|
|
130
|
+
developer: |
|
|
131
|
+
`AccessDeniedError`: ein über `ctx.queryAsMember` erreichter Query-Handler
|
|
132
|
+
hat versucht zu schreiben (`ctx.write`/`writeAs`/`appendEvent`/
|
|
133
|
+
`jobRunner`/...). Aufgelöste Member-Principals sind nur lesend — es gibt
|
|
134
|
+
kein `writeAsMember`.
|
|
135
|
+
|
|
100
136
|
agent.tool_not_allowed:
|
|
101
137
|
endUser: |
|
|
102
138
|
Dieses Tool ist im aktuellen Modus nicht verfügbar.
|
package/src/errors/i18n/en.yaml
CHANGED
|
@@ -94,6 +94,40 @@ system_identity_switch_denied:
|
|
|
94
94
|
(or on the `r.hook(...)` call, if the switch happens inside a hook), or
|
|
95
95
|
move the logic into an `r.systemScope()` feature or a job.
|
|
96
96
|
|
|
97
|
+
identity_switch_denied:
|
|
98
|
+
endUser: |
|
|
99
|
+
This action isn't allowed.
|
|
100
|
+
Please contact an administrator if you believe this is a mistake.
|
|
101
|
+
developer: |
|
|
102
|
+
`AccessDeniedError`: a handler or hook called `ctx.queryAs`/`ctx.writeAs`
|
|
103
|
+
with an identity other than its own caller — a different user id, tenant,
|
|
104
|
+
claims or origin, or roles the caller does not have — without being an
|
|
105
|
+
`r.systemScope()` handler, a job, or declaring `escapeHatch: { reason }`.
|
|
106
|
+
Switching to the caller itself or to a subset of its roles stays free.
|
|
107
|
+
|
|
108
|
+
Declare `escapeHatch: { reason: "..." }` on the write or query handler
|
|
109
|
+
(or on the `r.hook(...)` call, if the switch happens inside a hook), or
|
|
110
|
+
move the logic into an `r.systemScope()` feature or a job.
|
|
111
|
+
|
|
112
|
+
member_resolution_denied:
|
|
113
|
+
endUser: |
|
|
114
|
+
This action isn't allowed.
|
|
115
|
+
Please contact an administrator if you believe this is a mistake.
|
|
116
|
+
developer: |
|
|
117
|
+
`AccessDeniedError`: `ctx.queryAsMember(userId, ...)` could not resolve an
|
|
118
|
+
active membership for `userId` in the caller's tenant (not a member,
|
|
119
|
+
principal blocked, or the tenant is in teardown). Deliberately generic —
|
|
120
|
+
`details` never says which check failed.
|
|
121
|
+
|
|
122
|
+
member_resolution_read_only:
|
|
123
|
+
endUser: |
|
|
124
|
+
This action isn't allowed.
|
|
125
|
+
Please contact an administrator if you believe this is a mistake.
|
|
126
|
+
developer: |
|
|
127
|
+
`AccessDeniedError`: a query handler reached via `ctx.queryAsMember` tried
|
|
128
|
+
to write (`ctx.write`/`writeAs`/`appendEvent`/`jobRunner`/...). Resolved
|
|
129
|
+
member principals are read-only — there is no `writeAsMember`.
|
|
130
|
+
|
|
97
131
|
agent.tool_not_allowed:
|
|
98
132
|
endUser: |
|
|
99
133
|
This tool is not available in the current mode.
|
package/src/errors/reasons.ts
CHANGED
|
@@ -42,6 +42,19 @@ export const FrameworkReasons = {
|
|
|
42
42
|
// AccessDeniedError: a handler/hook called ctx.queryAs/ctx.writeAs with a
|
|
43
43
|
// SYSTEM identity without an r.systemScope() feature or a declared escapeHatch.
|
|
44
44
|
systemIdentitySwitchDenied: "system_identity_switch_denied",
|
|
45
|
+
|
|
46
|
+
// AccessDeniedError: a handler/hook called ctx.queryAs/ctx.writeAs with a
|
|
47
|
+
// non-SYSTEM identity other than its own caller (different user, tenant,
|
|
48
|
+
// claims, origin or extra roles) without r.systemScope() or escapeHatch.
|
|
49
|
+
identitySwitchDenied: "identity_switch_denied",
|
|
50
|
+
|
|
51
|
+
// AccessDeniedError: ctx.queryAsMember's userId is not an active member of
|
|
52
|
+
// ctx's tenant. Deliberately generic — a caller can't probe which check failed.
|
|
53
|
+
memberResolutionDenied: "member_resolution_denied",
|
|
54
|
+
|
|
55
|
+
// AccessDeniedError: a query handler invoked via ctx.queryAsMember tried to
|
|
56
|
+
// write — a resolved member principal is read-only by construction.
|
|
57
|
+
memberResolutionReadOnly: "member_resolution_read_only",
|
|
45
58
|
} as const;
|
|
46
59
|
|
|
47
60
|
export type FrameworkReason = (typeof FrameworkReasons)[keyof typeof FrameworkReasons];
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type DispatchWriteRef,
|
|
11
11
|
type JobContext,
|
|
12
12
|
type JobRunIn,
|
|
13
|
+
type MemberReader,
|
|
13
14
|
type Registry,
|
|
14
15
|
type SessionUser,
|
|
15
16
|
SYSTEM_TENANT_ID,
|
|
@@ -552,6 +553,9 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
552
553
|
tenantId,
|
|
553
554
|
})
|
|
554
555
|
: context.derivatives;
|
|
556
|
+
// Per-job-run reader, created lazily on queryAsMember's first call and
|
|
557
|
+
// cached for the rest of this run.
|
|
558
|
+
let memberReader: MemberReader | undefined;
|
|
555
559
|
const jobContext: JobContext = {
|
|
556
560
|
...context,
|
|
557
561
|
// Same union as configDb above — job runners are always constructed
|
|
@@ -597,6 +601,16 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
597
601
|
}
|
|
598
602
|
return dispatchWriteRef.queryAs(user, qn, payload);
|
|
599
603
|
},
|
|
604
|
+
queryAsMember: (userId: string, qn: string, payload: unknown) => {
|
|
605
|
+
if (!dispatchWriteRef) {
|
|
606
|
+
throw new Error(
|
|
607
|
+
"JobContext.queryAsMember called before dispatcher attached — call attachDispatcher() first",
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
// Lazy per-run reader, mirroring the handler-context reader.
|
|
611
|
+
memberReader ??= dispatchWriteRef.createMemberReader(tenantId);
|
|
612
|
+
return memberReader(userId, qn, payload);
|
|
613
|
+
},
|
|
600
614
|
};
|
|
601
615
|
|
|
602
616
|
await options.onJobStart?.(jobName, jobId, meta);
|
|
@@ -51,6 +51,23 @@ describe("dispatcherSpanAttributes", () => {
|
|
|
51
51
|
"kumiko.feature": "feat",
|
|
52
52
|
});
|
|
53
53
|
});
|
|
54
|
+
|
|
55
|
+
test("includes kumiko.user_origin when the user has one", () => {
|
|
56
|
+
const user = {
|
|
57
|
+
id: "u1",
|
|
58
|
+
tenantId: "tenant-1",
|
|
59
|
+
roles: [],
|
|
60
|
+
origin: "member-resolution" as const,
|
|
61
|
+
};
|
|
62
|
+
const attrs = dispatcherSpanAttributes("feat:query:task:list", "query", user, "feat");
|
|
63
|
+
expect(attrs["kumiko.user_origin"]).toBe("member-resolution");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("omits kumiko.user_origin when the user has none", () => {
|
|
67
|
+
const user = createSystemUser("tenant-1");
|
|
68
|
+
const attrs = dispatcherSpanAttributes("feat:query:task:list", "query", user, "feat");
|
|
69
|
+
expect(attrs["kumiko.user_origin"]).toBeUndefined();
|
|
70
|
+
});
|
|
54
71
|
});
|
|
55
72
|
|
|
56
73
|
describe("prefixValidationPath", () => {
|
|
@@ -19,7 +19,6 @@ const globalStoreTable = defineUnmanagedTable({
|
|
|
19
19
|
primaryKey: true,
|
|
20
20
|
defaultSql: "gen_random_uuid()",
|
|
21
21
|
},
|
|
22
|
-
{ name: "tenant_id", pgType: "uuid", notNull: true },
|
|
23
22
|
{ name: "note", pgType: "text", notNull: true },
|
|
24
23
|
],
|
|
25
24
|
});
|
|
@@ -36,7 +35,6 @@ const escapeHatchFeature = defineFeature("escape-hatch-probe", (r) => {
|
|
|
36
35
|
escapeHatch: { reason: "fw#2855 integration test — declared cross-tenant write" },
|
|
37
36
|
handler: async (event, ctx) => {
|
|
38
37
|
const row = await ctx.db.global(globalStoreTable).insertOne<{ id: string }>({
|
|
39
|
-
tenantId: event.user.tenantId,
|
|
40
38
|
note: event.payload.note,
|
|
41
39
|
});
|
|
42
40
|
return { isSuccess: true as const, data: row };
|
|
@@ -49,7 +47,6 @@ const escapeHatchFeature = defineFeature("escape-hatch-probe", (r) => {
|
|
|
49
47
|
access: { roles: ["User"] },
|
|
50
48
|
handler: async (event, ctx) => {
|
|
51
49
|
const row = await ctx.db.global(globalStoreTable).insertOne<{ id: string }>({
|
|
52
|
-
tenantId: event.user.tenantId,
|
|
53
50
|
note: event.payload.note,
|
|
54
51
|
});
|
|
55
52
|
return { isSuccess: true as const, data: row };
|