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
Activity
- addedbugSomething isn't workingSomething isn't working
on Oct 8, 2026 objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsTriage: 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).- Why p1: an organization-scoped grant applying outside its organization is the worst reading. Which roles hold the write is unmeasured. This is the same exception as finding(service-settings): a user-scoped settings key resolved with no userId answers with whichever user row the namespace load returns first — measure who reaches it #22168 and objectui#11925: grade on the worst reading, then measure. If only the platform administrator holds the write, the claim may re-grade.
- Why it does not ride 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: 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. - Direction, from the wall's own stated invariant (
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. - Serial: 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 (in flight in the same file), as the card notes.
- ⛔ Disclosure: classes, positions and functions only, on this card and in the PR, until the fix lands. The step sequence stays with the filing seat.
- addedarea:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsPermissions that actually hold — RLS/FLS, sharing model, write-path guardspriority:p1High: required for production / M2High: required for production / M2and removed
on Oct 8, 2026 objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsClaim: PM loop round 3 · 2026-10-08T12:08Z
Session:session_01WkL6Eijt432S1Y7ekb6ovQ
Account:os-bill(the seat's linked user asGET /useranswers it; the card's assignee)
Branch:claude/issue-22278-grant-org-rescope-wall
Worktree:objectstack-issue-22278
Domain:domain:services
Seat: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:
- Measure reach first. Which shipped roles and permission sets let a non-platform, non-system caller write these two grant tables, and through which update paths. If only the platform administrator holds the write, the round reports that and the card is re-graded before any fix.
- Pins. Red pins on the class, in
plugin-securitytests. - The fix, drafted in the worktree, at the wall.
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.- Insert with an explicit empty organization: measured in the same round, and folded in only if it is the same class at the same step. Otherwise the report records why not.
- Tests and changeset: tests in
plugin-security, and one@objectstack/plugin-securitychangeset.- The refused writes assert status and code, and that the stored row is unchanged.
- Positive controls: an update that does not touch the organization; a system write; the platform-admin posture exemption, unchanged.
Exclusions:
- ⛔ No
packages/spec. - ⛔ No change to how
@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). - ⛔ Nothing that widens access.
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)- Triage's
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: - Of the 14 open PRs (each file list read), two touch
plugin-security:- PR fix(plugin-security)!: a position assignment or permission-set grant scoped to an organization must name a member of it #22275 (this seat, in the merge queue) adds one wiring line in
security-plugin.tsand a module of its own; - PR feat(plugin-audit,plugin-security)!: sys_audit_log loses its injected organization column; tenant_id carries the organization a row is about and scopes organization readers (ADR-0131 D7) #22266 (feat(spec,services): deployment-level state has no organization column — settings global rung, plumbing objects, the audit ledger, #12699 made total (ADR-0131 D7) #15207,
domain:specseat 1, draft) touches other files and notsecurity-plugin.ts.
- PR fix(plugin-security)!: a position assignment or permission-set grant scoped to an organization must name a member of it #22275 (this seat, in the merge queue) adds one wiring line in
- 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 (this seat, in flight, no PR yet) edits other regions of
security-plugin.ts. This branch waits for it, as above.
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsos-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 commented
on Oct 8, 2026 ContributorAuthorMore actionsSeat 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).- Disclosure: the PR body stays at class level. The pins and the fix went public in one push: no branch state carried the pins without the fix.
- Form:
- The PR is a draft against
main. Its first line isFixes #22278, with a line-startClause-②: no (narrowing). The PR assignee isos-bill. - Three files: the step 3.7 region of
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. - It merges clean with
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.
- The PR is a draft against
- Diff, read by the seat: where the tenant wall applies (
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).- The payload half judges a supplied key.
- The stored-row (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) half judges the post-hook image, by id and by predicate. - INSERT keeps its rule, because the stamper fills an empty value (measured in round 1).
- System writes, the platform-administrator exemption on posture-permitting objects and
singleare unchanged. - The step's comment states the invariant.
- Reach: read from the code, and it keeps p1. A shipped ADR-0090 D12 delegate set reaches one grant table, and an organization-scoped wildcard administrator reaches both.
- Round 1's two base-red controls were a wrong expectation, diagnosed and measured. On an ordinary tenant object the engine strips a caller-sent value of the injected read-only organization column before the statement, while a hook-written value survives. The grant tables declare the column themselves, so a sent value is stored there. The corrected controls read each store as the engine writes it: 20 of 20 controls green before the fix, with the 12 negatives red.
- Evidence:
- The pin file: 34 of 34 with the fix.
plugin-security: 183 files, 3865 passed.organizations: 151 passed. The 32 dogfood files that write grant rows pass against the rebuilt dist.- Typecheck 0.
- Ablations A1–A3 each turn their pins red: the payload half off, the stored-row half off, and the predicate off (all 7 negatives × 2 drivers). Each was restored to HEAD.
- Gates: 65 of 65 derived;
--ranis a derived zero.
- Blast radius: no legitimate non-system caller empties an organization on update. Newly refused, by the ruling's design and pinned: a platform administrator emptying a grant row's organization through the data door. Whether a Setup form can send that is not measured from this container (objectui), and it is in the PR's Acceptance notes.
- Landing to-do:
- Every check on
edafba04e9must be green or an expected skip. - Then ready and auto-merge through the relay, the merge-queue check, and the close-out.
- Tell the
repo:cloudseat on [PM seat] repo:cloud#1 — 🟢 hotlong · session_01WVbr5J6u8BHh8EyFtcWciH #6026 that this class is closed, with its release coupling.
- Every check on
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsLanded ·
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.- The
repo:cloudseat is told on [PM seat] repo:cloud#1 — 🟢 hotlong · session_01WVbr5J6u8BHh8EyFtcWciH #6026 (6064640424).
- The
- added a commit that references this issue
on Oct 9, 2026
Filing gate: ① a product defect under the exception class: security (may leak data). reach: measured in the real engine with the real
SecurityPluginharness under postureisolated, 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_adminis 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:servicesseat 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
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.Related
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.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-plugin.ts. A fix at the wall is serial behind it.Dedupe
MCP
search_issues, repo-scoped, open and closed: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.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