Skip to content

Commit 2b1382a

Browse files
fix(objectql,plugin-security): an insert's explicit organization_id the Layer 0 wall judges is stored as sent (#25048)
Part of #24822 Clause-②: yes (widening) — `PostHookWriteImageCheck` gains the optional published member `judgesOrganization`, and an in-process `strictReadonlyWrites: true` insert naming an organization the wall admits is now stored instead of refused `ERR_READONLY_FIELD_REJECTED`. This is half 1 of the card: INSERT only. The UPDATE half is the card's half 2. It reuses the member and is not in this PR. The seat call that ruled the channel and the declaration is `6305891812` on the card (Q1 → A, Q2 → A). ## What was wrong Under `group`, take a member whose active organization is A and whose membership set includes C. An insert naming `organization_id: C` on an app object, which carries the platform-injected tenant column: - **Answer:** 2xx, and the row was stored in A, single and array alike. - **Mechanism:** - The Layer 0 write wall (plugin-security step 3.7) judged C and admitted it. - The engine's static-`readonly` insert strip then took the value, because `TENANT_SCOPE_FIELD_DEF` declares `readonly: true`. - driver-sql's `injectTenantOnInsert` then filled the now-absent column with the active organization. - **Visibility:** the only signals were a `droppedFields` `readonly` entry and a WARN. ADR-0105 D5 and the organizations runtime's stamp comment ("a SUPPLIED value is never touched here … admitted where the caller's organization scope admits it, otherwise refused") both rule out that replacement. ## What changed - **`packages/objectql/src/engine.ts`.** - `PostHookWriteImageCheck` gains one optional member, `judgesOrganization?: boolean`, with its doc. - In `insert()`, `wallJudgesOrganization` is read once: true only for a non-system context whose installed seam declares the member. - The new `insertStaticReadonlySubject` returns the static-`readonly` insert subject without `organization_id` when that holds. It is the one function both passes use: the pre-hook withhold (`withholdInsertReadonlyFromHooks`) and the post-hook `staticReadonlyCreateStrip` call site. - `validate()` is unchanged. - **`packages/plugins/plugin-security/src/security-plugin.ts`.** - Step 3.7 sets `judgesOrganization: true` on the stored-image seam it composes, only for an insert with `tenantParts` non-empty. - The private `WriteImageCheckSeam` mirror type gains the member. - **Tests:** - `objectql`: `engine-insert-wall-judged-organization.test.ts`, which is new, plus one census row in `federated-injected-column-readers.test.ts` for the new `organization_id` use. - `plugin-security`: `insert-seam-judges-organization.test.ts`, which is new. - `organizations`: `create-explicit-organization-wall.test.ts`. - **Changeset:** `.changeset/24822-wall-judged-organization-stored.md`: `@objectstack/objectql` minor, `@objectstack/plugin-security` patch. ## Public surface Every change below is a widening; none admits a pair the wall does not. - `PostHookWriteImageCheck.judgesOrganization`: a new optional published member. - An in-process `strictReadonlyWrites: true` insert naming an admitted organization is stored, where it used to be refused `ERR_READONLY_FIELD_REJECTED`. - An admitted organization is no longer reported as dropped: not in `droppedFields`, not through `onFieldsDropped`, not by the read-only WARN. - `beforeInsert` hooks see the caller's `organization_id` on a walled insert. - **F1:** under `group`, a member with no active organization naming a member organization is stored there. It used to be refused under ADR-0123 D2. - **F2:** an on-behalf-of insert by a crossing operator is held to the delegator's wall. An admitted value is stored as sent; a refused one is refused. - **F3:** an array row's explicit empty `organization_id` is no longer reported or refused under strict, and is still stored as the active organization. - **F4:** only the platform's injected tenant column is spared, told apart by the registry's provenance reader (`resolveInjectedColumnProvenance`). An AUTHORED `organization_id` declared `readonly: true` keeps today's strip: taken, reported, and refused under strict. ## Seam - **Who sets the member, and when.** Only `@objectstack/plugin-security`, at step 3.7. It sets it on the seam it composes for an INSERT, and only when the tenant wall's own parts (`tenantParts`: the caller's Layer 0, and the ADR-0090 D10 delegator's) are in it. That is exactly when the seam's loop holds every stored row's `organization_id` to the wall. The payload half of the same step has already refused every supplied value outside it, before the engine runs. - **When it is left unset:** - the seam a business `check` installs alone at step 3.6, which is how `single` gets a seam with no wall behind it; - the crossing platform operator's no-active-organization seam (#24821), which has no wall parts, so its loop admits every row; - every update seam. - **Why the engine reads nothing else.** The engine cannot tell on its own where the wall judged the column, and seam presence is not the answer: the business-`check` seam and the operator seam are both seams with no organization judgement. Measured in the dev report `6305849709`, a strip exemption the engine decides alone stored the caller's value on five no-wall paths. Deriving the answer from the posture and the context would be a mirror of the wall, the re-derivation the #15706 ruling deleted. It is wider than the wall wherever the wall is not mounted. So the wall states its verdict on the seam it owns, and the flag and the judgement are the same object: a seam that does not judge the organization does not carry the flag. `tenantLayer0Verdict` is untouched. ## Boundary statement The set of `(caller, organization)` pairs an insert can store into is the set the Layer 0 wall admits, before and after. Every row below was measured in process on a real stack. A, C and D are organizations: A is the active one, C is in the membership set, D is outside it. - **Rounds 1–2:** ObjectQL + SqliteWasmDriver + the real `OrganizationsPlugin` (Middleware A) + the real `SecurityPlugin`. Before is base `aaee0840b`; after is the pins. - **F1, F3 (round 3):** the same stack. Before is the head with the engine's member read disabled (`false &&`, through `ablation-replace`); after is the head. - **F2 (round 3):** ObjectQL + SqlDriver + `SecurityPlugin`, a delegator rebuilt from stored `sys_user`, `sys_member` and `sys_user_permission_set` rows. | posture · caller | insert on the injected column | before | after | |:--|:--|:--|:--| | `group` member | names C, single and array | 2xx, stored **A**, `droppedFields` readonly + WARN | 2xx, stored **C**, nothing reported | | `group` member | names D, single and array | 403 `PERMISSION_DENIED` | 403 `PERMISSION_DENIED` | | `group` member | names none | stored A, not reported | stored A, not reported | | `group` member, `strictReadonlyWrites` (in process) | names C | `ERR_READONLY_FIELD_REJECTED` | stored C | | `group` member with NO active organization (F1) | names C, single and array | 403 `PERMISSION_DENIED` (ADR-0123 D2: the value was taken, so the row named none) | 2xx, stored **C**, nothing reported | | `group` member with NO active organization (F1) | names D | 403 `PERMISSION_DENIED` (the wall) | 403 `PERMISSION_DENIED` (the wall) | | `group` member with NO active organization (F1) | names none | 403 `PERMISSION_DENIED` (D2) | 403 `PERMISSION_DENIED` (D2) | | `group` crossing operator acting for a delegator holding {A, C} (F2, ADR-0090 D10) | names C | 2xx, stored **A**, `droppedFields` readonly | 2xx, stored **C**, nothing reported | | `group` crossing operator acting for that delegator (F2) | names D | 403 (the delegator's wall) | 403 (the delegator's wall) | | `group` crossing operator acting for that delegator (F2) | names none | stored A | stored A | | `isolated` member | names A | stored A, reported readonly + WARN | stored A, nothing reported | | `isolated` member | names C or D, single and array | 403 | 403 | | `isolated` crossing operator acting for a delegator (F2) | names A | stored A, `droppedFields` readonly | stored A, nothing reported | | `isolated` crossing operator acting for a delegator (F2) | names C | 403 (the delegator's wall) | 403 (the delegator's wall) | | `isolated` member, ARRAY insert (F3) | `organization_id: null` / `''` | stored A, `droppedFields` readonly + WARN; under strict `ERR_READONLY_FIELD_REJECTED` | stored A, nothing reported, not refused under strict | | `group` member, object with an AUTHORED `readonly: true` `organization_id` (F4) | names C | 2xx, stored A, `droppedFields` readonly + WARN; under strict `ERR_READONLY_FIELD_REJECTED` | unchanged: 2xx, stored A, `droppedFields` readonly + WARN; under strict `ERR_READONLY_FIELD_REJECTED` | - **What the wall admits is unchanged:** `group` {A, C}, `isolated` {A}, and a delegated insert the delegator's own set. Ablations A3 and R3A show the refusals are the wall's own; R3B shows the no-organization refusal is the ADR-0123 D2 arm's. - **Through an explicit payload on the injected column:** `group` goes from {A} to {A, C}, the wall's set; `isolated` stays {A} to {A}. - **F1** turns a refusal into a store only for a row that names an organization the wall admits. That matches ADR-0123's amended scope: the insert arms refuse a row that names no organization. - **Through every other route, already {A, C} under `group` before this change:** a declared-column object, and a `beforeInsert` hook's value judged by the same seam. - **No pair becomes writable that the wall does not admit.** - **F4 measurement.** At `2e932a613`, before the narrowing, the head read "2xx, stored C, nothing reported; strict stores". The narrowing (`resolveInjectedColumnProvenance` is `'author'` ⇒ keep the strip) restores the before column. The wall still judges an authored column and its seam declares it; the author's read-only declaration is what keeps the strip. Unchanged before and after. Each path is measured at base and pinned at the head, and on each the value is taken, reported and refused under strict as before: | path | explicit organization | stored | seam | |:--|:--|:--|:--| | `single`, no organizations runtime | C | A (the context's), reported | none | | `single` with a business insert `check` | C | A, reported | installed, member unset | | `single`, `strictReadonlyWrites` | C | refused `ERR_READONLY_FIELD_REJECTED` | none | | `group` / `isolated`, security plugin absent | C | A, reported | none | | `group` / `isolated`, security plugin absent | none | A, not reported (#21682) | none | | platform operator crossing on a private object, `group` / `isolated` | D | A, reported | none | | platform operator with no active organization, private object | D | refused 403 (no active organization) | installed, member unset | | `tenancy.enabled: false` object, authored read-only `organization_id` | C | NULL, reported | none | | system context | C, D | as sent | none | ## Pins - **`organizations/src/create-explicit-organization-wall.test.ts`.** This is the real stack. An innermost probe middleware records each insert's seam: whether one is installed, and whether it carries the member. - The rows of both tables above, as `group` / `isolated` cells. - The old cell "a caller that sends the readonly key itself still sees it reported" is rewritten to `isolated`: "a caller that sends its own active organization has it stored as sent, and nothing is reported". Its `single` twin stays. - **`plugin-security/src/insert-seam-judges-organization.test.ts`.** This is real ObjectQL + SqlDriver + SecurityPlugin. - The member is SET on a walled insert (`isolated`, `group` single and array, composed with a business `check`). - It is UNSET on the `single` business-`check` seam, on the operator's no-active-organization seam, and on an update's seam. - There is NO seam for an operator with an active organization, or for a system context. - **`objectql/src/engine-insert-wall-judged-organization.test.ts`.** The engine's reading, with a stand-in installer. - With the member SET, the value is stored as sent, shown to `beforeInsert`, handed to the seam, not reported, and not refused under strict. Another read-only key (`created_by`) is still taken. - With the member absent, `false`, or no seam at all, the value is taken, withheld from the hook, reported, WARNed, and refused under strict. - A system context is unchanged. The `validate()` preview is unchanged. ## Ablations Every ablation went through `node scripts/ablation-replace.mjs` (WRAP mode) inside a script with its own `trap` restore. Each ran the three pin files above, and each predicted direction was stated before the run. objectql, plugin-security and driver-sqlite-wasm are vitest src aliases in these suites, so there is no `dist/` leg. Every restore left the blob equal to HEAD and `git diff HEAD` empty. Rounds 1–2 (pin files at 40 / 8 / 13 cases): | id | mutation | predicted red | observed | |:--|:--|:--|:--| | A1 (ruled) | the step 3.6 business-`check` seam carries `judgesOrganization: true` | the two `single` business-`check` pins | org 1 failed / 40, sec 1 failed / 8, oql 0 failed / 13. The organizations pin: `- "organization_id": "org_alpha"` / `+ "organization_id": "org_south"` | | A2 | step 3.7 sets the member on every insert seam it composes (the `tenantParts` conjunct dropped) | the two operator no-active-organization pins | org 1 / 40 ("expected the no-active-organization refusal: expected true to be false", meaning the write was admitted), sec 1 / 8, oql 0 / 13 | | A3 | the wall's predicate admits every row | every wall refusal | org 10 / 40, sec 0 / 8, oql 0 / 13 | | A4 | the engine spares the column without the member | every no-wall cell | org 10 / 40, sec 3 / 8, oql 6 / 13 | | A5 | the #21682 caller-key record switched off | the two security-absent "stamp not reported" cells only | org 2 / 40, sec 0, oql 0. The walled #21682 cells stay green: on a walled insert the member now spares the column too | | A6 | the engine ignores the member (positive control) | every stored-as-sent cell | org 5 / 40, sec 1 / 8, oql 4 / 13 | Round 3 (pin files at 44 / 12 / 13 cases, head `a89f493fe`): | id | mutation | predicted red | observed | |:--|:--|:--|:--| | R3A | the wall's predicate admits every row | every wall refusal, now including F1's D and F2's `group` D / `isolated` C | org 11 / 44 (the round-2 ten plus "a create naming an organization outside the membership set is still refused by the wall"), sec 2 / 12 (both delegated refusals), oql 0 / 13 | | R3B | the ADR-0123 D2 insert arm switched off (`false &&`) | F1's no-organization cell only | org 1 / 44: `expected 'ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED' to be 'PERMISSION_DENIED'`; sec 0, oql 0 | | R3C | the engine ignores the member (positive control) | every stored-as-sent cell, now including F1, F2 and F3 | org 7 / 44, sec 3 / 12, oql 4 / 13 | Round 4 (pin files at 45 / 12 / 14 cases, head `534d68315`): | id | mutation | predicted red | observed | |:--|:--|:--|:--| | R4 | the provenance narrowing switched off (`false && resolveInjectedColumnProvenance(…) === 'author'`) | the two F4 pins only | org 1 / 45 (`- "organization_id": "org_alpha"` / `+ "organization_id": "org_south"`), sec 0 / 12, oql 1 / 14 | ## Tests and gates (final head `64a75d2bc`) - **Suites:** - `objectql` full suite (`vitest run --project local`): 454 files, 8547 passed, `VERDICT command-exit 0`. - `plugin-security` full suite: 202 files, 4151 passed, 45 skipped. - `organizations` full suite: 14 files, 190 passed. - The `plugin-security` and `organizations` suites and all three typechecks ran in one `&&`-joined command, which printed `VERDICT command-exit 0`. - **Typecheck:** `typecheck` exits 0 for `objectql`, `plugin-security` and `organizations`, and `check:test-typecheck` is OK for each. `objectql`'s ledger is unchanged at 39 files / 233 errors / 64 pinned signatures, and no signature was added. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, with no paths, derived 76 commands from 7 paths against merge base `b2890909a`. All 76 exit 0, each exit captured before any pipe. The `--ran` reconciliation reads '76 derived, 76 run, 0 NOT-MEASURED, 0 UNRUN' and exits 0. That includes `check-engine-split-ratio` (98.6%, on deepened history), `check:dual-build-cjs-loads` (69 packages, 728 cjs files) and `check:i18n` ('all bundles in sync'), each after a full `turbo run build`. - **CI on `64a75d2bc`:** 41 check-runs, 34 success and 7 skipped (the opt-in and main-only jobs), 0 failed, including Test Core 1–6, Build Core, TypeScript Type Check, Lint & Repo Gates and Governed Surface Queue Guard. The PR is mergeable (clean). - **`origin/main` has moved again** (to `72518d20f`: #25047, #25038, #25051; #25051 touches `objectql`'s `rule-validator.ts`, which this PR does not edit). It is not merged: the PR is open, mergeable and green, so §10's merge triggers (a conflict, a `main`-caused red, a queue removal) are absent. ## Acceptance notes - **The `validate()` preview (import dry run, `validateData`) is wall-blind.** This is finding 2 of the dev report. It runs no middleware, so it holds no seam. After this change, a `group` insert preview naming C still reports `droppedFields: organization_id readonly`, while the write stores C. Before and after, a preview naming D answers `valid: true` with the same drop, while the write answers 403. `ValidateDataResponse` describes `droppedFields` as what "the write would LEGALLY strip". Measured in process only (`engine.validate`, pinned in the objectql file above), not at the HTTP validate door. Dedupe words: `validate preview organization_id droppedFields wall` · `import dry run tenant wall not run` · `validateData Layer 0 wall blind`. - **The UPDATE half is not here.** It is the card's half 2: a re-point the wall admits that the update-side strip then takes. It reuses `judgesOrganization`. --- _Generated by [Claude Code](https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 688052e commit 2b1382a

7 files changed

Lines changed: 1370 additions & 16 deletions
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/plugin-security': patch
4+
---
5+
6+
fix(objectql,plugin-security): an insert's explicit `organization_id` that the Layer 0 wall judges is stored as sent, not replaced by the active organization
7+
8+
Clause-②: yes (widening) — `PostHookWriteImageCheck` gains the optional published member `judgesOrganization`, and an in-process `strictReadonlyWrites: true` insert naming an organization the wall admits is now stored instead of refused `ERR_READONLY_FIELD_REJECTED`.
9+
10+
**What was wrong.** Under the `group` posture, a member whose active organization is A can name C, another organization it belongs to, in an insert's `organization_id`. The Layer 0 write wall admitted C. Then the engine's static-`readonly` strip took it, because the platform's injected tenant column is `readonly: true`, and the driver stamped A. The insert answered 2xx with the row in A, single and array alike. The only signals were a `droppedFields` entry and a WARN. Under `isolated`, a caller that named its own active organization saw the same strip and report, for a value that ended up stored anyway.
11+
12+
**What changed.**
13+
14+
- **`@objectstack/objectql`.**
15+
- `PostHookWriteImageCheck` (reached as `OperationContext.postHookWriteImageCheck`) gains an optional member, `judgesOrganization?: boolean`. An enforcement layer sets it when the seam's `evaluate` holds every inserted row's `organization_id` to the tenant wall.
16+
- `ObjectQL.insert` reads that member to decide. When it is set and the object's `organization_id` is the platform's injected tenant column, a non-system caller's explicit `organization_id` stays out of the pre-hook withhold and out of the static-`readonly` strip. The injected column is told apart by the registry's provenance reader; an authored column keeps the strip. The value is then stored as sent where the wall admits it, and refused by the wall where it does not.
17+
- When the member is absent or `false`, both passes run exactly as before. That covers a seam that carries only a business `check`, a stack without an enforcement layer, and `validate()`, which runs no middleware.
18+
- **`@objectstack/plugin-security`.**
19+
- Step 3.7 sets the member only where it composes the wall's own parts into an insert's stored-image seam.
20+
- It does not set the member on a business-`check`-only seam, on a crossing platform operator's seam, or on any update.
21+
- The wall's admit/refuse set is unchanged: an organization outside the caller's scope is still refused `403 PERMISSION_DENIED`.
22+
23+
**What a caller sees.** Every change below is a widening. None of them admits a `(caller, organization)` pair the wall does not already admit, and nothing accepted before is refused now.
24+
25+
- **The optional member.** `PostHookWriteImageCheck.judgesOrganization` (`OperationContext.postHookWriteImageCheck`) is new published surface on `@objectstack/objectql`.
26+
- **The value is stored as sent.**
27+
- Under `group`, an insert naming an organization of the caller's membership set is stored there.
28+
- Under `isolated`, an insert naming the active organization is stored as before.
29+
- **No longer reported.** Such an organization is no longer reported as dropped: not in `droppedFields`, not through the `onFieldsDropped` listener, and not by the read-only WARN.
30+
- **`strictReadonlyWrites` stores it.** An in-process `engine.insert(..., { strictReadonlyWrites: true })` naming such an organization is now stored, where it used to be refused `ERR_READONLY_FIELD_REJECTED`: strict refuses exactly what the strip takes. No REST door can set that flag.
31+
- **`beforeInsert` hooks see it.** On a walled insert, `beforeInsert` hooks now see the caller's `organization_id`, because the value will be stored. Before, it was withheld from them.
32+
- **A member with no active organization.** Under `group`, such a member with a non-empty membership set can now insert a row naming one of those organizations: it is stored there. It used to be refused `403 PERMISSION_DENIED` under ADR-0123 D2, because the value was taken and the row then named no organization. A row naming none is still refused under D2, and one naming an organization outside the set is still refused by the wall.
33+
- **An on-behalf-of insert (ADR-0090 D10).** Take a platform operator who crosses the wall and acts for a walled delegator. The named organization is held to the delegator's wall: stored as sent where that wall admits it, refused `403` where it does not. Before, an admitted value was taken and the active organization stored.
34+
- **An explicit empty value in an array insert.** A row naming `organization_id: null` or `''` is no longer taken, reported, or refused under strict. It is still stored as the active organization, because the driver's stamp fills an empty value.
35+
- **Only the platform's injected column is spared.** On an object whose author declares its own `organization_id` with `readonly: true`, a caller's value is still taken, reported, and refused under strict, exactly as before. The wall still judges that column. The author's declaration is what keeps the strip, and the injected column is told apart by the registry's provenance reader (`resolveInjectedColumnProvenance`).
36+
- **Where no wall judges the column, nothing changes.** The value is still taken and reported, and strict still refuses it. That includes `single`, a composition without the security plugin, a tenancy-disabled object, and a platform operator crossing the wall on its own behalf.
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#24822] `engine.insert` leaves a non-system caller's explicit
4+
// `organization_id` out of its static-`readonly` passes (the pre-hook withhold
5+
// and the post-hook strip) ONLY when the stored-image seam declares that it
6+
// judges the insert's organization: `PostHookWriteImageCheck.judgesOrganization`.
7+
//
8+
// The engine side of the seam, pinned with a stand-in installer. The installer
9+
// in production is `@objectstack/plugin-security`'s step 3.7. Its side, and the
10+
// two packages together on a real stack, are pinned in
11+
// `plugin-security/src/insert-seam-judges-organization.test.ts` and
12+
// `organizations/src/create-explicit-organization-wall.test.ts`.
13+
//
14+
// What this file holds the engine to:
15+
// - the member SET: the value is stored as sent, shown to `beforeInsert`,
16+
// handed to the seam, not reported, and not refused under
17+
// `strictReadonlyWrites`. Every OTHER read-only key is still taken, and so
18+
// is an AUTHORED read-only `organization_id`: only the platform's injected
19+
// column is spared;
20+
// - the member ABSENT or `false` on an installed seam, and no seam at all:
21+
// the strip takes the value exactly as before (reported, withheld from the
22+
// hook, refused under strict). Seam presence is not the signal: a business
23+
// `check` installs a seam with no wall behind it;
24+
// - `validate()` runs no middleware, so it holds no seam, and its preview is
25+
// unchanged.
26+
27+
import { describe, it, expect, beforeEach } from 'vitest';
28+
import { ObjectQL } from './engine.js';
29+
30+
function makeDriver() {
31+
const stores = new Map<string, Map<string, any>>();
32+
const storeFor = (o: string) => {
33+
let s = stores.get(o);
34+
if (!s) { s = new Map(); stores.set(o, s); }
35+
return s;
36+
};
37+
const matches = (row: any, where: any): boolean => {
38+
if (!where || typeof where !== 'object') return true;
39+
return Object.entries(where).every(([k, v]: [string, any]) => {
40+
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
41+
return row?.[k] === v;
42+
});
43+
};
44+
let n = 0;
45+
const driver: any = {
46+
name: 'memory', version: '0.0.0', supports: {},
47+
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
48+
async find(object: string, ast: any) {
49+
const rows = Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where));
50+
// The caller's bound, applied AFTER the filter and by PRESENCE
51+
// (`check:objectql-double-limit`).
52+
return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows;
53+
},
54+
async findOne(object: string, ast: any) {
55+
for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return r;
56+
return null;
57+
},
58+
async create(object: string, data: Record<string, unknown>) {
59+
n += 1;
60+
const id = (data.id as string) ?? `r_${n}`;
61+
const row = { ...data, id };
62+
storeFor(object).set(id, row);
63+
return row;
64+
},
65+
async update(object: string, id: string, data: Record<string, unknown>) {
66+
const s = storeFor(object);
67+
const row = { ...s.get(id), ...data, id };
68+
s.set(id, row);
69+
return row;
70+
},
71+
async updateMany(object: string, ast: any, data: Record<string, unknown>) {
72+
const s = storeFor(object);
73+
let count = 0;
74+
for (const row of [...s.values()]) {
75+
if (!matches(row, ast?.where)) continue;
76+
s.set(row.id, { ...row, ...data, id: row.id });
77+
count += 1;
78+
}
79+
return count;
80+
},
81+
async delete(object: string, id: string) { return storeFor(object).delete(id); },
82+
async count() { return 0; },
83+
async bulkCreate(object: string, rows: Record<string, unknown>[]) {
84+
return Promise.all(rows.map((r) => this.create(object, r, undefined)));
85+
},
86+
async bulkUpdate() { return []; }, async bulkDelete() {},
87+
async beginTransaction() { return { __trx: true, commit: async () => {}, rollback: async () => {} }; },
88+
async commit() {}, async rollback() {},
89+
};
90+
return { driver, storeFor };
91+
}
92+
93+
const OBJECT = 'qa_ledger';
94+
const ACTIVE = 'org_a';
95+
const NAMED = 'org_c';
96+
const SESSION = { userId: 'usr_member', tenantId: ACTIVE };
97+
const AUTHORED = 'qa_authored';
98+
99+
/** How the stand-in installer behaves on this engine's next insert. */
100+
type SeamMode = 'judges' | 'judges-false' | 'no-member' | 'none';
101+
102+
describe('[#24822] engine.insert spares organization_id only where the seam declares the organization judged', () => {
103+
let engine: ObjectQL;
104+
let storeFor: ReturnType<typeof makeDriver>['storeFor'];
105+
let seamMode: SeamMode;
106+
/** `organization_id` as `beforeInsert` saw it: `'absent'` when the key was not on the row. */
107+
let hookSaw: unknown[];
108+
/** `organization_id` on every row the seam judged. */
109+
let seamJudged: unknown[];
110+
let warns: string[];
111+
112+
beforeEach(async () => {
113+
warns = [];
114+
hookSaw = [];
115+
seamJudged = [];
116+
seamMode = 'judges';
117+
const logger: any = {
118+
warn: (m: string) => warns.push(String(m)),
119+
debug() {}, info() {}, error() {}, trace() {}, fatal() {},
120+
child() { return logger; },
121+
};
122+
engine = new ObjectQL({ logger });
123+
const d = makeDriver();
124+
storeFor = d.storeFor;
125+
engine.registerDriver(d.driver, true);
126+
await engine.init();
127+
engine.registry.registerObject({ name: OBJECT, fields: { name: { type: 'text' } } } as any);
128+
// [#24822] F4: an app object whose AUTHOR declares its own read-only
129+
// organization_id. The member alone does not spare it.
130+
engine.registry.registerObject({
131+
name: AUTHORED,
132+
fields: { name: { type: 'text' }, organization_id: { type: 'text', readonly: true } },
133+
} as any);
134+
135+
engine.registerHook('beforeInsert', async (ctx: any) => {
136+
const data = (ctx.input?.data ?? {}) as Record<string, unknown>;
137+
hookSaw.push(Object.prototype.hasOwnProperty.call(data, 'organization_id') ? data.organization_id : 'absent');
138+
}, { object: OBJECT });
139+
140+
// The stand-in installer: what an enforcement layer puts on an insert.
141+
engine.registerMiddleware(async (opCtx: any, next: () => Promise<void>) => {
142+
if (opCtx.operation === 'insert' && seamMode !== 'none') {
143+
opCtx.postHookWriteImageCheck = {
144+
evaluate: (rows: readonly Record<string, unknown>[]) => {
145+
for (const row of rows) seamJudged.push(row.organization_id);
146+
},
147+
...(seamMode === 'judges' ? { judgesOrganization: true } : {}),
148+
...(seamMode === 'judges-false' ? { judgesOrganization: false } : {}),
149+
};
150+
}
151+
await next();
152+
});
153+
});
154+
155+
const insertReporting = async (data: unknown, extra: Record<string, unknown> = {}) => {
156+
const dropped: Array<{ fields: string[]; reason: string }> = [];
157+
await engine.insert(OBJECT, data as any, {
158+
context: SESSION,
159+
onFieldsDropped: (e: any) => dropped.push({ fields: e.fields, reason: e.reason }),
160+
...extra,
161+
} as any);
162+
return dropped;
163+
};
164+
const stored = () => [...storeFor(OBJECT).values()].map((r) => r.organization_id);
165+
const readonlyWarns = () => warns.filter((w) => w.includes("Field 'organization_id'"));
166+
167+
it('PRECONDITION: the platform injects organization_id as a read-only column on this object', () => {
168+
const field = (engine.getSchema(OBJECT) as any)?.fields?.organization_id;
169+
expect(field?.readonly, 'the strip has the column to take').toBe(true);
170+
});
171+
172+
it('SET: the named organization is stored as sent, shown to beforeInsert, handed to the seam, and not reported', async () => {
173+
const dropped = await insertReporting({ name: 'one', organization_id: NAMED });
174+
175+
expect(stored()).toEqual([NAMED]);
176+
expect(dropped).toEqual([]);
177+
expect(readonlyWarns()).toEqual([]);
178+
expect(hookSaw).toEqual([NAMED]);
179+
expect(seamJudged, 'the seam judges the value the driver stores').toEqual([NAMED]);
180+
});
181+
182+
it('SET: an array insert keeps each row\'s named organization', async () => {
183+
const dropped = await insertReporting([
184+
{ name: 'one', organization_id: NAMED },
185+
{ name: 'two', organization_id: ACTIVE },
186+
]);
187+
188+
expect(stored()).toEqual([NAMED, ACTIVE]);
189+
expect(dropped).toEqual([]);
190+
expect(seamJudged).toEqual([NAMED, ACTIVE]);
191+
});
192+
193+
it('SET spares organization_id ONLY: another read-only key the caller sends is still taken and reported', async () => {
194+
const dropped = await insertReporting({ name: 'one', organization_id: NAMED, created_by: 'usr_forged' });
195+
196+
expect(stored()).toEqual([NAMED]);
197+
expect(dropped).toEqual([{ fields: ['created_by'], reason: 'readonly' }]);
198+
expect([...storeFor(OBJECT).values()][0]?.created_by, 'the forged audit value is not stored').not.toBe('usr_forged');
199+
});
200+
201+
it('SET, but an AUTHORED read-only organization_id: the author\'s declaration keeps the strip, and strict refuses it', async () => {
202+
const dropped: Array<{ fields: string[]; reason: string }> = [];
203+
await engine.insert(AUTHORED, { name: 'one', organization_id: NAMED } as any, {
204+
context: SESSION,
205+
onFieldsDropped: (e: any) => dropped.push({ fields: e.fields, reason: e.reason }),
206+
} as any);
207+
208+
expect([...storeFor(AUTHORED).values()].map((r) => r.organization_id), 'the caller\'s value is not stored').toEqual([undefined]);
209+
expect(dropped).toEqual([{ fields: ['organization_id'], reason: 'readonly' }]);
210+
211+
let thrown: { code?: unknown; fields?: unknown } | undefined;
212+
try {
213+
await engine.insert(AUTHORED, { name: 'two', organization_id: NAMED } as any, { context: SESSION, strictReadonlyWrites: true } as any);
214+
} catch (e) {
215+
thrown = e as typeof thrown;
216+
}
217+
expect(thrown?.code).toBe('ERR_READONLY_FIELD_REJECTED');
218+
expect(thrown?.fields).toEqual(['organization_id']);
219+
});
220+
221+
it('SET: strictReadonlyWrites does not refuse it, because strict refuses only what the strip takes', async () => {
222+
await insertReporting({ name: 'one', organization_id: NAMED }, { strictReadonlyWrites: true });
223+
224+
expect(stored()).toEqual([NAMED]);
225+
});
226+
227+
for (const mode of ['no-member', 'judges-false', 'none'] as const) {
228+
describe(`NOT SET (${mode === 'none' ? 'no seam at all' : mode === 'no-member' ? 'a seam that does not declare it' : 'the member false'}): the strip takes the value exactly as before`, () => {
229+
beforeEach(() => { seamMode = mode; });
230+
231+
it('taken, withheld from beforeInsert, reported, and WARNed', async () => {
232+
const dropped = await insertReporting({ name: 'one', organization_id: NAMED });
233+
234+
expect(stored(), 'the caller\'s organization is not stored').toEqual([undefined]);
235+
expect(dropped).toEqual([{ fields: ['organization_id'], reason: 'readonly' }]);
236+
expect(readonlyWarns()).toHaveLength(1);
237+
expect(hookSaw, 'withheld from the hook, as #22306 requires').toEqual(['absent']);
238+
expect(seamJudged).toEqual(mode === 'none' ? [] : [undefined]);
239+
});
240+
241+
it('refused under strictReadonlyWrites, and nothing is stored', async () => {
242+
let thrown: { code?: unknown; fields?: unknown; operation?: unknown } | undefined;
243+
try {
244+
await insertReporting({ name: 'one', organization_id: NAMED }, { strictReadonlyWrites: true });
245+
} catch (e) {
246+
thrown = e as typeof thrown;
247+
}
248+
expect(thrown, 'expected the strict write to be refused').toBeDefined();
249+
expect(thrown?.code).toBe('ERR_READONLY_FIELD_REJECTED');
250+
expect(thrown?.fields).toEqual(['organization_id']);
251+
expect(thrown?.operation).toBe('insert');
252+
expect(stored()).toEqual([]);
253+
});
254+
});
255+
}
256+
257+
it('a system context stores what it sends with or without the member: the strips never run for it', async () => {
258+
seamMode = 'none';
259+
await engine.insert(OBJECT, { name: 'one', organization_id: NAMED } as any, { context: { isSystem: true } } as any);
260+
seamMode = 'judges';
261+
await engine.insert(OBJECT, { name: 'two', organization_id: NAMED } as any, { context: { isSystem: true } } as any);
262+
263+
expect(stored()).toEqual([NAMED, NAMED]);
264+
});
265+
266+
it('validate() runs no middleware, so it holds no seam: its preview still reports the strip it always reported', async () => {
267+
const preview = await engine.validate(OBJECT, { name: 'one', organization_id: NAMED }, { mode: 'insert', context: SESSION as any });
268+
269+
expect(preview.results[0]?.droppedFields).toEqual([{ object: OBJECT, fields: ['organization_id'], reason: 'readonly' }]);
270+
});
271+
});

0 commit comments

Comments
 (0)