@cosmicdrift/kumiko-framework 0.266.0 → 0.268.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/api/__tests__/api.test.ts +6 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +6 -0
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +6 -0
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +6 -0
- package/src/api/__tests__/jwt.test.ts +8 -0
- package/src/api/jwt.ts +8 -0
- package/src/changes.json +15 -0
- package/src/db/__tests__/event-store-executor-global-tenancy.integration.test.ts +73 -0
- package/src/db/__tests__/tenant-db-global.integration.test.ts +63 -26
- package/src/db/__tests__/tenant-db-global.test.ts +95 -5
- package/src/db/entity-table-meta.ts +7 -0
- package/src/db/event-store-executor-context.ts +19 -0
- package/src/db/event-store-executor-write.ts +16 -0
- package/src/db/index.ts +3 -1
- package/src/db/table-builder.ts +27 -0
- package/src/db/tenant-db.ts +83 -10
- package/src/engine/active-membership.ts +15 -1
- package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +58 -0
- package/src/engine/boot-validator/global-tenancy.ts +17 -0
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/feature-ui-extensions.ts +2 -2
- package/src/engine/index.ts +3 -0
- package/src/engine/registry-validate.ts +6 -6
- package/src/engine/types/index.ts +2 -0
- package/src/entrypoint/index.ts +2 -13
- package/src/errors/i18n/de.yaml +20 -0
- package/src/errors/i18n/en.yaml +19 -0
- package/src/errors/reasons.ts +8 -0
- package/src/jobs/job-runner.ts +14 -0
- package/src/pipeline/__tests__/dispatcher-utils.test.ts +17 -0
- package/src/pipeline/__tests__/escape-hatch.integration.test.ts +0 -3
- package/src/pipeline/__tests__/system-identity-switch.test.ts +60 -15
- package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +103 -0
- package/src/pipeline/active-membership.ts +3 -1
- package/src/pipeline/dispatch-shared.ts +89 -10
- package/src/pipeline/dispatch-stream.ts +9 -5
- package/src/pipeline/dispatch-write.ts +7 -0
- package/src/pipeline/dispatcher-utils.ts +1 -0
- package/src/pipeline/dispatcher.ts +18 -0
- package/src/pipeline/index.ts +1 -1
- package/src/pipeline/member-reader.ts +108 -0
- package/src/pipeline/system-identity-switch.ts +73 -13
- package/src/stack/test-stack.ts +10 -1
- package/src/testing/handler-context.ts +3 -0
package/src/db/table-builder.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
moneyAmount,
|
|
30
30
|
table as pgTable,
|
|
31
31
|
plainDate,
|
|
32
|
+
type SchemaTable,
|
|
32
33
|
SQL_EXPR_BRAND,
|
|
33
34
|
type SqlExpression,
|
|
34
35
|
serial,
|
|
@@ -505,6 +506,32 @@ function stampGlobalTenancyMeta<E extends EntityDefinition>(
|
|
|
505
506
|
};
|
|
506
507
|
}
|
|
507
508
|
|
|
509
|
+
/**
|
|
510
|
+
* Stamps `tenancy: "global"` onto a `table()`/SchemaTable's meta in place.
|
|
511
|
+
* Throws if the table has a `tenant_id` column or no EntityTableMeta to stamp.
|
|
512
|
+
*/
|
|
513
|
+
export function declareGlobalTenancy<T extends SchemaTable>(table: T): T & TenancyBrand<"global"> {
|
|
514
|
+
const meta = (table as unknown as Record<symbol, EntityTableMeta | undefined>)[
|
|
515
|
+
KUMIKO_META_SYMBOL
|
|
516
|
+
];
|
|
517
|
+
if (!meta) {
|
|
518
|
+
throw new Error("declareGlobalTenancy(): table has no EntityTableMeta to stamp.");
|
|
519
|
+
}
|
|
520
|
+
if (meta.columns.some((c) => c.name === "tenant_id")) {
|
|
521
|
+
throw new Error(
|
|
522
|
+
`declareGlobalTenancy("${meta.tableName}"): tables with a "tenant_id" column cannot be ` +
|
|
523
|
+
'declared tenancy: "global" — a global table\'s rows carry the system tenant, not a ' +
|
|
524
|
+
"per-row tenant identity.",
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
(table as unknown as Record<symbol, EntityTableMeta>)[KUMIKO_META_SYMBOL] = {
|
|
528
|
+
...meta,
|
|
529
|
+
tenancy: "global",
|
|
530
|
+
};
|
|
531
|
+
// @cast-boundary type-brand — TenancyBrand<"global"> is a phantom marker with no runtime representation.
|
|
532
|
+
return table as T & TenancyBrand<"global">;
|
|
533
|
+
}
|
|
534
|
+
|
|
508
535
|
export function buildEntityTable<E extends EntityDefinition>(
|
|
509
536
|
entityName: string,
|
|
510
537
|
entity: E,
|
package/src/db/tenant-db.ts
CHANGED
|
@@ -137,17 +137,48 @@ function tableNameOf(table: Table | EntityTableMeta): string {
|
|
|
137
137
|
return asEntityTableMeta(table)?.tableName ?? "<unknown>";
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
// A grant is only real when its reason is non-empty — `{ reason: "" }` must not silently unlock it.
|
|
141
|
+
function hasGrant(decl: EscapeHatchDeclaration | undefined): boolean {
|
|
142
|
+
return decl !== undefined && decl.reason.trim().length > 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isForeignTenantId(tenantIdValue: unknown): boolean {
|
|
146
|
+
return tenantIdValue !== undefined && tenantIdValue !== SYSTEM_TENANT_ID;
|
|
147
|
+
}
|
|
148
|
+
|
|
140
149
|
// Checks the canonical EntityTableMeta (branded EntityTable's KUMIKO_META_SYMBOL
|
|
141
150
|
// or a plain deriveEntityTableMeta/defineUnmanagedTable result), not a direct
|
|
142
151
|
// `table.tenantId` property read — the latter only exists on branded EntityTables
|
|
143
152
|
// and silently returned false (no tenant filter!) for plain EntityTableMeta
|
|
144
153
|
// tables like unmanaged direct-write stores, e.g. userSessionTable.
|
|
145
|
-
function hasTenantColumn(table: Table): boolean {
|
|
154
|
+
function hasTenantColumn(table: Table | EntityTableMeta): boolean {
|
|
146
155
|
const meta = asEntityTableMeta(table);
|
|
147
156
|
if (meta) return meta.columns.some((c) => c.name === "tenant_id");
|
|
148
157
|
return (table as Record<string, unknown>)["tenantId"] !== undefined;
|
|
149
158
|
}
|
|
150
159
|
|
|
160
|
+
// Grants for TenantDb's two escape hatches: db.global()'s write methods
|
|
161
|
+
// (write-handler-only) and ctx.db.unsafeRaw() (handler or hook, re-granted per hook).
|
|
162
|
+
export type TenantDbGrants = {
|
|
163
|
+
readonly globalWrites?: EscapeHatchDeclaration;
|
|
164
|
+
readonly unsafeRaw?: EscapeHatchDeclaration;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const unsafeRawRebinders = new WeakMap<
|
|
168
|
+
TenantDb,
|
|
169
|
+
(grant: EscapeHatchDeclaration | undefined) => TenantDb
|
|
170
|
+
>();
|
|
171
|
+
|
|
172
|
+
// Rebinds tenantDb's unsafeRaw grant (e.g. a hook's own escapeHatch); inputs not built by
|
|
173
|
+
// createTenantDb pass through unchanged, without touching any property (ctx.db may be a throwing Proxy).
|
|
174
|
+
export function withUnsafeRawGrant(
|
|
175
|
+
tenantDb: TenantDb,
|
|
176
|
+
grant: EscapeHatchDeclaration | undefined,
|
|
177
|
+
): TenantDb {
|
|
178
|
+
const rebind = unsafeRawRebinders.get(tenantDb);
|
|
179
|
+
return rebind ? rebind(grant) : tenantDb;
|
|
180
|
+
}
|
|
181
|
+
|
|
151
182
|
export function createTenantDb(
|
|
152
183
|
db: DbRunner,
|
|
153
184
|
tenantId: TenantId,
|
|
@@ -155,8 +186,7 @@ export function createTenantDb(
|
|
|
155
186
|
tracer?: Tracer,
|
|
156
187
|
meter?: Meter,
|
|
157
188
|
signal?: AbortSignal,
|
|
158
|
-
|
|
159
|
-
escapeHatch?: EscapeHatchDeclaration,
|
|
189
|
+
grants?: TenantDbGrants,
|
|
160
190
|
): TenantDb {
|
|
161
191
|
if (meter) registerStandardMetrics(meter);
|
|
162
192
|
|
|
@@ -215,7 +245,7 @@ export function createTenantDb(
|
|
|
215
245
|
// (e.g. exclude SYSTEM reference rows at the DB instead of post-filtering
|
|
216
246
|
// after a limit). Values outside the scope are dropped; if nothing valid
|
|
217
247
|
// remains, the full enforced scope applies — widening is never possible.
|
|
218
|
-
function readWhere(table: Table, where?: WhereObject): WhereObject | undefined {
|
|
248
|
+
function readWhere(table: Table | EntityTableMeta, where?: WhereObject): WhereObject | undefined {
|
|
219
249
|
if (!hasTenantColumn(table) || mode === "system") return where;
|
|
220
250
|
const allowed = [tenantId, SYSTEM_TENANT_ID];
|
|
221
251
|
const requested = where?.["tenantId"];
|
|
@@ -242,7 +272,7 @@ export function createTenantDb(
|
|
|
242
272
|
}
|
|
243
273
|
|
|
244
274
|
function missingEscapeHatch(table: Table | EntityTableMeta): AccessDeniedError | undefined {
|
|
245
|
-
if (
|
|
275
|
+
if (hasGrant(grants?.globalWrites)) return undefined;
|
|
246
276
|
return new AccessDeniedError({
|
|
247
277
|
message:
|
|
248
278
|
`db.global(${tableNameOf(table)}): write rejected — declare ` +
|
|
@@ -251,6 +281,15 @@ export function createTenantDb(
|
|
|
251
281
|
});
|
|
252
282
|
}
|
|
253
283
|
|
|
284
|
+
function foreignTenantOnGlobalWrite(
|
|
285
|
+
table: Table | EntityTableMeta,
|
|
286
|
+
tenantIdValue: unknown,
|
|
287
|
+
message: string = `db.global(${tableNameOf(table)}): tenantId "${String(tenantIdValue)}" is not SYSTEM_TENANT_ID — a "global" table's rows must carry the system tenant, not an arbitrary tenant's id.`,
|
|
288
|
+
): AccessDeniedError | undefined {
|
|
289
|
+
if (!isForeignTenantId(tenantIdValue)) return undefined;
|
|
290
|
+
return new AccessDeniedError({ message });
|
|
291
|
+
}
|
|
292
|
+
|
|
254
293
|
function globalTable<TTable extends (SchemaTable | EntityTableMeta) & TenancyBrand<"global">>(
|
|
255
294
|
table: TTable,
|
|
256
295
|
): GlobalTableDb<TTable> {
|
|
@@ -273,7 +312,8 @@ export function createTenantDb(
|
|
|
273
312
|
insertOne<T = Record<string, unknown>>(
|
|
274
313
|
values: Record<string, unknown>,
|
|
275
314
|
): Promise<T | undefined> {
|
|
276
|
-
const denied =
|
|
315
|
+
const denied =
|
|
316
|
+
missingEscapeHatch(table) ?? foreignTenantOnGlobalWrite(table, values["tenantId"]);
|
|
277
317
|
if (denied) return Promise.reject(denied);
|
|
278
318
|
return withDbSpan("insert", table, async () => bunInsertOne<T>(db, table, values));
|
|
279
319
|
},
|
|
@@ -281,7 +321,8 @@ export function createTenantDb(
|
|
|
281
321
|
set: Record<string, unknown>,
|
|
282
322
|
where: WhereObject,
|
|
283
323
|
): Promise<readonly T[]> {
|
|
284
|
-
const denied =
|
|
324
|
+
const denied =
|
|
325
|
+
missingEscapeHatch(table) ?? foreignTenantOnGlobalWrite(table, set["tenantId"]);
|
|
285
326
|
if (denied) return Promise.reject(denied);
|
|
286
327
|
if (!where || Object.keys(where).length === 0) {
|
|
287
328
|
return Promise.reject(
|
|
@@ -308,14 +349,28 @@ export function createTenantDb(
|
|
|
308
349
|
} as GlobalTableDb<TTable>;
|
|
309
350
|
}
|
|
310
351
|
|
|
311
|
-
|
|
352
|
+
const tenantDb: TenantDb = {
|
|
312
353
|
tenantId,
|
|
313
354
|
mode,
|
|
314
355
|
raw: db,
|
|
315
356
|
global: globalTable,
|
|
316
357
|
|
|
358
|
+
unsafeRaw(reason: string): DbRunner {
|
|
359
|
+
if (reason.trim().length === 0) {
|
|
360
|
+
throw new Error("unsafeRaw requires a non-empty reason");
|
|
361
|
+
}
|
|
362
|
+
if (!hasGrant(grants?.unsafeRaw)) {
|
|
363
|
+
throw new AccessDeniedError({
|
|
364
|
+
message:
|
|
365
|
+
'ctx.db.unsafeRaw(reason): rejected — declare `escapeHatch: { reason: "..." }` on ' +
|
|
366
|
+
"the handler or hook to allow unsafeRaw.",
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
return db;
|
|
370
|
+
},
|
|
371
|
+
|
|
317
372
|
selectMany<T = Record<string, unknown>>(
|
|
318
|
-
table: Table,
|
|
373
|
+
table: Table | EntityTableMeta,
|
|
319
374
|
where?: WhereObject,
|
|
320
375
|
options?: SelectOptions,
|
|
321
376
|
): Promise<readonly T[]> {
|
|
@@ -324,7 +379,7 @@ export function createTenantDb(
|
|
|
324
379
|
},
|
|
325
380
|
|
|
326
381
|
fetchOne<T = Record<string, unknown>>(
|
|
327
|
-
table: Table,
|
|
382
|
+
table: Table | EntityTableMeta,
|
|
328
383
|
where: WhereObject,
|
|
329
384
|
): Promise<T | undefined> {
|
|
330
385
|
const filter = readWhere(table, where) ?? {};
|
|
@@ -335,6 +390,19 @@ export function createTenantDb(
|
|
|
335
390
|
table: Table,
|
|
336
391
|
values: Record<string, unknown>,
|
|
337
392
|
): Promise<T | undefined> {
|
|
393
|
+
if (
|
|
394
|
+
mode === "tenant" &&
|
|
395
|
+
hasTenantColumn(table) &&
|
|
396
|
+
asEntityTableMeta(table)?.tenancy === "global"
|
|
397
|
+
) {
|
|
398
|
+
const denied = foreignTenantOnGlobalWrite(
|
|
399
|
+
table,
|
|
400
|
+
tenantId,
|
|
401
|
+
`insertOne(${tableNameOf(table)}): "global" table rows must carry SYSTEM_TENANT_ID; ` +
|
|
402
|
+
"use db.global(table) with escapeHatch instead.",
|
|
403
|
+
);
|
|
404
|
+
if (denied) return Promise.reject(denied);
|
|
405
|
+
}
|
|
338
406
|
const data = insertValues(table, values);
|
|
339
407
|
return withDbSpan("insert", table, async () => bunInsertOne<T>(db, table, data));
|
|
340
408
|
},
|
|
@@ -367,6 +435,11 @@ export function createTenantDb(
|
|
|
367
435
|
return withDbSpan("delete", table, async () => bunDeleteMany(db, table, filter));
|
|
368
436
|
},
|
|
369
437
|
};
|
|
438
|
+
|
|
439
|
+
unsafeRawRebinders.set(tenantDb, (grant) =>
|
|
440
|
+
createTenantDb(db, tenantId, mode, tracer, meter, signal, { ...grants, unsafeRaw: grant }),
|
|
441
|
+
);
|
|
442
|
+
return tenantDb;
|
|
370
443
|
}
|
|
371
444
|
|
|
372
445
|
export { asRawClient };
|
|
@@ -10,12 +10,26 @@ import type { TenantId } from "./types/identifiers";
|
|
|
10
10
|
// with a since-deleted row should not silently treat it as active.
|
|
11
11
|
export type PrincipalStatus = "active" | "blocked" | "unknown";
|
|
12
12
|
|
|
13
|
+
// Global-roles/timezone/locale snapshot ctx.queryAsMember needs to mint a
|
|
14
|
+
// resolved principal's SessionUser, without exposing its own table.
|
|
15
|
+
export type PrincipalProfile = {
|
|
16
|
+
readonly globalRoles: readonly string[];
|
|
17
|
+
readonly timezone?: string;
|
|
18
|
+
readonly locale?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
13
21
|
// Registered via r.useExtension(EXT_PRINCIPAL_STATUS, "user", principalStatusPlugin).
|
|
14
22
|
export type PrincipalStatusPlugin = {
|
|
15
23
|
readonly resolveStatus: (
|
|
16
24
|
userId: string,
|
|
17
25
|
deps: { readonly db: DbConnection },
|
|
18
26
|
) => Promise<PrincipalStatus>;
|
|
27
|
+
// Required so a missing provider fails closed (InternalError) instead of
|
|
28
|
+
// silently minting empty global roles for ctx.queryAsMember.
|
|
29
|
+
readonly resolveProfile: (
|
|
30
|
+
userId: string,
|
|
31
|
+
deps: { readonly db: DbConnection },
|
|
32
|
+
) => Promise<PrincipalProfile | null>;
|
|
19
33
|
};
|
|
20
34
|
|
|
21
35
|
// `null` means the tenant has no lifecycle row (never entered teardown).
|
|
@@ -38,7 +52,7 @@ function hasResolveStatusFn(v: unknown): v is { readonly resolveStatus: unknown
|
|
|
38
52
|
}
|
|
39
53
|
|
|
40
54
|
export function isPrincipalStatusPlugin(v: unknown): v is PrincipalStatusPlugin {
|
|
41
|
-
return hasResolveStatusFn(v);
|
|
55
|
+
return hasResolveStatusFn(v) && "resolveProfile" in v && typeof v.resolveProfile === "function";
|
|
42
56
|
}
|
|
43
57
|
|
|
44
58
|
export function isTenantLifecycleStatusPlugin(v: unknown): v is TenantLifecycleStatusPlugin {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// fw#2858 — a `tenancy: "global"` entity's event stream must live on
|
|
2
|
+
// SYSTEM_TENANT_ID (systemStream: true); otherwise it would silently fork
|
|
3
|
+
// one stream per creating tenant while still being writable/readable
|
|
4
|
+
// cross-tenant via db.global(table).
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { defineFeature } from "../../define-feature";
|
|
8
|
+
import { createEntity, createTextField } from "../../factories";
|
|
9
|
+
import { validateGlobalTenancyEntities } from "../global-tenancy";
|
|
10
|
+
|
|
11
|
+
const textField = () =>
|
|
12
|
+
createTextField({
|
|
13
|
+
required: true,
|
|
14
|
+
maxLength: 64,
|
|
15
|
+
personal: false,
|
|
16
|
+
reason: "technical_reference",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("validateGlobalTenancyEntities", () => {
|
|
20
|
+
test("a tenancy: 'global' entity WITHOUT systemStream: true throws", () => {
|
|
21
|
+
const entity = createEntity({
|
|
22
|
+
table: "read_fw2858_global_no_stream",
|
|
23
|
+
tenancy: "global",
|
|
24
|
+
fields: { name: textField() },
|
|
25
|
+
});
|
|
26
|
+
const feature = defineFeature("fw2858-global-no-stream", (r) => {
|
|
27
|
+
r.entity("fw2858GlobalNoStream", entity);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
expect(() => validateGlobalTenancyEntities([feature])).toThrow(/systemStream/);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("a tenancy: 'global' entity WITH systemStream: true passes", () => {
|
|
34
|
+
const entity = createEntity({
|
|
35
|
+
table: "read_fw2858_global_with_stream",
|
|
36
|
+
tenancy: "global",
|
|
37
|
+
systemStream: true,
|
|
38
|
+
fields: { name: textField() },
|
|
39
|
+
});
|
|
40
|
+
const feature = defineFeature("fw2858-global-with-stream", (r) => {
|
|
41
|
+
r.entity("fw2858GlobalWithStream", entity);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(() => validateGlobalTenancyEntities([feature])).not.toThrow();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("a tenant-tenancy entity without systemStream is never checked", () => {
|
|
48
|
+
const entity = createEntity({
|
|
49
|
+
table: "read_fw2858_tenant_entity",
|
|
50
|
+
fields: { name: textField() },
|
|
51
|
+
});
|
|
52
|
+
const feature = defineFeature("fw2858-tenant-entity", (r) => {
|
|
53
|
+
r.entity("fw2858TenantEntity", entity);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(() => validateGlobalTenancyEntities([feature])).not.toThrow();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -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) {
|
|
@@ -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,
|
|
@@ -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,26 @@ 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
|
+
member_resolution_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`: `ctx.queryAsMember(userId, ...)` konnte für `userId`
|
|
106
|
+
keine aktive Mitgliedschaft im Tenant des Aufrufers auflösen (kein
|
|
107
|
+
Mitglied, Principal gesperrt, oder der Tenant befindet sich im Teardown).
|
|
108
|
+
Bewusst generisch — `details` verrät nie, welche Prüfung fehlgeschlagen ist.
|
|
109
|
+
|
|
110
|
+
member_resolution_read_only:
|
|
111
|
+
endUser: |
|
|
112
|
+
Diese Aktion ist nicht erlaubt.
|
|
113
|
+
Wende dich an einen Administrator wenn du glaubst, dass das ein Fehler ist.
|
|
114
|
+
developer: |
|
|
115
|
+
`AccessDeniedError`: ein über `ctx.queryAsMember` erreichter Query-Handler
|
|
116
|
+
hat versucht zu schreiben (`ctx.write`/`writeAs`/`appendEvent`/
|
|
117
|
+
`jobRunner`/...). Aufgelöste Member-Principals sind nur lesend — es gibt
|
|
118
|
+
kein `writeAsMember`.
|
|
119
|
+
|
|
100
120
|
agent.tool_not_allowed:
|
|
101
121
|
endUser: |
|
|
102
122
|
Dieses Tool ist im aktuellen Modus nicht verfügbar.
|
package/src/errors/i18n/en.yaml
CHANGED
|
@@ -94,6 +94,25 @@ 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
|
+
member_resolution_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`: `ctx.queryAsMember(userId, ...)` could not resolve an
|
|
103
|
+
active membership for `userId` in the caller's tenant (not a member,
|
|
104
|
+
principal blocked, or the tenant is in teardown). Deliberately generic —
|
|
105
|
+
`details` never says which check failed.
|
|
106
|
+
|
|
107
|
+
member_resolution_read_only:
|
|
108
|
+
endUser: |
|
|
109
|
+
This action isn't allowed.
|
|
110
|
+
Please contact an administrator if you believe this is a mistake.
|
|
111
|
+
developer: |
|
|
112
|
+
`AccessDeniedError`: a query handler reached via `ctx.queryAsMember` tried
|
|
113
|
+
to write (`ctx.write`/`writeAs`/`appendEvent`/`jobRunner`/...). Resolved
|
|
114
|
+
member principals are read-only — there is no `writeAsMember`.
|
|
115
|
+
|
|
97
116
|
agent.tool_not_allowed:
|
|
98
117
|
endUser: |
|
|
99
118
|
This tool is not available in the current mode.
|
package/src/errors/reasons.ts
CHANGED
|
@@ -42,6 +42,14 @@ 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: ctx.queryAsMember's userId is not an active member of
|
|
47
|
+
// ctx's tenant. Deliberately generic — a caller can't probe which check failed.
|
|
48
|
+
memberResolutionDenied: "member_resolution_denied",
|
|
49
|
+
|
|
50
|
+
// AccessDeniedError: a query handler invoked via ctx.queryAsMember tried to
|
|
51
|
+
// write — a resolved member principal is read-only by construction.
|
|
52
|
+
memberResolutionReadOnly: "member_resolution_read_only",
|
|
45
53
|
} as const;
|
|
46
54
|
|
|
47
55
|
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 };
|