Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-sharing): a manage_sharing holder with no active organization no longer reads every tenant's sharing rules (#8158) - #8237
Conversation
…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
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 7 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
…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
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8158
SharingRuleServicedecided its admin read scope on the absence of an organization id, not on system-ness: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-scoped
manage_sharingcapability. Three sites shared the shape —adminOrgScope,getRuleandfindRuleRowByName— so an authenticated, non-system caller with neitherorganizationIdnortenantIdread every organization's rules, resolved any of them by id or by name, and couldevaluatethem, which reconcilessys_record_sharegrants: 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_sharingholder a session with noactiveOrganizationIdis not measured here"). It is measured now, over HTTP, inpackages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts:better-auth
sign-up+sign-inmint the session; ADR-0081 D1'ssession.create.beforehook is the thing that would stamp an active organization and declines, because the user holds nosys_memberrow (the test asserts the storedsys_sessionrow carries none);the user holds an org-scoped
manage_sharinggrant anyway — asys_user_permission_setrow is independent of membership, andresolveUserAuthzGrantskeeps an org-scoped grant when there is no active org to compare it against;GET /api/v1/sharing/rulesas that session, on a fixture with two organizations, answered HTTP 200 on the pre-fix build with: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), aninvite-onlydeployment, 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
assertCanManageRulesrefuses 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_sharingis declaredscope: '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_settingsor theplatform_adminposition), 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 thetenantIdspelling (whatresolveAuthzContextactually 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):
content/docs/permissions/sharing-rules.mdx— the ADR-0111 D6 "Rule administration" section, which is where the rule surface's403 PERMISSION_DENIEDalready lives: the capability alone is not enough, an active organization is required, and system contexts + platform operators are deliberately unaffected.content/docs/kernel/runtime-services/sharing-service.mdx— the "Typical Errors" list docs(sharing-service): name the federated phantom-anchor case in the SHARING_NOT_ENABLED list (#8119) #8217 corrected twenty minutes before this PR. The new condition rides on the existingPERMISSION_DENIEDbullet, explicitly scoped toISharingRuleService(declared in the same canonical source) and cross-linked to the page above. That page's version was taken from currentmainso docs(sharing-service): name the federated phantom-anchor case in the SHARING_NOT_ENABLED list (#8119) #8217's line is preserved.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