Repository navigation
fix(objectql,plugin-security): an update's organization_id re-point the Layer 0 wall admits is stored as sent - #25094
Conversation
…he Layer 0 wall judges is stored as sent Step 3.7 now declares judgesOrganization on the stored-image seam it composes for a single-payload update as well as an insert (tenantParts non-empty). The engine reads that member once per update and hands it to every static-readonly pass (the pre-hook hide pass, the by-id and predicate strips, and the stored view the readonlyWhen strips and the master-detail settlement read), through one helper shared with the insert side. Only the platform-injected column on an object the insert strip judges is spared, so sys_ and platform-internal objects and an authored read-only organization_id keep the strip. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
… the Layer 0 wall judges, and the changeset The engine half with a stand-in installer, step 3.7's setter on real ObjectQL + SqlDriver + SecurityPlugin (sys_ scope and delegated update included), and the boundary matrix on the real organizations stack: group and isolated, by id and by predicate, admitted and refused, emptied, array payload, F4, strict, preserveAudit, the no-wall paths and the wall-blind update preview. The insert-seam file's update cell flips to the update half's answer. Census rows follow the shared spare helper. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
…date-repoint-honoured
…tep 3.7 as it now reads Step 3.7 no longer calls an update's organization_id effectively immutable: a re-point the Layer 0 wall admits is stored. The header's citation and its note on the injected column follow it. Comment only. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
…date-repoint-honoured
📓 Docs Drift Check7 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 08f81a0c1d269f60e109f306efab6b0693ba4ce9 && git checkout 08f81a0c1d269f60e109f306efab6b0693ba4ce9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4feccc26851b89dcb8f4d78dfab011e73d12c128 784b1f9f3110eeb99ec43838259df08047bd6dec && git checkout -B drift-repro 4feccc26851b89dcb8f4d78dfab011e73d12c128 && git merge --no-ff 784b1f9f3110eeb99ec43838259df08047bd6dec
node scripts/docs-audit/affected-docs.mjs --json 4feccc26851b89dcb8f4d78dfab011e73d12c128 |
Contract reviewServed-tier: Inputs read. Card #24822: its body and all 14 comments, through the half-2 dev report
Nothing was built, tested or re-run. The only script run was the record tool ( ① Derived judgments(a) RIGHT: the
(b) RIGHT:
(c) RIGHT: the shared helper, and the descriptor member that travels through
(d) RIGHT: the pins cover the ruled matrix, and the negative pins are ablation-verified per the PR body.
(e) RIGHT: every public-surface change is listed in the changeset and the PR body, N2 included.
② Semver level
③ Boundary flagsThe seat's rulings for half 2:
U3, the grant tables: this PR changes nothing for them.
The dev's flags:
The dev's out-of-scope findings:
Notes. None blocks.
Check-runs on
Implemented-by: VERDICT: PASS |
席位核对ACCEPT · #24822 half 2 (UPDATE, the last half) ·
|
Fixes #24822
Clause-②: yes (widening) — an update re-point of
organization_idthe Layer 0 wall admits is now stored instead of silently dropped, and an in-processstrictReadonlyWrites: trueupdate naming an organization the wall admits is now stored instead of refusedERR_READONLY_FIELD_REJECTED.This is half 2 of the card: UPDATE. Half 1 (insert) landed as PR #25048 (
2b1382a5b3). This half reuses its member,PostHookWriteImageCheck.judgesOrganization, and its F4 provenance rule, through one helper shared by both write verbs. The card's last half, so this PR closes it.What was wrong
Under
group, take a member whose active organization is A and whose membership set includes C. It updates a row of an app object, which carries the platform-injected tenant column, namingorganization_id: C.readonlyupdate strip then took the value, becauseTENANT_SCOPE_FIELD_DEFdeclaresreadonly: true, and the row kept its own organization.droppedFieldsreadonlyentry and a WARN;beforeUpdatehooks were not shown the value.organization_idstored the same re-point, by id and by predicate (premise U2, measured below), so honouring it on the injected column is a fix, not a new capability.The governing text is the organizations runtime's Middleware A comment: a supplied value "goes on to the Layer 0 write wall … the same wall an UPDATE that re-points
organization_idmeets, and gets the same answer: admitted where the caller's organization scope … admits it, otherwise refused403 PERMISSION_DENIED". The seat call6305891812carried the UPDATE half on this card.What changed
packages/objectql/src/engine.ts.staticReadonlySparesOrganization(schema, wallJudgesOrganization), new: the ONE answer both write verbs read. It spares the column only when the wall's seam declares the member, the object is one the INSERT strip judges (staticReadonlyInsertSubjectholds the column: neversys_, never a platform-internalmanagedBybucket), and the column's provenance is not'author'(F4).insertStaticReadonlySubjectnow reads it. Its behaviour is unchanged (half 1's pins are green, and ablation B1-F4 turns its F4 pin red through the shared helper).updateStaticReadonlySubject(schema, wallJudgesOrganization), new: the update strip's schema view, withoutorganization_idwhere the helper spares it;schemaitself otherwise.update(),wallJudgesOrganizationis read ONCE, inside the middleware chain's final handler (!isSystemand the seam's member=== true), andreadonlyUpdateSubjectis built once from it. Every static-readonlypass reads that one view: the pre-hook hide pass (whatbeforeUpdateis shown), the by-id strip and the predicate strip (what is stored).staticReadonlyStoredView/staticReadonlyStripTakes(the views thereadonlyWhenstrips and the master-detail settlement read) take the same flag on their descriptor and apply the same helper, so areadonlyWhenpredicate readingrecord.organization_idreads the organization the row is stored in.PostHookWriteImageCheck.judgesOrganizationdoc now covers updates. No member is added.validate()is unchanged.packages/plugins/plugin-security/src/security-plugin.ts.operation === 'insert'conjunct is replaced byoperation === 'insert' || (operation === 'update' && !Array.isArray(data)), still ANDed withtenantParts.length > 0. The array clause restates the enclosing condition on purpose: the member is the seam's own claim about what it judges.WriteImageCheckSeamdoc, the setter's comment, and step 3.7's "effectively immutable" paragraph are rewritten to state the stored re-point (seat ruling 6).objectql:engine-update-wall-judged-organization.test.ts(new, stand-in installer), and census rows infederated-injected-column-readers.test.tsfor the shared helper.plugin-security:update-seam-judges-organization.test.ts(new, real ObjectQL + SqlDriver + SecurityPlugin, real grant tables); ininsert-seam-judges-organization.test.tsthe cell "an update's seam does not carry the member" flips to the update half's answer;tenant-wall-organization-removal.test.tsheader prose only.organizations:update-repoint-organization-wall.test.ts(new, the real stack half 1 measured on)..changeset/24822-update-repoint-honoured.md:@objectstack/objectqlminor,@objectstack/plugin-securitypatch.Public surface
Every change below is a widening. None admits a
(caller, organization)pair the wall does not already admit, and the engine's own gates (the wall, the strip, the strict flag) refuse nothing they accepted before.PostHookWriteImageCheck.judgesOrganization(published,OperationContext.postHookWriteImageCheck): its contract now covers single-payload updates, andObjectQL.updatereads it. No new member.droppedFields, not throughonFieldsDropped, not by the read-only WARN.strictReadonlyWrites: trueupdate naming an admitted organization is stored, where it used to be refusedERR_READONLY_FIELD_REJECTED.beforeUpdatehooks see the caller'sorganization_idon a walled update.group, a member with NO active organization re-points within its membership set the same way (before: taken and reported).readonlyWhen/requiredWhenpredicate and a hook readingorganization_idsee the organization the row moves to. So a re-point that collides with an organization-scoped unique key in its new organization is refused there, exactly as on a declared-column object today; before, the re-point was dropped and the write reported success.sys_and platform-internal objects, an AUTHOREDreadonly: trueorganization_id, an emptied value, an array payload, every no-wall path,preserveAudit,isSystem, and the wall-blindupdatepreview.Seam
tenantPartsnon-empty: the caller's Layer 0 and the ADR-0090 D10 delegator's). That seam's loop then holds every stored image's organization to the wall and refuses an emptied one; the payload half of the same step has already refused every supplied value outside the wall, and every emptied one, before the engine runs.check-only update seam (howsinglegets a seam with no wall); a crossing platform operator's update (it gets no step 3.7 seam:tenantPartsis empty and the no-active-organization arm is insert-only); an array update payload (step 3.7 composes nothing for it);isSystem(the middleware returns before steps 3.6 and 3.7).tenantLayer0Verdictis untouched.Boundary statement
The set of
(caller, organization)pairs an update can move a row into is the set the Layer 0 wall admits, before and after. A, C and D are organizations: A is the active one and the row's own, C is in the membership set, D is outside it.OrganizationsPlugin+ the realSecurityPlugin, the stack half 1 used. Rows marked (sec) are ObjectQL + SqlDriver +SecurityPluginwith the real grant tables and a delegator rebuilt from storedsys_user,sys_memberandsys_user_permission_setrows.2b1382a5b, and by the final pins withmain's two source files (c67f244f5) restored over the tree (tree only; restored to blob == HEAD after). After is the probe at22e257dfeand the pins at the final head.groupmember (org)droppedFieldsreadonly + WARN, hook sees nonegroupmember (org)groupmember (org)PERMISSION_DENIEDPERMISSION_DENIEDgroupmember (org)groupmember,strictReadonlyWrites(org, in process)ERR_READONLY_FIELD_REJECTEDgroupmember with NO active organization (org)isolatedmember (org)isolatedmember (org)group/isolatedmember (org)null,''), by id / by predicategroupmember (org, sec)groupcrossing operator for a delegator holding {A, C} (sec)isolatedcrossing operator for a delegator (sec)groupmember, AUTHOREDreadonly: trueorganization_id(org, F4)groupmember,sys_object on the injected column (sec)groupmember, amanagedBy: 'system-data'app object (sec probe)group{A, C},isolated{A}, a delegated update the delegator's own set. B2-WALL shows the refusals are the wall's own.groupgoes from {A} to {A, C}, the wall's set;isolatedstays {A}.groupbefore this change (U2, the control cell in the organizations file).Unchanged before and after, measured at base and pinned at the head; on each the re-point is taken, reported and refused under strict, or refused earlier, as before:
single, no organizations runtime, by id / by predicatesinglewith a business updatecheck, by id / by predicatesingle,strictReadonlyWritesERR_READONLY_FIELD_REJECTEDgroup/isolated, security plugin absent, by id / by predicategroup/isolated, by id / by predicatetenancy.enabled: falseobject, authored read-onlyorganization_idsingle,preserveAudit: truesys_user_position/sys_user_permission_set, a membersys_user_position/sys_user_permission_set, a platform administrator (walled there)managedBy: 'engine-owned'/'append-only'object (sec probe; the engine-level cell is pinned in oql)group)404 RECORD_NOT_FOUND(step 2.7, #21771)where.id(org,group)Must-not-regress
Each point of both contract reviews' ③ lists for half 2, with its pin and the ablation that turns that pin red (the Ablations table below has the counts).
From
6308267963③:tenantParts; never on step 3.6's check seam or the crossing operator's seam; theinsertconjunct replaced deliberately. The setter ANDstenantParts.length > 0withinsert || (update && !Array.isArray(data)). Pins: sec "singlewith a businesscheck: … UNSET", org "singlewith a business updatecheck, by id / by predicate" (B2-SET1b red); sec and org operator-crossing cells, NO SEAM; the positive control B2-SETOFF.[#22278]block, eight cells (B2-E22278 red); the org D cells and sec D cells (B2-WALL red).tenant-wall-organization-removal.test.tsstays green in the full suite.isSystemand update-modevalidate()unchanged; the census row. OnewallJudgesOrganizationread, onereadonlyUpdateSubject. Pins: org "shown to beforeUpdate" by id / by predicate and oql "SET, by id" (B1-HIDE red); oqlreadonlyWhencell (B1-VIEW red); system-context cells in all three files; the preview cells in org and oql; census rows. The!isSystemconjunct is an equivalent mutant (the strips never run for a system caller).honouredfail-closed guard. Untouched; its pintenant-wall-post-hook-image.test.ts"a host that never runs the installed wall judgement …", both drivers (B2-HON red).From
6309495055③:isSystem. As points 1, 2 and 4 above; sec "a system context installs no seam".[#22278]block (B2-E22278 red: with the gate off, the spare stores the emptied value).readonly: trueorganization_idkeeps the update strip. Org F4 by id / by predicate, oql "SET, but an AUTHORED …" (B1-F4 red, which also turns half 1's insert F4 pins red through the shared helper).sys_objects: measure and rule. Measured as premise U3, ruled by the seat (ruling 1: the update spare covers exactly the objects the insert spare covers). Pins: oqlsys_andengine-ownedcells, secsys_cell (B1-SYS red). The grant tables declare a WRITABLEorganization_id, so the strip never took a re-point there: their cells are controls, measured identical before and after (the before run withmain's sources passes them), and no mutation of the spare can reach them.checkis judged on the pre-hook change set, so abeforeUpdatestamp that rewrites a checked field (e.g. the organization derived from a re-pointed parent) is stored unjudged — the tracker #16790 now answers 404 #19989), the step 2.7 pre-image selection, the predicate seam's judgement of every matched row. Org falsy-id cell (B2-PRE27 red); org out-of-scope row cell (B2-PRE27P red: it needs step 2.7 AND the engine's scoped prior read off, two independent gates); oql "SET, by predicate … the seam judges every one" (B1-PRED red).preserveAudit's update-only exemption must not widen toorganization_id. OrgpreserveAuditcell, oql "preserveAudit does not spare it" x3 (B1-PA2 red).staticReadonlySparesOrganizationis shared by insert and update. The preview: see Acceptance notes.Pins
organizations/src/update-repoint-organization-wall.test.ts(real stack; an innermost probe middleware records each update's seam and the organizations it judged): every row of both tables above that is marked (org), asgroup/isolated, by id and by predicate.plugin-security/src/update-seam-judges-organization.test.ts: the member SET on a walled update (isolated,groupby id and by predicate, composed with a businesscheck); UNSET onsingle's check seam; NO SEAM for a crossing operator, a system context and an array payload; thesys_scope (asys_object, both grant tables); the delegated update undergroupandisolated.objectql/src/engine-update-wall-judged-organization.test.ts(stand-in installer): with the member SET, by id and by predicate, the re-point is stored, shown tobeforeUpdate, handed to the seam on every stored image, not reported, not refused under strict;created_byis still taken; an AUTHORED column, asys_object and anengine-ownedbucket keep the strip; areadonlyWhenlock reads the stored organization. With the member absent,false, or no seam: taken, withheld, reported, WARNed, refused under strict, andpreserveAuditdoes not spare it. A system context is unchanged. The update preview still reports the drop.Ablations
Every leg went through
node scripts/ablation-replace.mjs(WRAP mode) inside a script carrying its owntrap … EXIT INT TERMrestore against the absolute path, with the restore proven by blob == HEAD and an emptygit diff HEAD. Each ran the six pin files (org 88, sec 28, oql 42 cases), and each predicted direction was written down before the run. objectql, plugin-security and driver-sqlite-wasm / driver-sql are vitest source aliases in these suites, so there is nodist/leg.!isSystem)sys_/ bucket exclusionssys_andengine-ownedcells onlyreadonlyWhencell onlypreserveAudit's exemption widened toorganization_idpreserveAuditcells'organization_id'literal inrule-validator.ts)single+ check cellsemptiesOrganizationalways false[#22278]cellsclearsWalladmits every rowhonouredfail-closed guard off (run ontenant-wall-post-hook-image.test.ts+ the sec update file)Two first attempts were no-ops that
ablation-replacerefused before running anything (exit 1, "the anchor count moved 1 -> 1"): B1-PA and B2-SET1, whose replacements still contained their anchors. Their re-runs are B1-PA2 and B2-SET1b.Tests and gates (final head
784b1f9f3)organizations/srcandplugin-security/src, run under the verify lock and deleted, never committed; base2b1382a5b): U1 true (by id and by predicate: 2xx, row stays A,droppedFieldsreadonly + 1 WARN; D refused 403). U2 true (the declared-column object stores C by id and by predicate, the seam judging C; D refused 403). U3 measured (the update strip runs onsys_objects and step 3.7 composes its wall there; the grant tables declare a writable column; a member's update of them meets the RBAC administration gate;engine-owned/append-onlyobjects meet the ADR-0103 guard). U4 true. U5 measured: every no-wall path takes the re-point.VERDICT command-exit 0):objectql(vitest run --project local): 458 files, 8610 passed.plugin-security: 205 files, 4193 passed, 45 skipped.organizations: 15 files, 233 passed.typecheckexits 0 for all three, andcheck:test-typecheckis OK for each.objectql's ledger is unchanged at 39 files / 233 errors / 64 pinned signatures; no signature was added.@objectstack/speccheck:generated: exit 0 (packages/specmoved onmain's side of the merge).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, no paths, derived 76 commands on784b1f9f3. All 76 exit 0, each exit captured before any pipe, after a fullturbo run buildof./packages/*and./packages/*/*(75 tasks).--ranreads "76 derived, 76 run, 0 NOT-MEASURED, 0 UNRUN" and exits 0. That includescheck-engine-split-ratio(98.6%; the shallow clone's oldest visible commit predates the window),check:dual-build-cjs-loads,check:type-check-debtandcheck:i18n.origin/main: merged twice before opening (c67f244f5, thenf46ef4d70, which brought fix(plugin-security)!: an update of a controlled_by_parent detail also asks for edit access on the master its body names (ADR-0055 §3) #25066'ssecurity-plugin.tsregion; no conflict). Every number above is on the second merge.mainhas since moved to4feccc268(fix(service-messaging)!: an html or mjml notification template escapes every hole for markup, and each outlet package pins its outlet list (ruling 3C) #25076, fix(platform-objects): Create User result dialog reads true for a typed password #25081, fix(driver-sql): a SQLite database whose header SQLite cannot read, and a dangling-link directory on the boot that writes, fail connect() naming the file #25080, feat(pm): the relay requests review from every GOVERNED_APPROVERS account on a Tier H pull; CODEOWNERS routes every Tier H surface to both approvers #25064: driver-sql, platform-objects, service-messaging, PM tooling), none sharing a file with this PR.Acceptance notes
update-modevalidate()preview is wall-blind (seat ruling 7). It runs no middleware, so it holds no seam. After this change, agroupupdate preview naming C still reportsdroppedFields: organization_id readonlywhile the write stores C; a preview naming D answers the same drop while the write answers 403 (before and after). Pinned in the org and oql files. objectql: thevalidatepreview (import dry run) runs no tenant write wall, so after PR #25048 it reportsorganization_idas dropped for a walled insert that the write stores, and it answers valid for a value the write refuses #25062 carries the preview, insert and update alike; this PR does not change it.sys_objects on the injected column keep the update strip, by seat ruling 1. A source scan of this tree lists thesys_objects that declare noorganization_idof their own, and so carry the injected one wherever they are tenant-scoped:sys_attachment,sys_comment,sys_comment_reaction,sys_business_unit_member(platform),sys_email_template,sys_webhook(config),sys_user_preference,sys_notification_template,sys_notification_preference(system-data),sys_file,sys_upload_session,sys_inbox_message. Where the wall applies to one of them, a re-point is still taken and reported, exactly as an insert's organization there is (pinned on asys_object in the sec file); the seam declares the member there without effect.where.idreaches the driver and fails with a raw SQL error (no such column: 0), before and after this change; measured in process only, no public door measured. Observation, not filed (carrier: none).Generated by Claude Code