Skip to content

fix(plugin-sharing): a manage_sharing holder with no active organization no longer reads every tenant's sharing rules (#8158) - #8237

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-8158-admin-org-scope-fall-open
Aug 12, 2026
Merged

fix(plugin-sharing): a manage_sharing holder with no active organization no longer reads every tenant's sharing rules (#8158)#8237
os-zhuang merged 2 commits into
mainfrom
claude/issue-8158-admin-org-scope-fall-open

Conversation

@os-zhuang

@os-zhuangos-zhuang commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Fixes#8158

SharingRuleService decided its admin read scope on the absence of an organization id, not on system-ness:

if(!orgId)returnwhere;// unscoped — every tenant's rows

That unfiltered branch exists for the system context (boot seeding, the reconcile hooks, the backfills), but it was reached on capability rather than system-ness, and the ADR-0111 D6 gate admits any caller holding the org-scopedmanage_sharing capability. Three sites shared the shape — adminOrgScope, getRule and findRuleRowByName — so an authenticated, non-system caller with neither organizationId nor tenantId read every organization's rules, resolved any of them by id or by name, and could evaluate them, which reconciles sys_record_share grants: a cross-tenant write.

Step 1 — reachability, measured through the real login path

The card filed its own gap ("whether a real deployment can hand an authenticated manage_sharing holder a session with no activeOrganizationId is not measured here"). It is measured now, over HTTP, in packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts:

  • better-auth sign-up + sign-in mint the session; ADR-0081 D1's session.create.before hook is the thing that would stamp an active organization and declines, because the user holds no sys_member row (the test asserts the stored sys_session row carries none);

  • the user holds an org-scoped manage_sharing grant anyway — a sys_user_permission_set row is independent of membership, and resolveUserAuthzGrants keeps an org-scoped grant when there is no active org to compare it against;

  • GET /api/v1/sharing/rules as that session, on a fixture with two organizations, answered HTTP 200 on the pre-fix build with:

    ["rule_8158_tenant_a@org_8158_a", "rule_8158_tenant_b@org_8158_b",
    "share_high_value_red_projects_with_managers@null",
    "share_new_inquiries_with_field_ops@null",
    "share_open_tasks_with_manager@null",
    "share_red_projects_with_execs@null"]

    Both tenants' rules, to a caller whose only grant was scoped to tenant A. Reachability: yes.

The shape is ordinary, not contrived: a multi-organization deployment (whose membership reconciler binds nobody — ADR-0093 D1 no-target-org), an invite-only deployment, a user removed from their organization, or an SSO JIT user pending placement all produce it.

The fix

The three sites now take the execution context instead of a bare org id, so "system" and "no organization" can no longer be the same input, and assertCanManageRules refuses an authenticated caller with no resolvable organization: PERMISSION_DENIED, mapped to HTTP 403 by the route's existing error mapping.

Refuse rather than answer empty. Both are fail-closed; the difference is what the caller is told. An empty list is the #7676 shape — {data: []} over rules that exist and are actively granting access — which reads as "this deployment has no sharing rules". manage_sharing is declared scope: 'org': with no organization there is no scope in which it grants anything, so the honest answer is a refusal naming the missing organization.

Two classes keep the unfiltered read, deliberately — system contexts (isSystem), and platform operators (manage_platform_settings or the platform_admin position), whose cross-tenant read is what the platform-only Setup sharing pages are and who are exactly the caller a single-tenant deployment has before its default organization is bootstrapped. The card's own reading: "a platform operator hitting this path is harmless".

Tests

  • packages/plugins/plugin-sharing/src/sharing-rule.test.ts — a new [#8158] block: two organizations with a rule each plus the platform-global seed, so it cannot pass on a single-tenant fixture; refusals for list / get-by-id / get-by-name / evaluate / delete / define, each paired with the row it used to leak; the grant table asserted unchanged after the refused evaluate; and the permitted side pinned — system context still reads and still SEEDS, both platform-authority spellings still read unfiltered, each org-bound admin sees exactly its own organization plus the platform-global row, and the tenantId spelling (what resolveAuthzContext actually stamps) resolves.
  • packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts — the HTTP-level proof above.

Ablation (prediction written first, then measured): restoring the three if (!orgId) branches turned exactly the 8 predicted unit refusals and the 7 predicted dogfood refusals red, and left every system / platform / org-bound pin green.

Docs — the new refusal is enumerated where refusals are enumerated

A second commit documents it on both pages a reader would reach, following #8217's precedent (the runtime refusing more than the page says is the enforced-but-undocumented inverse of a declared-but-unenforced gap):

Related: #7795 (this card's origin), #7761, #7676, #5852. Adjacent, deliberately not unified here: #8208 is the same "authenticated session with no active organization" state falling closed in a different subsystem.


Generated by Claude Code

…s organization (#8158)
`SharingRuleService` took its unfiltered admin read branch on the ABSENCE of an
organization id rather than on system-ness, so an authenticated, non-system
caller holding the org-scoped `manage_sharing` capability with no active
organization read every tenant's sharing rules, resolved any of them by id or
name, and could evaluate them — a cross-tenant write, since evaluation
reconciles `sys_record_share` grants.
The three sites that shared the `if (!orgId)` shape (`adminOrgScope`,
`getRule`, `findRuleRowByName`) now take the execution context, and an
authenticated caller with no resolvable organization is refused with
PERMISSION_DENIED (403). System contexts (boot seeding, hooks, backfills) and
platform operators (`manage_platform_settings` / the `platform_admin`
position) keep the unfiltered read unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 10:04pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-sharing.

7 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/kernel/runtime-services/examples.mdx(via @objectstack/plugin-sharing)
  • content/docs/kernel/runtime-services/sharing-service.mdx(via @objectstack/plugin-sharing)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/plugin-sharing)
  • content/docs/permissions/authorization.mdx(via packages/plugins/plugin-sharing)
  • content/docs/permissions/permissions-matrix.mdx(via packages/plugins/plugin-sharing)
  • content/docs/plugins/packages.mdx(via @objectstack/plugin-sharing)
  • content/docs/protocol/objectql/security.mdx(via packages/plugins/plugin-sharing)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/plugin-sharing)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 12, 2026
…pages (#8158)
The "Typical Errors" list on `services.sharing` enumerates the exact conditions
behind each status, and the ADR-0111 D6 section of the sharing-rules page is
where a reader of the rule surface looks. This PR adds a refusal to that
surface — an authenticated `manage_sharing` holder whose session resolves no
active organization now gets 403 PERMISSION_DENIED on every verb — so both
pages say so, including which two callers (system contexts, platform
operators) are deliberately unaffected.
Same reasoning as #8217: the runtime refusing more than the page says is the
enforced-but-undocumented inverse of a declared-but-unenforced gap.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugin-sharing: a manage_sharing holder whose session has no ACTIVE organization reads every tenant's sharing rules (adminOrgScope falls open)

2 participants

@os-zhuang@claude