@cosmicdrift/kumiko-framework 0.265.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 +35 -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__/access-declarations.test.ts +331 -15
- package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +58 -0
- package/src/engine/boot-validator/access-declarations.ts +101 -27
- package/src/engine/boot-validator/global-tenancy.ts +17 -0
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/boot-validator/zod-shape.ts +92 -1
- package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +28 -0
- package/src/engine/feature-ast/extractors/hooks.ts +8 -4
- package/src/engine/feature-ui-extensions.ts +2 -2
- package/src/engine/index.ts +4 -0
- package/src/engine/registry-validate.ts +6 -6
- package/src/engine/types/index.ts +3 -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/ui-types/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.268.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-types": "0.268.0",
|
|
202
202
|
"bullmq": "^5.76.7",
|
|
203
203
|
"bun-types": "^1.3.13",
|
|
204
204
|
"hono": "^4.13.1",
|
|
@@ -214,7 +214,7 @@
|
|
|
214
214
|
"zod": "^4.4.3"
|
|
215
215
|
},
|
|
216
216
|
"devDependencies": {
|
|
217
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
217
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.268.0",
|
|
218
218
|
"bun-types": "^1.3.13",
|
|
219
219
|
"pino-pretty": "^13.1.3"
|
|
220
220
|
},
|
|
@@ -640,6 +640,12 @@ describe("POST /api/stream pre-pull race", () => {
|
|
|
640
640
|
async resolveActiveMembership() {
|
|
641
641
|
return { kind: "rejected", reason: "not_a_member" };
|
|
642
642
|
},
|
|
643
|
+
// Not exercised by this route.
|
|
644
|
+
createMemberReader() {
|
|
645
|
+
return async () => {
|
|
646
|
+
throw new Error("createMemberReader not available in this stub");
|
|
647
|
+
};
|
|
648
|
+
},
|
|
643
649
|
};
|
|
644
650
|
}
|
|
645
651
|
|
|
@@ -64,6 +64,12 @@ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
|
|
|
64
64
|
}
|
|
65
65
|
return { kind: "active", membership: { tenantId, roles: membership.roles } };
|
|
66
66
|
},
|
|
67
|
+
// Not exercised by these cookie-only tests.
|
|
68
|
+
createMemberReader() {
|
|
69
|
+
return async () => {
|
|
70
|
+
throw new Error("createMemberReader not available in this stub");
|
|
71
|
+
};
|
|
72
|
+
},
|
|
67
73
|
...overrides,
|
|
68
74
|
};
|
|
69
75
|
return merged;
|
|
@@ -39,6 +39,12 @@ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
|
|
|
39
39
|
},
|
|
40
40
|
// Stream API added with r.streamHandler (#1446) — stub unused in these routes.
|
|
41
41
|
async *stream(): AsyncGenerator<unknown> {},
|
|
42
|
+
// Not exercised by these routes.
|
|
43
|
+
createMemberReader() {
|
|
44
|
+
return async () => {
|
|
45
|
+
throw new Error("createMemberReader not available in this stub");
|
|
46
|
+
};
|
|
47
|
+
},
|
|
42
48
|
};
|
|
43
49
|
return { ...base, ...overrides };
|
|
44
50
|
}
|
|
@@ -49,6 +49,12 @@ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
|
|
|
49
49
|
async resolveActiveMembership() {
|
|
50
50
|
return { kind: "rejected", reason: "not_a_member" };
|
|
51
51
|
},
|
|
52
|
+
// Not exercised by these routes.
|
|
53
|
+
createMemberReader() {
|
|
54
|
+
return async () => {
|
|
55
|
+
throw new Error("createMemberReader not available in this stub");
|
|
56
|
+
};
|
|
57
|
+
},
|
|
52
58
|
};
|
|
53
59
|
return { ...base, ...overrides };
|
|
54
60
|
}
|
|
@@ -50,6 +50,12 @@ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
|
|
|
50
50
|
async resolveActiveMembership() {
|
|
51
51
|
return { kind: "rejected", reason: "not_a_member" };
|
|
52
52
|
},
|
|
53
|
+
// Not exercised by these routes.
|
|
54
|
+
createMemberReader() {
|
|
55
|
+
return async () => {
|
|
56
|
+
throw new Error("createMemberReader not available in this stub");
|
|
57
|
+
};
|
|
58
|
+
},
|
|
53
59
|
};
|
|
54
60
|
return { ...base, ...overrides };
|
|
55
61
|
}
|
|
@@ -49,6 +49,12 @@ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
|
|
|
49
49
|
async resolveActiveMembership() {
|
|
50
50
|
return { kind: "rejected", reason: "not_a_member" };
|
|
51
51
|
},
|
|
52
|
+
// Not exercised by these routes.
|
|
53
|
+
createMemberReader() {
|
|
54
|
+
return async () => {
|
|
55
|
+
throw new Error("createMemberReader not available in this stub");
|
|
56
|
+
};
|
|
57
|
+
},
|
|
52
58
|
};
|
|
53
59
|
return { ...base, ...overrides };
|
|
54
60
|
}
|
|
@@ -42,6 +42,12 @@ function failingDispatcher(): Dispatcher {
|
|
|
42
42
|
async resolveActiveMembership() {
|
|
43
43
|
return { kind: "rejected", reason: "not_a_member" };
|
|
44
44
|
},
|
|
45
|
+
// Not exercised by these routes.
|
|
46
|
+
createMemberReader() {
|
|
47
|
+
return async () => {
|
|
48
|
+
throw new Error("createMemberReader not available in this stub");
|
|
49
|
+
};
|
|
50
|
+
},
|
|
45
51
|
};
|
|
46
52
|
}
|
|
47
53
|
|
|
@@ -106,6 +106,14 @@ describe("createJwtHelper.verify — payload validation (KF-2)", () => {
|
|
|
106
106
|
const payload = await jwt.verify(await jwt.sign(user));
|
|
107
107
|
expect(payload.locale).toBeUndefined();
|
|
108
108
|
});
|
|
109
|
+
|
|
110
|
+
// A ctx.queryAsMember-resolved SessionUser must never be minted into a
|
|
111
|
+
// session — it carries no `sid` and is read-only by construction.
|
|
112
|
+
it("refuses to sign a SessionUser with origin set", async () => {
|
|
113
|
+
await expect(jwt.sign({ ...user, origin: "member-resolution" })).rejects.toThrow(
|
|
114
|
+
/member-resolution/,
|
|
115
|
+
);
|
|
116
|
+
});
|
|
109
117
|
});
|
|
110
118
|
|
|
111
119
|
describe("createJwtHelper — keyring form", () => {
|
package/src/api/jwt.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as jose from "jose";
|
|
|
2
2
|
import type { DbRow } from "../db/connection";
|
|
3
3
|
import type { SessionUser, TenantId } from "../engine/types";
|
|
4
4
|
import { parseTenantId } from "../engine/types";
|
|
5
|
+
import { InternalError } from "../errors";
|
|
5
6
|
|
|
6
7
|
export type JwtPayload = {
|
|
7
8
|
// JWT `sub` is a string per RFC 7519. Matches SessionUser.id — a UUID-string
|
|
@@ -108,6 +109,13 @@ export function createJwtHelper(
|
|
|
108
109
|
|
|
109
110
|
return {
|
|
110
111
|
async sign(user) {
|
|
112
|
+
// A resolved member principal (origin set) carries no `sid` and must
|
|
113
|
+
// never be minted into a session — only interactive sign-in may sign.
|
|
114
|
+
if (user.origin !== undefined) {
|
|
115
|
+
throw new InternalError({
|
|
116
|
+
message: `jwt.sign: refusing to sign a SessionUser with origin "${user.origin}" — only interactively-authenticated sessions may be minted.`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
111
119
|
const body: Omit<JwtPayload, "sub" | "jti"> = {
|
|
112
120
|
tenantId: user.tenantId,
|
|
113
121
|
roles: [...user.roles],
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,39 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.267.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "ctx.db.unsafeRaw(reason) gated by escapeHatch, tenancy: \"global\" invariants, bundled features migrated off ctx.db.raw (fw#2858).",
|
|
6
|
+
"detail": "`TenantDb.unsafeRaw(reason)` returns the unfiltered `DbRunner` only when the calling write or query handler declares `escapeHatch: { reason }` (lifecycle hooks: `r.hook(..., { escapeHatch })`; a hook no longer inherits the handler's grant, same as the #2859 SYSTEM identity-switch gate). Without a declaration it throws `AccessDeniedError`; an empty reason throws `Error`. `r.systemScope()` features keep using `ctx.systemDb.unsafeRaw(reason)`. `TenantDb.raw` stays deprecated until fw#2860. `createTenantDb`'s 7th parameter changed from an `EscapeHatchDeclaration` to `TenantDbGrants` `{ globalWrites?, unsafeRaw? }`; `withUnsafeRawGrant(tenantDb, grant)` rebinds the unsafeRaw grant. `bindHookIdentitySwitchGrant`/`withHookIdentitySwitchGrant` are renamed to `bindHookEscapeHatchGrant`/`withHookEscapeHatchGrant`. Global tenancy: a `tenancy: \"global\"` entity must declare `systemStream: true` (boot validator + executor) and its create/update payloads may not carry a non-SYSTEM `tenantId`; tenant-mode `insertOne` into a global table with a `tenant_id` column and `db.global(table)` writes with a non-SYSTEM `tenantId` are rejected; `defineUnmanagedTable({ tenancy: \"global\" })` rejects a `tenant_id` column; new `declareGlobalTenancy(table)` declares a plain `table()` store without `tenant_id` as global. Bundled features: `userEntity` is `tenancy: \"global\"`, `store_global_feature_state` is declared global, and every bundled `ctx.db.raw` call site now uses `ctx.db.global(table)`, a filtered `ctx.db` method, or `ctx.db.unsafeRaw(reason)` with an `escapeHatch` on the handler. Handlers that call the auth-mfa `mfaStatusChecker`/MFA code verifier callbacks, handlers wrapped by cap-counter `withStockCap` (declares it automatically) and callers of the custom-fields quota/field-access query helpers now need that declaration.",
|
|
7
|
+
"migration": "Replace each `ctx.db.raw` use: reads of a `tenancy: \"global\"` table → `ctx.db.global(table).selectMany/fetchOne(...)`; reads/inserts whose where/values only use the caller's own tenantId → `ctx.db.selectMany/fetchOne/insertOne(table, ...)` (method form); everything else → `ctx.db.unsafeRaw(\"<what is read/written and why the tenant filter cannot apply>\")` plus `escapeHatch: { reason }` on the write/query handler definition or in the `r.hook` options. Generic reasons (todo, legacy, migration) fail infra#788. `bun scripts/migrate-db-raw.ts --dry-run <paths>` rewrites the global-table and own-tenant cases and lists the remaining sites; drop `--dry-run` to apply. Custom handlers calling `mfaStatusChecker`/the MFA code verifier or the custom-fields query helpers must declare `escapeHatch`. Direct `createTenantDb(..., escapeHatch)` calls pass `{ globalWrites: escapeHatch }` (and `unsafeRaw` if needed). Rename imports of `bindHookIdentitySwitchGrant`/`withHookIdentitySwitchGrant`. `defineUnmanagedTable({ tenancy: \"global\" })` definitions must drop their `tenant_id` column; `tenancy: \"global\"` entities must add `systemStream: true`.",
|
|
8
|
+
"codemod": "bun scripts/migrate-db-raw.ts --dry-run <paths>"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"version": "0.267.0",
|
|
12
|
+
"type": "improvement",
|
|
13
|
+
"title": "ctx.queryAsMember reads a query handler as a stored tenant member, resolved internally by the framework (fw#2873).",
|
|
14
|
+
"detail": "New `ctx.queryAsMember(userId, qn, payload)` on `HandlerContext`, `JobContext` and (optional) `AppContext` — resolves a background SessionUser for `userId` in the current tenant (active membership, via a new stricter `BACKGROUND_READ_POLICY` — no pending-destruction grace window, no unknown-principal pass → `PrincipalStatusPlugin.resolveProfile` → `buildSessionRoles` → `resolveAuthClaims`) and runs the target query handler as that member. The resolved SessionUser is never exposed: no `sid`, `origin: \"member-resolution\"`. Rejection for any reason (not a member, blocked principal, tenant teardown, or would-be-SYSTEM) surfaces the same generic AccessDeniedError (`member_resolution_denied`, no rejection reason in `details`). Resolution is cached per handler invocation (its lifecycle hooks share that cache) or job run; a nested dispatch gets its own cache. Needs the same grant as a SYSTEM `queryAs` (`r.systemScope()` or `escapeHatch`); a hook's OWN escapeHatch re-gates it independently of the enclosing handler's grant (can grant even when the handler has none). Jobs stay ungated. There is no `writeAsMember` — a resolved member principal is read-only by construction: `write`/`writeAs`/`appendEvent`/`unsafeAppendEvent`/`tryAppendEvent`/`fetchForWriting`/`archiveStream`/`restoreStream`/`snapshotAggregate`/`queryAsMember`/`resolveActiveMembership`/`jobRunner`/`scheduleAfterCommit` all throw `member_resolution_read_only` (also enforced at the shared event-append sink behind `appendEvent`/`fetchForWriting().appendOne`), and `dbOutsideTransaction`/`systemDb`/`runPreSave`/`files`/`derivatives` are unset (raw `ctx.db` writes are NOT blocked — same reachability as that handler running for the member over HTTP). `jwt.sign()` now refuses a SessionUser with `origin` set.",
|
|
15
|
+
"migration": "Custom principalStatus providers must implement resolveProfile for ctx.queryAsMember; login/switch-tenant unaffected."
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"version": "0.266.0",
|
|
19
|
+
"type": "breaking",
|
|
20
|
+
"title": "`publicIntake` is removed; an openToAll write handler declares unbound personal data with `openToAll: { reason, personalData: \"tenant-members\" }`.",
|
|
21
|
+
"detail": "`OpenToAllAccessRule.publicIntake` (`@cosmicdrift/kumiko-types/handlers`) no longer exists. `OpenToAllDeclaration` gains `personalData?: OpenToAllPersonalData`, a named union (currently only `\"tenant-members\"`, exported from `@cosmicdrift/kumiko-framework/engine` and `/ui-types`): signed-in tenant members may write personal data that no owner rule binds to the caller. The deprecated `openToAll: true` form cannot carry it. `validateAccessDeclarations` requires `personalData: \"tenant-members\"` for a write handler with `openToAll` whose input accepts a personal-data field that is not owner- or self-bound, rejects `personalData` on query and stream handlers, and rejects any other `personalData` value from untyped sources. `openToAll` never admits anonymous callers, so there is no public-intake value; anonymous personal-data intake is tracked separately. The feature-AST extractor reads `openToAll.personalData` and no longer reads `publicIntake`. Bundled `user:update` now declares `openToAll: { reason, personalData: \"tenant-members\" }`: `userEntity` has no entity-level `access.write`, and privileged actors may edit any user.",
|
|
22
|
+
"migration": "Replace `access: { openToAll: { reason: \"...\" }, publicIntake: true }` (or `openToAll: true, publicIntake: true`) with `access: { openToAll: { reason: \"<why any signed-in tenant member may write this personal data>\", personalData: \"tenant-members\" } }`. Handlers whose personal-data fields are owner- or self-bound through the entity's `access.write` need no declaration. A `publicIntake` key left in an untyped access object (pattern JSON, Designer) is ignored, so the handler fails boot until migrated. No known consumer used `publicIntake`."
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"version": "0.266.0",
|
|
26
|
+
"type": "breaking",
|
|
27
|
+
"title": "The openToAll personal-data boot check now finds personal-data fields nested anywhere in a write handler's input schema.",
|
|
28
|
+
"detail": "`validateAccessDeclarations` (`engine/boot-validator/access-declarations.ts`) previously compared only the top-level keys of a `z.object` input schema (unwrapping `.optional()`/`.nullable()`/`.default()`) against the handler entity's personal-data fields (pii / userOwned / recordOwned); any other schema shape was skipped. It now collects object keys at every depth via `collectZodObjectKeys` (`engine/boot-validator/zod-shape.ts`): through `z.intersection`, `z.union`/`z.discriminatedUnion`/`z.xor`, `.transform()`/`z.preprocess()`/`.pipe()`/codecs, `.optional()`/`.nullable()`/`.default()`/`.prefault()`/`.nonoptional()`/`.readonly()`/`.catch()`/`z.lazy()`, nested objects (e.g. the update shape `{ id, version, changes: {...} }`), arrays, tuples and records. An update handler that accepted the same personal-data field as its create handler used to pass boot while the create handler failed. A schema with more than 10000 distinct nodes (typically a `z.lazy()` getter that returns a new schema on every call) throws at boot instead of looping. Which entity's fields are compared is unchanged: the handler's mapped entity, or every entity of the feature for an unmapped handler.",
|
|
29
|
+
"migration": "A write handler with `openToAll` whose input accepts a personal-data field of its entity inside an intersection, union, pipe/transform, nested object (update `changes`), array or record now fails boot like a top-level field always did. Known: kumiko-enterprise kumiko-credit `update` (intersection) and `bauspar:update` (`changes.name`) under `trustDomain: \"tenant\"` or with an `access` map that grants any role `\"all\"`. For each affected handler, bind the entity's rows to the caller (owner-binding entry in this release), restrict `access` to roles, or declare `openToAll: { reason: \"...\", personalData: \"tenant-members\" }`."
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"version": "0.266.0",
|
|
33
|
+
"type": "improvement",
|
|
34
|
+
"title": "Owner- and self-bound personal-data fields no longer need a declaration on openToAll write handlers.",
|
|
35
|
+
"detail": "A personal-data field is exempt from the openToAll personal-data boot check when it is bound to the caller: the handler maps to exactly one entity, that entity's `access.write` is a non-empty `OwnershipMap` in which every role's rule is `from(\"user:id\", <column>)` on the binding column, the handler declares no `escapeHatch`, and its feature is not `r.systemScope()`. The binding column is the owner field for `personal: { of: \"<ownerField>\" }` (userOwned), and `id` for `personal: \"self\"` and `personal: { of: \"id\" }` — there the row itself is the caller. The event-store executor enforces such a map on every write: create rejects a row whose binding column is not the caller (`ownership_denied`), update checks the old and the new row, delete/restore the existing row. Not exempt: a missing or empty `access.write`, any `\"all\"` rule (also next to binding rules), a rule on another column, `user:tenantId` or claim refs, `where` rules, `self` fields on an entity without `from(\"user:id\", \"id\")`, handlers not mapped to a single entity, handlers with `escapeHatch` and handlers of `r.systemScope()` features (both can write around the executor via `db.global()`, `systemDb` or a SYSTEM identity)."
|
|
36
|
+
},
|
|
2
37
|
{
|
|
3
38
|
"version": "0.265.0",
|
|
4
39
|
"type": "improvement",
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// fw#2858 — a `tenancy: "global"` entity's aggregate is tenant-agnostic: create/update
|
|
2
|
+
// must reject an explicit foreign (non-SYSTEM) payload.tenantId before it ever reaches
|
|
3
|
+
// the projection insert/update. Modelled on event-store-system-stream.integration.test.ts.
|
|
4
|
+
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
6
|
+
import { createEntity, createTextField } from "../../engine";
|
|
7
|
+
import { AccessDeniedError } from "../../errors";
|
|
8
|
+
import {
|
|
9
|
+
createTestDb,
|
|
10
|
+
type TestDb,
|
|
11
|
+
TestUsers,
|
|
12
|
+
testTenantId,
|
|
13
|
+
unsafeCreateEntityTable,
|
|
14
|
+
} from "../../stack";
|
|
15
|
+
import { createEventStoreExecutor } from "../event-store-executor";
|
|
16
|
+
import { buildEntityTable } from "../table-builder";
|
|
17
|
+
import { createTenantDb } from "../tenant-db";
|
|
18
|
+
|
|
19
|
+
const globalEntity = createEntity({
|
|
20
|
+
table: "gtenancy_global",
|
|
21
|
+
tenancy: "global",
|
|
22
|
+
systemStream: true,
|
|
23
|
+
fields: { name: createTextField({ required: true, personal: false, reason: "test_fixture" }) },
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const globalTable = buildEntityTable("gtenancyGlobal", globalEntity);
|
|
27
|
+
const globalExec = createEventStoreExecutor(globalTable, globalEntity, {
|
|
28
|
+
entityName: "gtenancyGlobal",
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const user = TestUsers.admin; // tenantId === testTenantId(1)
|
|
32
|
+
const foreignTenantId = testTenantId(2);
|
|
33
|
+
|
|
34
|
+
let testDb: TestDb;
|
|
35
|
+
|
|
36
|
+
beforeAll(async () => {
|
|
37
|
+
testDb = await createTestDb();
|
|
38
|
+
await unsafeCreateEntityTable(testDb.db, globalEntity, "gtenancyGlobal");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterAll(async () => {
|
|
42
|
+
await testDb.cleanup();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("global entity tenantId invariant (fw#2858)", () => {
|
|
46
|
+
test("create with an explicit foreign tenantId throws AccessDeniedError", async () => {
|
|
47
|
+
const db = createTenantDb(testDb.db, user.tenantId);
|
|
48
|
+
await expect(
|
|
49
|
+
globalExec.create({ name: "a", tenantId: foreignTenantId }, user, db),
|
|
50
|
+
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("create without a tenantId (or SYSTEM) succeeds", async () => {
|
|
54
|
+
const db = createTenantDb(testDb.db, user.tenantId);
|
|
55
|
+
const res = await globalExec.create({ name: "a" }, user, db);
|
|
56
|
+
expect(res.isSuccess).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("update with an explicit foreign tenantId in changes throws AccessDeniedError", async () => {
|
|
60
|
+
const db = createTenantDb(testDb.db, user.tenantId);
|
|
61
|
+
const created = await globalExec.create({ name: "a" }, user, db);
|
|
62
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
63
|
+
|
|
64
|
+
await expect(
|
|
65
|
+
globalExec.update(
|
|
66
|
+
{ id: String(created.data.id), changes: { tenantId: foreignTenantId } },
|
|
67
|
+
user,
|
|
68
|
+
db,
|
|
69
|
+
{ skipOptimisticLock: true },
|
|
70
|
+
),
|
|
71
|
+
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
// fw#
|
|
2
|
-
//
|
|
1
|
+
// fw#2858 — db.global(table) against real Postgres: a "global" unmanaged table has
|
|
2
|
+
// no tenant_id column at all (binding decision, issue comment 2026-09-14), so its
|
|
3
|
+
// rows are tenant-agnostic — every caller sees the same rows through db.global().
|
|
4
|
+
// A normal tenancy: "tenant" table is unaffected and still isolates by tenant.
|
|
3
5
|
// Follows tenant-db-where-merge.integration.test.ts's setupTestStack pattern.
|
|
4
6
|
|
|
5
7
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
8
|
+
import { AccessDeniedError } from "../../errors";
|
|
6
9
|
import { setupTestStack, type TestStack, testTenantId, unsafePushTables } from "../../stack";
|
|
7
10
|
import type { TableColumns } from "../dialect";
|
|
8
11
|
import { defineUnmanagedTable } from "../entity-table-meta";
|
|
@@ -10,8 +13,22 @@ import { insertOne } from "../query";
|
|
|
10
13
|
import { createTenantDb } from "../tenant-db";
|
|
11
14
|
|
|
12
15
|
const globalItemsTable = defineUnmanagedTable({
|
|
13
|
-
tableName: "
|
|
16
|
+
tableName: "store_fw2858_global_items_it",
|
|
14
17
|
tenancy: "global",
|
|
18
|
+
columns: [
|
|
19
|
+
{
|
|
20
|
+
name: "id",
|
|
21
|
+
pgType: "uuid",
|
|
22
|
+
notNull: true,
|
|
23
|
+
primaryKey: true,
|
|
24
|
+
defaultSql: "gen_random_uuid()",
|
|
25
|
+
},
|
|
26
|
+
{ name: "some_field", pgType: "text", notNull: true },
|
|
27
|
+
],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const tenantItemsTable = defineUnmanagedTable({
|
|
31
|
+
tableName: "store_fw2858_tenant_items_it",
|
|
15
32
|
columns: [
|
|
16
33
|
{
|
|
17
34
|
name: "id",
|
|
@@ -27,15 +44,20 @@ const globalItemsTable = defineUnmanagedTable({
|
|
|
27
44
|
|
|
28
45
|
let stack: TestStack;
|
|
29
46
|
|
|
30
|
-
const tenantA = testTenantId(
|
|
31
|
-
const tenantB = testTenantId(
|
|
47
|
+
const tenantA = testTenantId(93);
|
|
48
|
+
const tenantB = testTenantId(94);
|
|
32
49
|
|
|
33
50
|
beforeAll(async () => {
|
|
34
51
|
stack = await setupTestStack({ features: [] });
|
|
35
|
-
await unsafePushTables(stack.db, {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
52
|
+
await unsafePushTables(stack.db, {
|
|
53
|
+
globalItems: globalItemsTable,
|
|
54
|
+
tenantItems: tenantItemsTable,
|
|
55
|
+
});
|
|
56
|
+
await insertOne(stack.db, globalItemsTable, { someField: "match" });
|
|
57
|
+
await insertOne(stack.db, globalItemsTable, { someField: "match" });
|
|
58
|
+
await insertOne(stack.db, globalItemsTable, { someField: "no-match" });
|
|
59
|
+
await insertOne(stack.db, tenantItemsTable, { tenantId: tenantA, someField: "match" });
|
|
60
|
+
await insertOne(stack.db, tenantItemsTable, { tenantId: tenantB, someField: "match" });
|
|
39
61
|
});
|
|
40
62
|
|
|
41
63
|
afterAll(async () => {
|
|
@@ -43,25 +65,35 @@ afterAll(async () => {
|
|
|
43
65
|
});
|
|
44
66
|
|
|
45
67
|
describe("TenantDb.global() — reads, real Postgres", () => {
|
|
46
|
-
test("
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
expect(
|
|
55
|
-
expect(
|
|
68
|
+
test("selectMany returns the same rows to callers on different tenants, unfiltered by tenant", async () => {
|
|
69
|
+
const rowsForA = await createTenantDb(stack.db, tenantA)
|
|
70
|
+
.global(globalItemsTable)
|
|
71
|
+
.selectMany<{ id: string; someField: string }>({ someField: "match" });
|
|
72
|
+
const rowsForB = await createTenantDb(stack.db, tenantB)
|
|
73
|
+
.global(globalItemsTable)
|
|
74
|
+
.selectMany<{ id: string; someField: string }>({ someField: "match" });
|
|
75
|
+
|
|
76
|
+
expect(rowsForA).toHaveLength(2);
|
|
77
|
+
expect(rowsForA.map((r) => r.id).sort()).toEqual(rowsForB.map((r) => r.id).sort());
|
|
56
78
|
});
|
|
57
79
|
|
|
58
|
-
test("
|
|
80
|
+
test("fetchOne finds a row for a caller whose own tenant never wrote it", async () => {
|
|
81
|
+
const row = await createTenantDb(stack.db, tenantA)
|
|
82
|
+
.global(globalItemsTable)
|
|
83
|
+
.fetchOne<{ someField: string }>({ someField: "no-match" });
|
|
84
|
+
|
|
85
|
+
expect(row?.someField).toBe("no-match");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("TenantDb — tenant-mode isolation on a normal tenant table", () => {
|
|
90
|
+
test("db.selectMany only returns the caller's own tenant's rows", async () => {
|
|
59
91
|
const tdb = createTenantDb(stack.db, tenantA);
|
|
60
92
|
// selectMany's Table param types the branded-EntityTable shape only;
|
|
61
93
|
// EntityTableMeta reads work identically at runtime (both normalize via
|
|
62
94
|
// asEntityTableMeta) — same unbranded-view cast as tenant-db-where-merge.test.ts.
|
|
63
95
|
const rows = await tdb.selectMany<{ tenantId: string; someField: string }>(
|
|
64
|
-
|
|
96
|
+
tenantItemsTable as unknown as TableColumns,
|
|
65
97
|
{ someField: "match" },
|
|
66
98
|
);
|
|
67
99
|
|
|
@@ -71,18 +103,23 @@ describe("TenantDb.global() — reads, real Postgres", () => {
|
|
|
71
103
|
});
|
|
72
104
|
|
|
73
105
|
describe("TenantDb.global() — writes, real Postgres", () => {
|
|
74
|
-
test("insert/update/delete
|
|
106
|
+
test("insert/update/delete are rejected without a globalWrites grant", async () => {
|
|
107
|
+
const tdb = createTenantDb(stack.db, tenantA);
|
|
108
|
+
await expect(
|
|
109
|
+
tdb.global(globalItemsTable).insertOne({ someField: "ungranted" }),
|
|
110
|
+
).rejects.toThrow(AccessDeniedError);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("insert/update/delete succeed when createTenantDb was given a globalWrites grant", async () => {
|
|
75
114
|
const tdb = createTenantDb(stack.db, tenantA, "tenant", undefined, undefined, undefined, {
|
|
76
|
-
reason: "fw#
|
|
115
|
+
globalWrites: { reason: "fw#2858 integration test — cross-tenant write via db.global()" },
|
|
77
116
|
});
|
|
78
117
|
const globalDb = tdb.global(globalItemsTable);
|
|
79
118
|
|
|
80
|
-
const inserted = await globalDb.insertOne<{ id: string
|
|
81
|
-
tenantId: tenantB,
|
|
119
|
+
const inserted = await globalDb.insertOne<{ id: string }>({
|
|
82
120
|
someField: "escape-hatch-insert",
|
|
83
121
|
});
|
|
84
122
|
if (!inserted) throw new Error("insertOne returned no row");
|
|
85
|
-
expect(inserted.tenantId).toBe(tenantB);
|
|
86
123
|
|
|
87
124
|
const updated = await globalDb.updateMany<{ someField: string }>(
|
|
88
125
|
{ someField: "escape-hatch-update" },
|
|
@@ -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
|
|