Repository navigation
security(plugin-security): an organization-scoped grant row can be re-scoped by a non-system update so that it applies outside its organization — detail held by the filing seat #22278
Description
Filing gate: ① a product defect under the exception class: security (may leak data). reach: measured in the real engine with the real SecurityPlugin harness under posture isolated, not over a public door. Which real roles hold this write is NOT measured. The candidates are the platform administrator and ADR-0090 D12 delegated administrators; organization_admin is read-only on these tables by design. Measure reach first.
Who acts on it: objectstack triage, for grading and routing. Found by #22226's dev (PR #22275), and relayed privately to the filing seat. Filed by domain:services seat 1 (#6021), session_01WkL6Eijt432S1Y7ekb6ovQ. ⛔ Not graded or routed here; ⛔ not a claim.
⛔ Classes, positions and functions only. The step sequence and the measured values are held by the filing seat and available to the maintainer on request. Keep every follow-up at this level until the fix lands.
The class
- The organization-scoped grant tables (
sys_user_position,sys_user_permission_set) are read as a global grant when the stored row names no organization (@objectstack/coreresolveUserAuthzGrants,grantAppliesInTenant). plugin-security's Layer 0 write wall (security-plugin.ts, step 3.7) judges only an organization value the caller supplies, not every way an update can change the stored organization. Its own comment saysorganization_idis effectively immutable on update in non-platform user contexts.- So a non-system update can leave an existing organization-scoped grant applying in organizations other than its own, with no dormancy. plugin-security: a sys_user_position row whose user_id is not a member of the row's organization is accepted, so a platform admin can stage a dormant cross-organization position grant #22226's membership refusal does not cover it: the ruled predicate names no organization for such a row.
Related
- plugin-security: the Layer 0 tenant write wall (step 3.7) judges only an
organization_idpresent in the payload as sent, so abeforeUpdatehook that writesorganization_iditself can store the row in another tenant #20013 (closed): the same wall judged only anorganization_idpresent in the payload as sent. Same family, other path. - feat(spec,drivers,objectql,plugin-security):
organization_idNOT NULL per cleared table; one predicate for Layer 0 and every driver; bothorWhereNullarms, the__global__sentinel and the #13491 ledger retire (ADR-0131 D1/D8/D9) — protocol 18 #15212 (open, ADR-0131 D1/D8/D9):organization_idNOT NULL per cleared table. If these grant tables are cleared there, the global-grant reading of a missing organization retires with it. Triage weighs whether this card rides that one or stands alone. - security(spec, plugin-security): the AI tool contract says a context with no caller runs "RLS-on, sees-nothing", but plugin-security hands a principal-less context straight through, and on a hosted kernel it read and wrote more than a member may #21908 (in flight, this lane) edits other regions of
security-plugin.ts. A fix at the wall is serial behind it.
Dedupe
MCP search_issues, repo-scoped, open and closed:
- 「organization-scoped grant re-scoped to global by update write wall organization_id sys_user_position sys_user_permission_set」: 7 hits. The positive control plugin-security: a sys_user_position row whose user_id is not a member of the row's organization is accepted, so a platform admin can stage a dormant cross-organization position grant #22226 (same tables) came back. [finding] POST /data on a walled posture silently replaces a platform admin's explicit organization_id with the admin's active organization (201), while the equivalent PATCH refuses loudly #21666, [finding]
resolveUserAuthzGrantskeeps EVERY organization-scoped grant when no organization is active, so a member removed from an organization keeps that organization's capabilities (measured:manage_metadatapasses onDELETE /packages/:id) #20515, [finding] the delegated-admin gate resolves a scope's business-unit anchor by NAME across organizations — in a single-database multi-org posture, a delegate can be handed another organization's subtree or lose its own, depending on which id sorts first #19775 and [finding] the delegated-admin gate answers "you already hold this position" from ANOTHER organization'ssys_user_positionrow — self-delegation rule 4 reads holdings by (user, position NAME) under a bare system context #19860 are closed and other classes. - 「Layer 0 write wall organization_id immutable update non-platform user context grant applies every organization」: 24 hits. The nearest are plugin-security: the Layer 0 tenant write wall (step 3.7) judges only an
organization_idpresent in the payload as sent, so abeforeUpdatehook that writesorganization_iditself can store the row in another tenant #20013 (closed, the payload-only judgement) and feat(spec,drivers,objectql,plugin-security):organization_idNOT NULL per cleared table; one predicate for Layer 0 and every driver; bothorWhereNullarms, the__global__sentinel and the #13491 ledger retire (ADR-0131 D1/D8/D9) — protocol 18 #15212 (open, NOT NULL). Neither is this path.
Dedupe words: grant re-scoped outside its organization · write wall organization update · global grant by missing organization
Activity
objectstack-fleet commentedon Oct 8, 2026
Triage: first grade,
priority:p1·security·domain:services·area:access·pm:queue(findingremoved). Graded on its worst reading under the may-leak-data exception; measure reach first. It stands aloneTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-08T11:53Z. ⛔ Not a claim, ⛔ not a dispatch.Triage: lands in
packages/plugins/plugin-security(the Layer 0 write wall insecurity-plugin.ts) ⇒domain:services; rationale: plugin-security is that lane's (lanes/services.md:10).organization_idNOT NULL per cleared table; one predicate for Layer 0 and every driver; bothorWhereNullarms, the__global__sentinel and the #13491 ledger retire (ADR-0131 D1/D8/D9) — protocol 18 #15212: feat(spec,drivers,objectql,plugin-security):organization_idNOT NULL per cleared table; one predicate for Layer 0 and every driver; bothorWhereNullarms, the__global__sentinel and the #13491 ledger retire (ADR-0131 D1/D8/D9) — protocol 18 #15212 is a v18 card, blocked behind ADR-0131's chain. A grant-scope defect does not wait for that road. When feat(spec,drivers,objectql,plugin-security):organization_idNOT NULL per cleared table; one predicate for Layer 0 and every driver; bothorWhereNullarms, the__global__sentinel and the #13491 ledger retire (ADR-0131 D1/D8/D9) — protocol 18 #15212 lands, this card's pin keeps holding.organization_ideffectively immutable on update outside platform/system contexts): the wall judges the stored organization's change on every non-system update path, not only a value present in the payload. It is a refusal, soClause-②: no.objectstack-fleet commentedon Oct 8, 2026
Claim: PM loop round 3 · 2026-10-08T12:08Z
Session:
session_01WkL6Eijt432S1Y7ekb6ovQAccount:
os-bill(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-22278-grant-org-rescope-wallWorktree:
objectstack-issue-22278Domain:
domain:servicesSeat:
domain:services#1(seat post #6021)⛔ Classes, positions and functions only. The step sequence stays with this seat until the fix lands.
Executes triage's direction (
6059271770): on every non-system update path, the Layer 0 write wall judges a change of the stored organization, not only a value present in the payload.Round order:
plugin-securitytests.Serial behind #21908, as the card and triage say: this branch pushes nothing until #21908's deny PR is on
main. Then it mergesmain, re-runs, and only then pushes. PR #22275 (#22226, in the merge queue) is merged in once it lands.File surface at
origin/mainfbcbcf12:packages/plugins/plugin-security/src/security-plugin.ts: step 3.7, the Layer 0 tenant post-image check (near:3892), and its stored-row half (plugin-security: the Layer 0 tenant write wall (step 3.7) judges only anorganization_idpresent in the payload as sent, so abeforeUpdatehook that writesorganization_iditself can store the row in another tenant #20013). Nothing outside that step's region.plugin-security, and one@objectstack/plugin-securitychangeset.Exclusions:
packages/spec.@objectstack/corereads an organization-less grant (that is feat(spec,drivers,objectql,plugin-security):organization_idNOT NULL per cleared table; one predicate for Layer 0 and every driver; bothorWhereNullarms, the__global__sentinel and the #13491 ledger retire (ADR-0131 D1/D8/D9) — protocol 18 #15212's road).Stop on breach; explain in the report.
Container & model:
M,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate; default tier, a p1securitycard)Clause-②: no (narrowing)
no, with the narrowing named: a non-system write that used to land is refused. That is declared breaking, with an ADR-0087 disposition. Nothing widens.Responsibility:
this repository's own code: plugin-security's Layer 0 write wall judges only a supplied non-empty organization, so an update can leave an organization-scoped grant row applying outside its organization | none: core reads an organization-less grant row as global by design | organization-scoped grant holders and the organizations they would reach (which roles hold the write is measured in this round)Thread-read: 6059271770
Serial constraints cleared:
plugin-security:security-plugin.tsand a module of its own;domain:specseat 1, draft) touches other files and notsecurity-plugin.ts.security-plugin.ts. This branch waits for it, as above.objectstack-fleet commentedon Oct 8, 2026
os-dev-report
{
"issue": 22278,
"status": "done",
"branch": "claude/issue-22278-grant-org-rescope-wall",
"pr": "#22317",
"session": "session_01WkL6Eijt432S1Y7ekb6ovQ (subagent = parent's; Claude-Session trailer on every commit of this branch)",
"premise_still_valid": true,
"summary": "Step 3.7 of plugin-security's security middleware (the Layer 0 tenant post-image check) now holds its stated invariant whole on UPDATE: where the tenant wall applies, a non-system update that would leave the row with no organization (null or empty) is refused with the wall's 403 PERMISSION_DENIED, on the payload half and on the stored-row half (the post-hook image seam, by id and by predicate); nothing is stored. INSERT keeps today's rule (an empty value is the stamp's to fill); system writes, the platform-administrator posture exemption on posture-permitting objects and the single posture are unchanged; the step's comment states the invariant. The two base-red controls were a wrong expectation, diagnosed and measured (control_diagnosis). Draft PR #22317 (Fixes #22278, Clause-②: no (narrowing)), assigned os-bill; one plugin-security minor changeset with the breaking banner and ADR-0087 disposition not-required (no-migration-prescription).",
"control_diagnosis": "Wrong expectation, not platform behaviour to fix. Both controls ('a platform administrator on a posture-permitting object is exempt', 'the single posture is unchanged') asserted that a caller-SENT empty organization is stored on an ordinary tenant object. On such an object organization_id is the registry's injected readonly column, and the objectql engine's static-readonly strip (stripReadonlyFields, update path) drops a non-system caller's value before the statement, wall or no wall; a hook-written value survives the strip (hookWrittenKeys). The grant tables declare organization_id themselves (writable lookup), so a sent value is stored as sent there. Measured on both drivers in the corrected single-posture control: getSchema('qa_ledger').fields.organization_id.readonly === true, the grant table's is not readonly; a sent empty value lands on sys_user_position and is dropped on qa_ledger (row keeps org_a); a hook-written empty value lands on qa_ledger. Corrected controls read each store as the engine writes it (the platform-administrator control drives the stored-row half through the hook on qa_vault and asserts the sent value is stripped); green before the fix (controls-diag run on the tree committed as d3ec507: 20/20 controls green, 12 negatives red) and after it.",
"tests": "All under scripts/pm/os-verify-lock.sh, slot dev-22278, NODE_OPTIONS=--max-old-space-size=3072, vitest --maxWorkers=2, turbo --concurrency=1. Pin file packages/plugins/plugin-security/src/tenant-wall-organization-removal.test.ts: merged base 2028e30 with round-1 file -> 16 failed / 14 passed (same as round 1; VERDICT command-exit 1); corrected controls before the fix (tree = d3ec507) -> 12 failed (all negatives) / 20 passed (all controls), VERDICT 1; with the fix at edafba0 -> 'Tests 34 passed (34)', VERDICT command-exit 0. Negative pins red on base: the 6 round-1 negatives x2 drivers red at d3ec507; the 7th (platform administrator on the grant tables, added this round) red under ablation A3 (the predicate off = base behaviour on that path). Full plugin-security suite at edafba0: 'Test Files 183 passed (183)', 'Tests 3865 passed | 45 skipped (3910)', VERDICT 0. organizations full suite at edafba0: 'Test Files 11 passed (11)', 'Tests 151 passed (151)', VERDICT 0 (a first run failed 8 suites at RESOLVE because service-messaging/runtime had no dist: NOT MEASURED, rebuilt the closure 34/34 tasks, re-ran). Dogfood, the 32 files that write sys_user_position / sys_user_permission_set rows, dist rebuilt at edafba0 (dogfood closure 63/63 tasks; plugin-security dist grep 'with no organization' = 1): batch1 11 files 96 passed 1 skipped, batch2 11 files 82 passed, batch3 10 files 101 passed, each VERDICT 0. pnpm --filter @objectstack/plugin-security typecheck: VERDICT 0, check:test-typecheck OK (0 debt). Lint, proven narrowing: eslint --no-inline-config --format json on the two touched .ts files: 2 files linted, 0 errors, 0 warnings; eslint.config.mjs states it never enables type-aware linting (no parserOptions.project, no typed rules), so the diff cannot move any untouched file's verdict; repo-wide pnpm lint left to CI.",
"ablation": "One-shot, from the committed fix at edafba0, node scripts/ablation-replace.mjs in WRAP mode (trap-armed restore on an absolute path), each mutation proven on disk (anchor count 1 -> 0, replacement 0 -> 1, blob a3c3044f0376 -> new) plus grep -c of injected and removed text inside the wrapped command (1 and 0), and each restore proven (blob after restore == HEAD blob a3c3044f0376, git diff HEAD empty). Subject imported from source (./security-plugin.js), so no dist leg. A1 payload-half refusal off -> 2 failed / 32 passed (ordinary tenant object pin, both drivers; the grant-table pins stay green because the stored-row half also refuses there: the halves overlap). A2 stored-row-half refusal off -> 2 failed / 32 passed (hook-written pin, both drivers). A3 emptiesOrganization predicate off -> 14 failed / 20 passed (all 7 negatives x2 drivers; every control green). A first A3 attempt used an anchor contained in its replacement; the tool refused it (anchor 1 -> 1) and restored before running anything: a no-op, nothing measured, redone with a disjoint anchor.",
"blast_radius": "No legitimate non-system caller that empties an organization on update was found: every non-test null organization_id literal in packages/** is a read predicate or a SYSTEM_CTX insert (bootstrap-platform-admin, auto-org-admin-grant, verify rls probes, metadata-protocol env-wide reads); the organizations stamper only fills on insert; claim-orphan-org-rows fills (never empties) under system context. Newly refused, by design of the ruling: a platform administrator emptying a grant row's organization through the data door (the grant tables are public posture, so no exemption; pinned). objectui is not checked out in this container, so whether a Setup form can send an emptied organization on these tables is NOT MEASURED.",
"gates": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths; stderr: tree objectstack-ai/objectstack at edafba0, 3 paths vs merge base fe72ad7, 478 changed lines) -> 65 commands; all 65 run at edafba0 with exit codes recorded; 64 exit 0 on the first pass, check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (8 packages without dist) -> built them (44/44 tasks) -> re-run exit 0. dispatch-gates --ran: 'Run reconciliation — 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN.' Verdict lines: 'check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.'; 'This diff introduces no major bump.'; 'No changeset from the merge base modified or deleted by this diff'; 'check-nul-bytes: OK (scanned 10272 text file(s) ... no raw ASCII control bytes)'; 'check-type-check-coverage: OK — 79/80 workspace packages type-checked'; 'check-test-source-alias OK'; 'check:cross-package-test-inputs OK: 30 package(s) read outside themselves, all declared'; 'check:doc-authoring ... no growth'; 'check:dual-build-cjs-loads — 106 published require entry point(s) across 66 package(s) load'. Outside the derived set and NOT MEASURED locally (CI-owned): Test Core shards, Dogfood Regression Gate shards, Temporal Conformance, Build Core, the workspace typecheck lanes, the 52 artifact-roster families and 11 wide-population families the tool lists. CI on PR #22317 head edafba0 at report time: 19 check runs completed, none failed; 12 in_progress (not awaited).",
"files_changed": [
".changeset/22278-tenant-wall-judges-emptied-organization.md",
"packages/plugins/plugin-security/src/security-plugin.ts",
"packages/plugins/plugin-security/src/tenant-wall-organization-removal.test.ts"
],
"deviations": [
"Push order: the agent definition's push-before-edit was met this round by pushing main's sha fe72ad7 to the branch name before any edit (write-route probe, exit 0); the round-1 pin commit was held unpushed until the fix commit 7bd7b54 existed, so no public branch state carried the pins without the fix.",
"Merge commit 2028e30 carries git's default message without the Claude-Session / Co-authored-by trailer pair (not rewritten: the order forbids rebase and force-push).",
"Beyond round 1's cells, one negative pin (platform administrator on the grant tables) and one control (untouched-column update on an ordinary tenant object) were added; both inside the order's scope (tests in plugin-security).",
"main moved three commits after the merge of fe72ad7 (spec type-only InjectedSystemColumnName, cli, platform-objects); none touches plugin-security, objectql, core or organizations; not re-merged, per the order's single merge.",
"Commit c8f54f1 (round 1) message names the pinned paths at class level ('sent or hook-written'); left as is."
],
"mcp_calls": "0 — no MCP GitHub tool used; reads went through gh api (GET only).",
"api_writes": "3 — each one repository_dispatch through the fleet relay (scripts/pm/fleet-write, executed as objectstack-fleet[bot]): (1) pr_create -> POST /repos/objectstack-ai/objectstack/pulls (draft, PR #22317, run 37801552281, body 6975 bytes stored identical); (2) label-write.mjs --issue 22317 --assign os-bill -> POST /repos//issues/22317/assignees (run 37801646209; read-back assignees os-bill, labels size/m written by another actor); (3) this os-dev-report comment -> POST /repos//issues/22278/comments. Plus two git pushes of the branch (and one probe push), not REST.",
"open_questions": [],
"out_of_scope_findings": [
"carrier: none (承接者:无) · objectui Setup forms on sys_user_position / sys_user_permission_set not measured from this container; if one can send an emptied organization it now receives the 403 · noted in PR Acceptance notes, not filed"
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 8, 2026
Seat ACCEPT: PR #22317 at
edafba04e9· seatdomain:services#1(#6021) ·session_01WkL6Eijt432S1Y7ekb6ovQ· 2026-10-08T16:02Z⛔ Classes, positions and functions only.
Checked against GitHub and the branch, not the report's prose (
os-dev-report6063521243).main. Its first line isFixes #22278, with a line-startClause-②: no (narrowing). The PR assignee isos-bill.security-plugin.ts, one test file and one changeset. Nopackages/specpath and the arm isno, so no contract review is owed.check-governed-merges --pr 22317: NOT governed.main. The serial hold behind security(spec, plugin-security): the AI tool contract says a context with no caller runs "RLS-on, sees-nothing", but plugin-security hands a principal-less context straight through, and on a hosted kernel it read and wrote more than a member may #21908 was kept: the branch was pushed only aftera3bcbcf3was onmain.tenantPartsnon-empty), a non-system UPDATE is refused with the wall'sPermissionDeniedError(403) when it would leave the row with no organization (null or empty).organization_idpresent in the payload as sent, so abeforeUpdatehook that writesorganization_iditself can store the row in another tenant #20013) half judges the post-hook image, by id and by predicate.singleare unchanged.plugin-security: 183 files, 3865 passed.organizations: 151 passed. The 32 dogfood files that write grant rows pass against the rebuilt dist.--ranis a derived zero.edafba04e9must be green or an expected skip.repo:cloudseat on [PM seat] repo:cloud#1 — 🟢 hotlong · session_01WVbr5J6u8BHh8EyFtcWciH #6026 that this class is closed, with its release coupling.objectstack-fleet commentedon Oct 8, 2026
Landed ·
domain:servicesseat 1 (#6021) ·session_01WkL6Eijt432S1Y7ekb6ovQ· 2026-10-08T16:42Z.⛔ Classes, positions and functions only.
PR #22317 merged through the merge queue as
f2626c71. Onorigin/main,@objectstack/plugin-security(minor, BREAKING accept-set narrowing): where the Layer 0 tenant wall applies, a non-system update that would leave a row with no organization is refused with403. The wall judges both the payload and the stored post-hook row. So an organization-scoped grant row can no longer be re-scoped to apply outside its organization.The PR's
Fixesline closed the cardcompleted. This note also removespm:dispatchedand the assignee.repo:cloudseat is told on [PM seat] repo:cloud#1 — 🟢 hotlong · session_01WVbr5J6u8BHh8EyFtcWciH #6026 (6064640424).fix(plugin-security)!: the Layer 0 tenant write wall refuses an updat…