Skip to content

Commit 9340181

Browse files
fix(objectql,plugin-security): the write preview answers a row's organization from the tenant wall's own verdict (#25215)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgULzXHHREefG78bonpN6vqt)_ Fixes #25062 Clause-②: yes (widening): one additive registration member on @objectstack/objectql's plugin-facing API Metadata-protocol: none Builds ruling 6336640643 (letter A, confirmed by the maintainer in 6336790186). Claim 6339442996, `Ruling-ref: 6336640643`. The `plugin-security` half is the cross-lane touch declared on #21118 (comment 6339470244). ## Summary `ObjectQL.validate()` (the import dry run) runs no middleware, so the Layer 0 tenant write wall never judged a preview row. On a walled deployment the preview reported `organization_id` as dropped where the write stores it as sent (`group`, a sister organization), and answered `valid` for a row the write refuses with 403 (`isolated`, a foreign organization). Now the wall's own verdict travels to the engine as a value, per row, and the preview reads that value and nothing else: - **`plugin-security`.** Step 3.7's judgement of a write is composed by ONE method, `composeWriteTenantWall`, and the middleware itself calls it. That covers the scope pre-test with its ADR-0090 D10 delegator leg, the supplied-value forge guard, the emptied organization on update, the #24784 / #24821 no-active-organization insert arms, and whether the wall judges the organization. The payload half and the stored-image seam both read it. `tenantParts` and `clearsWall` are each declared once, inside it. `SecurityPlugin.writeWallVerdict(object, operation, context, rows)` answers each row from that same method, over the principals the middleware's own prologue resolves (`resolveOperationPrincipals`). It is registered on the engine as `registerWriteWallVerdict`. - **`objectql`.** `registerWriteWallVerdict(fn)` is new and optional, beside `registerWriteGateProbe`, with the types `WriteWallVerdict` and `WriteWallRowVerdict`. `validate()` asks for verdicts after the defaults: - **Admitted, organization judged:** `organization_id` is spared from the static `readonly` strip exactly as `insert()` / `update()` spare it, so `droppedFields` does not name it. - **Refused:** the row answers `valid: false` with the write's code `PERMISSION_DENIED` and the write's message. `field` is `organization_id` for a refused value and `_record` for refused caller state. The row runs no strip and reports no drop. Rows stay independent, and the set's `valid` stays the conjunction. - **Unregistered, or `null`:** today's strip, unchanged. - The engine reads no posture, `tenantId` or `accessible_org_ids`. No `OperationContext` member is added, no middleware is ridden, and no contract text moves. - **Second ask, for the stored-image half.** A row the strips changed is asked about again, as the write's seam judges the row it stores. This is the #24821 crossing operator: a platform operator with no active organization names one on a private object. The wall does not judge the organization, so the strip takes it, and the stored row has nowhere to land. The first ask admits the row, and only the stripped row shows the 403 the write answers. - **Docs.** The `registerWriteGateProbe` docblock is corrected (see Acceptance notes). The `validate()` docblock names the new behaviour and its limit. `content/docs/permissions/system-context.mdx` gains row 6c for the verdict's system-context exit, which the system-context census requires. ## Pins | Pin | File | What it holds | |:--|:--|:--| | The two cells + caller state + controls, in process | `packages/plugins/organizations/src/validate-preview-wall-verdict.test.ts` (10) | Real `ObjectQL`, `SqliteWasmDriver`, `OrganizationsPlugin` and `SecurityPlugin`. Each case runs the write and the preview on the same row. Cell (a): `group` sister and `isolated` own organization, stored as sent and no drop in the preview. Cell (b): `isolated` foreign and `group` outside the membership set, write 403 and preview `valid: false` with the write's code and message under `organization_id`. Caller state under `_record`: ADR-0123 D2, #24784, #24821. Controls: `single` posture, a non-tenant object (`tenancy.enabled: false`), and nothing registered (`security: false`) | | The HTTP import door, M5a / M5b | `packages/rest/src/import-dryrun-tenant-wall-parity.test.ts` (4) | The real route `POST /api/v1/data/:object/import`, the same rows dry then committed, and the row reports must agree: M5a, M5b, a mixed batch, and the `single` control | | Equivalence: seam = middleware | `packages/plugins/plugin-security/src/write-wall-verdict-equivalence.test.ts` (21) | 19 table cases across `isolated` / `group` / `single`, member / orgless / group-without-active / operator, insert / by-id update. The real middleware, with the installed seam evaluated by the terminal, gives the same admit / refuse / `judgesOrganization` / field as `writeWallVerdict`. Plus a bulk case and the `null` exits | | Engine contract | `packages/objectql/src/validate-write-wall-verdict.test.ts` (14) | A stand-in verdict on a fake driver: unregistered / `null` = today's strip, admitted judged and unjudged, an authored `readonly` `organization_id` keeps its strip, a refused row mid-batch, `_record`, asked once after defaults with no hook run, asked again for a stripped row, update mode, and a wrong-length or throwing verdict rejects | `engine-insert-wall-judged-organization.test.ts` keeps its assertion; its last title now says "with no write-wall verdict registered", which is what it pins. ## Ablation (one-off, not kept) The implementation was committed first (`3d071598`). Then the plugin's feature-detect was renamed on disk to `registerWriteWallVerdictAblated`, so the seam is never registered, and `@objectstack/plugin-security` was rebuilt. `scripts/ablation-dist-preflight.mjs` confirmed the marker was in `dist`. - Organizations pins went 7 red (both cells and the three caller-state cases) and 3 green (the three controls). - REST pins went 3 red (M5a, M5b, the mixed batch) and 1 green (the `single` control). - Every failure was an `AssertionError`. Restored with `git checkout HEAD --`. `git diff HEAD` is empty, the package was rebuilt, and the preflight's `--absent` leg passed. ## Verification All at head `0cc6f290a` unless noted. Every heavy command ran under `os-verify-lock`. - **Pins:** objectql `validate-write-wall-verdict`, `engine-insert-wall-judged-organization` and `federated-injected-column-readers` 32/32; plugin-security `write-wall-verdict-equivalence` 21/21; organizations `validate-preview-wall-verdict` 10/10; rest `import-dryrun-tenant-wall-parity` 4/4. Lock `VERDICT command-exit 0`. - **Package suites:** - `@objectstack/plugin-security` full: 207 files, 4253 passed, 45 skipped, after the extraction. This is the behaviour-preservation check for the moved step 3.7. - `@objectstack/objectql` full: 8662/8663 at `3d071598`. The one red was `federated-injected-column-readers.test.ts`: the new `WriteWallRowVerdict` type names `organization_id` at module scope. It got its `not-a-read` row in `0cc6f290` and is green at head. - **Typecheck:** `typecheck` including `check:test-typecheck` for objectql, plugin-security, organizations and rest. All OK, with no new test-typecheck debt (the shrink-only ledger held for objectql; 0 entries for the other three). - **Derived gates:** `dispatch-gates.mjs --commands` derives 106 families. All 106 ran, and `--ran` with exit codes recorded reads `106 derived, 106 run, 0 NOT-MEASURED, 0 UNRUN`. Every recorded exit is 0. Some needed a rerun for reasons outside this diff: - `check-engine-split-ratio` and the `check-plugin-teardown-shape` self-test refused on the shallow clone and passed once it was deepened. - `check:i18n`, `check:dual-build-cjs-loads` and `check:skill-examples` refused for missing `dist/` and passed after their named build closures. - `check:query-options-erasure` once raced a concurrent `.examples-build` writer and passed alone. - `check-system-context-census` was a real finding, fixed by row 6c. - The ratchet and census families were re-run at `0cc6f290a`. - **Full dogfood:** n/a. The title has no `!` and the `Clause-②` arm is `widening`. - **CI:** pending at open; read from this PR's check-runs. ## Acceptance notes - **The probe docblock finding (from report 6336244213).** The `registerWriteGateProbe` docblock listed "its ADR-0123 D2 organization wall (step 3.7)" among the arms the probe runs. The probe only asks step 3.7's scope pre-test, with no row. It does not ask the supplied-organization forge guard, and it answers `true` for a foreign organization (measurement M3). The docblock now says so, and points to `registerWriteWallVerdict` for the row judgement. Two questions, two members: the probe's boolean is unchanged. - **Changeset level: `patch`, not the `minor` the dispatch named.** The ruling sets the level "per AGENTS.md Post-Task Checklist step 3", and `check-changeset-no-major` holds every entry of a `Metadata-protocol: none` changeset to `patch`. The new member is optional and additive. - **The dry run names the field; the commit does not.** A refused dry-run row carries `field: organization_id` (or `_record`). The commit's row is built from a thrown 403, which has no field on it. Code and message are identical on both doors (M5b). The HTTP pin compares everything but `field`, and pins the dry run's `field` on its own. - **Named limit, kept from the ruling.** The preview judges the row as it holds it, after defaults and before hooks. An organization written by a `beforeInsert` / `beforeUpdate` hook is judged by the write's stored-image half alone. This is named in the `validate()` docblock. - **`null` exits.** `writeWallVerdict` answers "not asked" for a system context, a context with no user, and a caller the context prologue refuses before the wall is reached: principal-less, guest without an organization, a subsystem failure, or a missing or non-member delegator. The write refuses those callers before its wall, and the preview runs none of the write's caller gates. - **File surface.** The measurement probe `validate-preview-wall-verdict.probe.test.ts` is deleted (`941fa4e1`), and its readings are now the pins above. The surface also grew by two ledger rows the repo's own guards require, beyond the claim's list. One is `system-context.mdx` row 6c: `check-system-context-census` refuses an elevation read that no row anchors, and `writeWallVerdict` reads `isSystem`. The other is a `federated-injected-column-readers.test.ts` READERS row for the verdict type's `organization_id` literal. --- _Generated by [Claude Code](https://claude.ai/code/session_0157EiYDGYnrsXE9aHv1uMxU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3e07389 commit 9340181

13 files changed

Lines changed: 1861 additions & 332 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): the write preview (`validate()`, the import dry run) answers a row's `organization_id` from the Layer 0 tenant wall's own verdict
6+
7+
Clause-②: yes (widening)
8+
9+
Metadata-protocol: none
10+
11+
`validate()` runs no middleware, so the tenant write wall never judged a preview row. On a walled deployment (`group` or `isolated`) the preview reported a caller's `organization_id` as dropped where the write stores it as sent, and answered valid a row naming an organization the write refuses with 403.
12+
13+
- **New, optional: `ObjectQL.registerWriteWallVerdict(fn)`**, beside `registerWriteGateProbe`, with its types `WriteWallVerdict` and `WriteWallRowVerdict`. The layer that owns the wall registers it (`@objectstack/plugin-security` does), and the engine never decides the verdict itself.
14+
- **`validate()` reads each row's verdict.** A row whose organization the wall judges keeps `organization_id` through the static `readonly` strip, so no `droppedFields` entry names it, as on the write. A row the wall refuses answers `valid: false` with the write's own code (`PERMISSION_DENIED`) and message, under `organization_id` for a refused value and `_record` for the caller's state, and it drops nothing. Rows stay independent, and the set's `valid` stays the conjunction. A row the strips changed is asked about again, as the write's stored-image half judges it.
15+
- **Unregistered, nothing changes**: the strip runs as before, which is what the write does in a composition without the security plugin.
16+
- **Named limit:** the preview runs no hooks, so an organization a `beforeInsert` / `beforeUpdate` hook writes is judged by the write alone.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): step 3.7's tenant write wall is composed by one method, and the write preview asks it about each row
6+
7+
Clause-②: yes (widening)
8+
9+
Metadata-protocol: none
10+
11+
The engine's write preview (`validate()`, the import dry run) runs no middleware, so it never heard the Layer 0 tenant wall's verdict on a row's `organization_id`.
12+
13+
- **One spelling of the wall.** Step 3.7's judgement (the no-active-organization pre-test with its delegator leg, the supplied-value forge guard, the emptied organization on update, the #24784 / #24821 no-active-organization insert arms, and whether the wall judges the organization) is composed by one method, `composeWriteTenantWall`. The middleware's payload half and its stored-image seam both call it. Behaviour, refusals and log lines are unchanged.
14+
- **New: `SecurityPlugin.writeWallVerdict(object, operation, context, rows)`**, registered on the engine as `registerWriteWallVerdict`. It answers each row from that same method: admitted (and whether the wall judges its organization), or refused with the write's `PermissionDeniedError` code and message. It answers `null` for a system context, a context with no user, and a caller the context prologue refuses before the wall is reached. On an engine without the seam the plugin logs one warning at start and the preview keeps its old answer.

‎content/docs/permissions/system-context.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ that silently does not happen.
103103
| 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `packages/plugins/plugin-security/src/security-plugin.ts#canExport` |
104104
| 6 | Object-level read admission granted unconditionally | plugin-security | Get: `canReadObject` is `true`. This is the OBJECT-level half of a read — "may this caller read this object at all" — which the doors that bypass this middleware ask before they compile a statement of their own; `getReadFilter` is its row-level half, and the two are not interchangeable | `packages/plugins/plugin-security/src/security-plugin.ts#canReadObject` |
105105
| 6b | WRITE admission granted unconditionally | plugin-security | Get: `canWriteObject` is `true`. The WRITE twin of the object-level read admission above, asked by the write PREVIEW (`ObjectQL.validate()`), which runs no middleware for its target object and so has no gate of its own. It exists because a validation rule that reads one hop through a reference field is evaluated against a related row fetched under system authority, and that elevation is bounded to the arms the question RUNS — named, ⛔ never a category of the write decision, and ⛔ not a promise the write would succeed. The arms: the ADR-0103 engine-owned affordance gate and the ADR-0090 D12 delegated-admin gate (both ahead of every resolution), the fail-closed postures (#3545's unresolvable posture and the D10 dangling delegator), the ADR-0066 D3 capability arm, the CRUD grant, the ADR-0090 D10 delegator arm that a bare CRUD-grant check misses, the field-level-security write gate over the caller's payload when one is supplied, and the ADR-0123 D2 no-active-organization wall | `packages/plugins/plugin-security/src/security-plugin.ts#canWriteObject` |
106+
| 6c | The tenant wall's per-row verdict for the write PREVIEW answers **"not asked"** | plugin-security | Get: `writeWallVerdict` returns `null`, so `ObjectQL.validate()` treats the rows exactly as it does with no verdict registered: no row is refused by the Layer 0 tenant wall, and the elevated preview's `readonly` strips stay skipped as they always were. That is what the elevated write does: the whole security middleware short-circuits before step 3.7, so the wall never judges an elevated row's organization. Lose: nothing the write enforces. The verdict exists so a preview answers a row's organization as its write answers it (#25062), and an elevated write meets no tenant wall to answer for | `packages/plugins/plugin-security/src/security-plugin.ts#writeWallVerdict` |
106107
| 7 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `packages/plugins/plugin-security/src/security-plugin.ts#start` |
107108
| 8 | Metadata-plane schema masking exempt (ADR-0106 D4) | metadata-core | Get: unmasked object schema. Note: the exemption is a **caller** property — it short-circuits before the security service is consulted | `packages/metadata-core/src/object-schema-fls.ts#isObjectSchemaMaskExempt` |
108109
| 8b | Authoring-trail reads admitted | rest, runtime | Get: a metadata item's version history, audit rows and stored-version diff (`GET /meta/:type/:name/history`, `/audit`, `/diff`) without `studio.access` or `manage_metadata`, and a package's commit timeline (`GET /packages/:id/commits`) without `studio.access`. Note: narrower than the schema-mask exemption the draft switches ask (`isObjectSchemaMaskExempt`): neither door admits `setup.access`, which organization admins hold | `packages/rest/src/rest-server.ts#mayReadAuthoringTrail`, `packages/runtime/src/domains/packages.ts#requireCommitTrailReadCapability` |

‎packages/objectql/src/engine-insert-wall-judged-organization.test.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@
2121
// the strip takes the value exactly as before (reported, withheld from the
2222
// hook, refused under strict). Seam presence is not the signal: a business
2323
// `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.
24+
// - `validate()` runs no middleware, so it holds no seam: with no write-wall
25+
// verdict registered its preview is unchanged. The verdict it reads instead
26+
// ([#25062]) is pinned in `validate-write-wall-verdict.test.ts`.
2627

2728
import { describe, it, expect, beforeEach } from 'vitest';
2829
import { ObjectQL } from './engine.js';
@@ -263,7 +264,7 @@ describe('[#24822] engine.insert spares organization_id only where the seam decl
263264
expect(stored()).toEqual([NAMED, NAMED]);
264265
});
265266

266-
it('validate() runs no middleware, so it holds no seam: its preview still reports the strip it always reported', async () => {
267+
it('validate() runs no middleware, so it holds no seam: with no write-wall verdict registered its preview still reports the strip', async () => {
267268
const preview = await engine.validate(OBJECT, { name: 'one', organization_id: NAMED }, { mode: 'insert', context: SESSION as any });
268269

269270
expect(preview.results[0]?.droppedFields).toEqual([{ object: OBJECT, fields: ['organization_id'], reason: 'readonly' }]);

0 commit comments

Comments
 (0)