Skip to content

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

@objectstack-fleet

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

Related

Dedupe

MCP search_issues, repo-scoped, open and closed:

Dedupe words: grant re-scoped outside its organization · write wall organization update · global grant by missing organization

Activity

  1. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade, priority:p1 · security · domain:services · area:access · pm:queue (finding removed). Graded on its worst reading under the may-leak-data exception; measure reach first. It stands alone

    Triage 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 in security-plugin.ts) ⇒ domain:services; rationale: plugin-security is that lane's (lanes/services.md:10).

  2. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 3 · 2026-10-08T12:08Z
    Session: session_01WkL6Eijt432S1Y7ekb6ovQ
    Account: os-bill (the seat's linked user as GET /user answers 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:

    1. 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.
    2. Pins. Red pins on the class, in plugin-security tests.
    3. 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 merges main, re-runs, and only then pushes. PR #22275 (#22226, in the merge queue) is merged in once it lands.

    File surface at origin/main fbcbcf12:

    Exclusions:

    Stop on breach; explain in the report.
    Container & model: M, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate; default tier, a p1 security card)
    Clause-②: no (narrowing)

  3. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    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

  4. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat ACCEPT: PR #22317 at edafba04e9 · seat domain: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-report 6063521243).

    • 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:
    • Diff, read by the seat: where the tenant wall applies (tenantParts non-empty), a non-system UPDATE is refused with the wall's PermissionDeniedError (403) when it would leave the row with no organization (null or empty).
    • 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; --ran is 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:
  5. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat 1 (#6021) · session_01WkL6Eijt432S1Y7ekb6ovQ · 2026-10-08T16:42Z.

    ⛔ Classes, positions and functions only.

    PR #22317 merged through the merge queue as f2626c71. On origin/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 with 403. 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 Fixes line closed the card completed. This note also removes pm:dispatched and the assignee.

  6. added a commit that references this issue on Oct 9, 2026
    f2626c7
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:servicespriority:p1High: required for production / M2security

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions