@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
|
@@ -2,11 +2,18 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import type { EntityTableMeta } from "@cosmicdrift/kumiko-types/entity-table-meta-types";
|
|
3
3
|
import type { TenancyBrand } from "@cosmicdrift/kumiko-types/tenancy-brand";
|
|
4
4
|
import { createEntity, createTextField } from "../../engine";
|
|
5
|
+
import { AccessDeniedError } from "../../errors";
|
|
5
6
|
import { testTenantId } from "../../stack";
|
|
6
7
|
import type { DbRunner } from "../connection";
|
|
8
|
+
import type { TableColumns } from "../dialect";
|
|
7
9
|
import { defineUnmanagedTable, deriveEntityTableMeta } from "../entity-table-meta";
|
|
8
10
|
import { buildEntityTable } from "../table-builder";
|
|
9
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
createTenantDb,
|
|
13
|
+
createUncheckedSystemDb,
|
|
14
|
+
type TenantDb,
|
|
15
|
+
withUnsafeRawGrant,
|
|
16
|
+
} from "../tenant-db";
|
|
10
17
|
|
|
11
18
|
// db.global()'s runtime + write-gate behaviour (fw#2855). No Postgres needed —
|
|
12
19
|
// the stub runner throws on first touch, so a test that reaches it fails loudly
|
|
@@ -125,9 +132,7 @@ describe("TenantDb.global()", () => {
|
|
|
125
132
|
undefined,
|
|
126
133
|
undefined,
|
|
127
134
|
undefined,
|
|
128
|
-
{
|
|
129
|
-
reason: " ",
|
|
130
|
-
},
|
|
135
|
+
{ globalWrites: { reason: " " } },
|
|
131
136
|
);
|
|
132
137
|
await expect(tdb.global(globalUnmanagedTable).insertOne({ name: "x" })).rejects.toThrow(
|
|
133
138
|
/escapeHatch/,
|
|
@@ -146,11 +151,96 @@ describe("TenantDb.global()", () => {
|
|
|
146
151
|
},
|
|
147
152
|
} as DbRunner;
|
|
148
153
|
const tdb = createTenantDb(db, own, "tenant", undefined, undefined, undefined, {
|
|
149
|
-
reason: "cross-tenant backfill job",
|
|
154
|
+
globalWrites: { reason: "cross-tenant backfill job" },
|
|
150
155
|
});
|
|
151
156
|
await tdb.global(globalUnmanagedTable).insertOne({ name: "x" });
|
|
152
157
|
expect(captured).toHaveLength(1);
|
|
153
158
|
});
|
|
159
|
+
|
|
160
|
+
test("write with an explicit non-SYSTEM tenantId rejects even with a valid escapeHatch", async () => {
|
|
161
|
+
const tdb = createTenantDb(
|
|
162
|
+
unreachableRunner(),
|
|
163
|
+
own,
|
|
164
|
+
"tenant",
|
|
165
|
+
undefined,
|
|
166
|
+
undefined,
|
|
167
|
+
undefined,
|
|
168
|
+
{
|
|
169
|
+
globalWrites: { reason: "cross-tenant backfill job" },
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
await expect(
|
|
173
|
+
tdb.global(globalUnmanagedTable).insertOne({ name: "x", tenantId: own }),
|
|
174
|
+
).rejects.toThrow(/SYSTEM/);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("plain (non-global) insertOne on a global-tenancy managed table (has tenant_id) with a foreign tenant rejects before touching the runner", async () => {
|
|
178
|
+
const tdb = createTenantDb(unreachableRunner(), own);
|
|
179
|
+
// insertOne's WritableTable param types out branded EntityTables — same
|
|
180
|
+
// unbranded-view cast as tenant-db-global.integration.test.ts; runtime
|
|
181
|
+
// behavior only reads the table's EntityTableMeta, not the type brand.
|
|
182
|
+
await expect(
|
|
183
|
+
tdb.insertOne(globalManagedTable as unknown as TableColumns, { name: "x" }),
|
|
184
|
+
).rejects.toThrow(/SYSTEM/);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("TenantDb.unsafeRaw()", () => {
|
|
189
|
+
test("without a grant throws AccessDeniedError without touching the runner", () => {
|
|
190
|
+
const tdb = createTenantDb(unreachableRunner(), own);
|
|
191
|
+
expect(() => tdb.unsafeRaw("some reason")).toThrow(AccessDeniedError);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("an empty reason throws regardless of grant", () => {
|
|
195
|
+
const tdb = createTenantDb(
|
|
196
|
+
unreachableRunner(),
|
|
197
|
+
own,
|
|
198
|
+
"tenant",
|
|
199
|
+
undefined,
|
|
200
|
+
undefined,
|
|
201
|
+
undefined,
|
|
202
|
+
{
|
|
203
|
+
unsafeRaw: { reason: "granted" },
|
|
204
|
+
},
|
|
205
|
+
);
|
|
206
|
+
expect(() => tdb.unsafeRaw("")).toThrow();
|
|
207
|
+
expect(() => tdb.unsafeRaw(" ")).toThrow();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("with a valid grant returns the underlying DbRunner", () => {
|
|
211
|
+
const rawDb = unreachableRunner();
|
|
212
|
+
const tdb = createTenantDb(rawDb, own, "tenant", undefined, undefined, undefined, {
|
|
213
|
+
unsafeRaw: { reason: "granted" },
|
|
214
|
+
});
|
|
215
|
+
expect(tdb.unsafeRaw("some reason")).toBe(rawDb);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("withUnsafeRawGrant", () => {
|
|
220
|
+
test("rebinding to a grant makes unsafeRaw work", () => {
|
|
221
|
+
const rawDb = unreachableRunner();
|
|
222
|
+
const tdb = createTenantDb(rawDb, own);
|
|
223
|
+
expect(() => tdb.unsafeRaw("x")).toThrow(AccessDeniedError);
|
|
224
|
+
|
|
225
|
+
const rebound = withUnsafeRawGrant(tdb, { reason: "narrower hook grant" });
|
|
226
|
+
expect(rebound.unsafeRaw("x")).toBe(rawDb);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("rebinding to undefined makes unsafeRaw throw, even if the original had a grant", () => {
|
|
230
|
+
const rawDb = unreachableRunner();
|
|
231
|
+
const tdb = createTenantDb(rawDb, own, "tenant", undefined, undefined, undefined, {
|
|
232
|
+
unsafeRaw: { reason: "handler grant" },
|
|
233
|
+
});
|
|
234
|
+
expect(tdb.unsafeRaw("x")).toBe(rawDb);
|
|
235
|
+
|
|
236
|
+
const rebound = withUnsafeRawGrant(tdb, undefined);
|
|
237
|
+
expect(() => rebound.unsafeRaw("x")).toThrow(AccessDeniedError);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("passes an object not built by createTenantDb through unchanged", () => {
|
|
241
|
+
const notATenantDb = { some: "stub" } as unknown as TenantDb;
|
|
242
|
+
expect(withUnsafeRawGrant(notATenantDb, { reason: "x" })).toBe(notATenantDb);
|
|
243
|
+
});
|
|
154
244
|
});
|
|
155
245
|
|
|
156
246
|
describe("UncheckedSystemDb.unsafeRaw", () => {
|
|
@@ -459,6 +459,13 @@ export function defineUnmanagedTable<const T extends EntityTenancy = "tenant">(
|
|
|
459
459
|
input: UnmanagedTableInput & { readonly tenancy?: T },
|
|
460
460
|
): EntityTableMeta & TenancyBrand<T> {
|
|
461
461
|
assertUnmanagedTableName(input.tableName, "defineUnmanagedTable");
|
|
462
|
+
if (input.tenancy === "global" && input.columns.some((c) => c.name === "tenant_id")) {
|
|
463
|
+
throw new Error(
|
|
464
|
+
`defineUnmanagedTable("${input.tableName}"): tenancy: "global" unmanaged tables must not ` +
|
|
465
|
+
"have a tenant_id column — a global table's rows carry the system tenant, not a per-row " +
|
|
466
|
+
"tenant identity.",
|
|
467
|
+
);
|
|
468
|
+
}
|
|
462
469
|
return {
|
|
463
470
|
tableName: input.tableName,
|
|
464
471
|
columns: input.columns,
|
|
@@ -80,6 +80,16 @@ export function buildFilterWhere(
|
|
|
80
80
|
// doesn't carry a default or no default was declared. Only scalar types
|
|
81
81
|
// (text/number/boolean/select) support creation-time defaults — money/date/
|
|
82
82
|
// file/embedded fields don't.
|
|
83
|
+
// A "global" entity's stream must live on SYSTEM_TENANT_ID — otherwise the
|
|
84
|
+
// projection row and event stream would disagree on which tenant "owns" it.
|
|
85
|
+
export function isForeignTenantOnGlobalEntity(
|
|
86
|
+
entity: EntityDefinition,
|
|
87
|
+
tenantIdValue: unknown,
|
|
88
|
+
): boolean {
|
|
89
|
+
if (entity.tenancy !== "global") return false;
|
|
90
|
+
return tenantIdValue !== undefined && tenantIdValue !== SYSTEM_TENANT_ID;
|
|
91
|
+
}
|
|
92
|
+
|
|
83
93
|
function scalarDefault(field: FieldDefinition): unknown {
|
|
84
94
|
switch (field.type) {
|
|
85
95
|
case "text":
|
|
@@ -180,6 +190,15 @@ export function buildExecutorContext(
|
|
|
180
190
|
const { searchAdapter, entityName, entityCache } = options;
|
|
181
191
|
const softDelete = entity.softDelete ?? false;
|
|
182
192
|
|
|
193
|
+
// Without systemStream: true, a "global" entity would silently fork one event stream per creating tenant.
|
|
194
|
+
if (entity.tenancy === "global" && entity.systemStream !== true) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`event-store-executor requires entity "${entityName}" to declare systemStream: true ` +
|
|
197
|
+
'when tenancy: "global" is set — a global entity\'s event stream must live on the ' +
|
|
198
|
+
"system tenant, not the creating tenant's.",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
183
202
|
// Stream-tenant choke-point. A systemStream entity (tenant-independent, e.g.
|
|
184
203
|
// user) lives on SYSTEM_TENANT_ID deterministically — every op addresses it
|
|
185
204
|
// there. Everything else stays on the caller's tenant (byte-identical to the
|
|
@@ -3,6 +3,7 @@ import { userCanCreateFieldRow, userCanWriteFieldRow } from "../engine/ownership
|
|
|
3
3
|
import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user";
|
|
4
4
|
import type { EntityId, SessionUser } from "../engine/types";
|
|
5
5
|
import {
|
|
6
|
+
AccessDeniedError,
|
|
6
7
|
VersionConflictError as FrameworkVersionConflict,
|
|
7
8
|
IdempotentReplayError,
|
|
8
9
|
InternalError,
|
|
@@ -25,6 +26,7 @@ import {
|
|
|
25
26
|
buildEventMetadata,
|
|
26
27
|
type ExecutorContext,
|
|
27
28
|
entityEventName,
|
|
29
|
+
isForeignTenantOnGlobalEntity,
|
|
28
30
|
tryMapUniqueViolation,
|
|
29
31
|
} from "./event-store-executor-context";
|
|
30
32
|
import { runInSavepointIfSupported } from "./query";
|
|
@@ -115,6 +117,13 @@ export function createWriteVerbs(
|
|
|
115
117
|
|
|
116
118
|
return {
|
|
117
119
|
async create(payload, user, db, options) {
|
|
120
|
+
if (isForeignTenantOnGlobalEntity(entity, payload["tenantId"])) {
|
|
121
|
+
throw new AccessDeniedError({
|
|
122
|
+
message:
|
|
123
|
+
`${entityName}.create: entity is tenancy: "global" — payload.tenantId must be ` +
|
|
124
|
+
"SYSTEM_TENANT_ID or omitted.",
|
|
125
|
+
});
|
|
126
|
+
}
|
|
118
127
|
// Respect an explicit id in the payload (seed pattern, SCIM import). Without
|
|
119
128
|
// one the framework mints a fresh UUIDv7 via generateId. Strip it out of the
|
|
120
129
|
// event payload so defaults + downstream consumers don't see a redundant id field.
|
|
@@ -299,6 +308,13 @@ export function createWriteVerbs(
|
|
|
299
308
|
},
|
|
300
309
|
|
|
301
310
|
async update(payload, user, db, updateOptions) {
|
|
311
|
+
if (isForeignTenantOnGlobalEntity(entity, payload.changes["tenantId"])) {
|
|
312
|
+
throw new AccessDeniedError({
|
|
313
|
+
message:
|
|
314
|
+
`${entityName}.update: entity is tenancy: "global" — payload.changes.tenantId must ` +
|
|
315
|
+
"be SYSTEM_TENANT_ID or omitted.",
|
|
316
|
+
});
|
|
317
|
+
}
|
|
302
318
|
const previous = await loadById(payload.id, db);
|
|
303
319
|
if (!previous) return writeFailure(new NotFoundError(entityName, payload.id));
|
|
304
320
|
|
package/src/db/index.ts
CHANGED
|
@@ -146,14 +146,16 @@ export { tableExists } from "./schema-inspection";
|
|
|
146
146
|
export {
|
|
147
147
|
buildBaseColumns,
|
|
148
148
|
buildEntityTable,
|
|
149
|
+
declareGlobalTenancy,
|
|
149
150
|
type EntityTable,
|
|
150
151
|
toSnakeCase,
|
|
151
152
|
toTableName,
|
|
152
153
|
} from "./table-builder";
|
|
153
|
-
export type { TenantDb, TenantDbMode, UncheckedSystemDb } from "./tenant-db";
|
|
154
|
+
export type { TenantDb, TenantDbGrants, TenantDbMode, UncheckedSystemDb } from "./tenant-db";
|
|
154
155
|
export {
|
|
155
156
|
castTenantRows,
|
|
156
157
|
createTenantDb,
|
|
157
158
|
createUncheckedSystemDb,
|
|
158
159
|
SYSTEM_SCOPE_CHECK_BRAND,
|
|
160
|
+
withUnsafeRawGrant,
|
|
159
161
|
} from "./tenant-db";
|
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 };
|
|
@@ -34,6 +34,7 @@ describe("r.hook({ allOf }, ...) identity-switch gate", () => {
|
|
|
34
34
|
// escapeHatch or is systemScope) — the hook must not inherit this.
|
|
35
35
|
const handlerCtx = createGatedIdentitySwitch(
|
|
36
36
|
'handler "test:write:thing:create"',
|
|
37
|
+
undefined,
|
|
37
38
|
true,
|
|
38
39
|
ungated,
|
|
39
40
|
);
|
|
@@ -70,6 +71,7 @@ describe("r.hook({ allOf }, ...) identity-switch gate", () => {
|
|
|
70
71
|
};
|
|
71
72
|
const handlerCtx = createGatedIdentitySwitch(
|
|
72
73
|
'handler "test:write:thing:create"',
|
|
74
|
+
undefined,
|
|
73
75
|
false,
|
|
74
76
|
ungated,
|
|
75
77
|
);
|
|
@@ -114,7 +116,7 @@ describe("r.hook({ allOf }, ...) identity-switch gate", () => {
|
|
|
114
116
|
};
|
|
115
117
|
const ungatedResolveActiveMembership = mock(async () => activeResult);
|
|
116
118
|
const handlerCtx = {
|
|
117
|
-
...createGatedIdentitySwitch('handler "test:write:thing:create"', true, {
|
|
119
|
+
...createGatedIdentitySwitch('handler "test:write:thing:create"', undefined, true, {
|
|
118
120
|
queryAs: mock(async () => "ok") as QueryAsFn,
|
|
119
121
|
writeAs: async () => ({ isSuccess: true as const, data: null }),
|
|
120
122
|
}),
|
|
@@ -155,7 +157,7 @@ describe("r.hook({ allOf }, ...) identity-switch gate", () => {
|
|
|
155
157
|
};
|
|
156
158
|
const ungatedResolveActiveMembership = mock(async () => activeResult);
|
|
157
159
|
const handlerCtx = {
|
|
158
|
-
...createGatedIdentitySwitch('handler "test:write:thing:create"', false, {
|
|
160
|
+
...createGatedIdentitySwitch('handler "test:write:thing:create"', undefined, false, {
|
|
159
161
|
queryAs: mock(async () => "ok") as QueryAsFn,
|
|
160
162
|
writeAs: async () => ({ isSuccess: true as const, data: null }),
|
|
161
163
|
}),
|
|
@@ -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 {
|
|
@@ -144,6 +144,43 @@ describe("validateAccessDeclarations", () => {
|
|
|
144
144
|
expect(() => validateAccessDeclarations(feature)).not.toThrow();
|
|
145
145
|
});
|
|
146
146
|
|
|
147
|
+
// 2c. escapeHatch reason must be non-empty on a stream handler too — same
|
|
148
|
+
// contract as write/query: streams can't reach db.global(), but they can
|
|
149
|
+
// still declare escapeHatch to switch identity to SYSTEM via ctx.queryAs.
|
|
150
|
+
test("stream handler escapeHatch with an empty reason throws, naming the handler", () => {
|
|
151
|
+
const feature = defineFeature("notes", (r) => {
|
|
152
|
+
r.entity("note", noteEntity);
|
|
153
|
+
r.streamHandler(
|
|
154
|
+
"note:tail",
|
|
155
|
+
z.object({}),
|
|
156
|
+
async function* () {
|
|
157
|
+
yield {};
|
|
158
|
+
},
|
|
159
|
+
{ access: { roles: ["Admin"] }, escapeHatch: { reason: "" } },
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/Feature notes/);
|
|
163
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/"note:tail"/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("stream handler escapeHatch with a non-empty reason boots fine", () => {
|
|
167
|
+
const feature = defineFeature("notes", (r) => {
|
|
168
|
+
r.entity("note", noteEntity);
|
|
169
|
+
r.streamHandler(
|
|
170
|
+
"note:tail",
|
|
171
|
+
z.object({}),
|
|
172
|
+
async function* () {
|
|
173
|
+
yield {};
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
access: { roles: ["Admin"] },
|
|
177
|
+
escapeHatch: { reason: "cross-tenant note tail for auth" },
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
expect(() => validateAccessDeclarations(feature)).not.toThrow();
|
|
182
|
+
});
|
|
183
|
+
|
|
147
184
|
// 3. openToAll write handler accepting an unbound personal-data field needs personalData.
|
|
148
185
|
test("openToAll write handler accepting a personal-data field without personalData throws", () => {
|
|
149
186
|
const feature = defineFeature("notes", (r) => {
|
|
@@ -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
|
+
});
|
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
OwnershipMap,
|
|
5
5
|
OwnershipRule,
|
|
6
6
|
QueryHandlerDef,
|
|
7
|
+
StreamHandlerDef,
|
|
7
8
|
WriteHandlerDef,
|
|
8
9
|
} from "../types";
|
|
9
10
|
import type { EntityDefinition, ResolvedPiiFlags } from "../types/fields";
|
|
@@ -133,7 +134,10 @@ function validateEscapeHatchReason(
|
|
|
133
134
|
feature: FeatureDefinition,
|
|
134
135
|
kind: HandlerKind,
|
|
135
136
|
handlerName: string,
|
|
136
|
-
escapeHatch:
|
|
137
|
+
escapeHatch:
|
|
138
|
+
| WriteHandlerDef["escapeHatch"]
|
|
139
|
+
| QueryHandlerDef["escapeHatch"]
|
|
140
|
+
| StreamHandlerDef["escapeHatch"],
|
|
137
141
|
): void {
|
|
138
142
|
// skip: no escapeHatch declared, or its reason is already non-empty
|
|
139
143
|
if (!escapeHatch || escapeHatch.reason.trim().length > 0) return;
|
|
@@ -212,6 +216,7 @@ export function validateAccessDeclarations(feature: FeatureDefinition): void {
|
|
|
212
216
|
}
|
|
213
217
|
for (const [handlerName, handler] of Object.entries(feature.streamHandlers)) {
|
|
214
218
|
validateOpenToAllReason(feature, "stream", handlerName, handler.access);
|
|
219
|
+
validateEscapeHatchReason(feature, "stream", handlerName, handler.escapeHatch);
|
|
215
220
|
validatePersonalDataOnlyOnWrite(feature, "stream", handlerName, handler.access);
|
|
216
221
|
}
|
|
217
222
|
}
|