|
| 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. |
0 commit comments