Skip to content

Commit d34d9c9

Browse files
os-zhuangclaude
andauthored
fix(plugin-sharing): a manage_sharing holder with no active organization no longer reads every tenant's sharing rules (#8158) (#8237)
* fix(plugin-sharing): scope sharing-rule administration to the caller'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 * docs(sharing): name the missing-organization refusal on both sharing 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e5bd2f6 commit d34d9c9

6 files changed

Lines changed: 791 additions & 25 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
"@objectstack/plugin-sharing": patch
3+
---
4+
5+
security(plugin-sharing): a `manage_sharing` holder with no ACTIVE organization no longer reads every tenant's sharing rules (#8158)
6+
7+
`SharingRuleService` decided its admin read scope on the **absence of an
8+
organization id**, not on system-ness:
9+
10+
```ts
11+
if (!orgId) return where; // unscoped — every tenant's rows
12+
```
13+
14+
That unfiltered branch exists for the system context — boot seeding, the
15+
reconcile hooks, the backfills — which legitimately reads across tenants. But
16+
it was reached by any caller whose context happened to carry no organization,
17+
and the ADR-0111 D6 gate admits any caller holding the **org-scoped**
18+
`manage_sharing` capability. So an authenticated, non-system caller arriving
19+
with neither `organizationId` nor `tenantId` received the system read scope:
20+
`listRules` returned **every organization's** rules, `getRule` resolved any of
21+
them by id or by name, and `evaluateRule` reached those rows too — a
22+
cross-tenant **write**, since it reconciles `sys_record_share` grants.
23+
24+
**That session is reachable in a real deployment**, measured end to end over
25+
HTTP rather than inferred: a permission-set grant is independent of
26+
organization membership, and an org-scoped grant still resolves when the caller
27+
has no active organization to compare it against. A user holding
28+
`manage_sharing` with no `sys_member` row — a multi-organization deployment
29+
(whose membership reconciler binds nobody), an `invite-only` deployment, a user
30+
removed from their organization, an SSO JIT user pending placement — signs in,
31+
carries the capability, and carries no tenant.
32+
33+
**The fix** distinguishes "system context" from "no organization id" at the
34+
decision point instead of conflating them: `adminOrgScope`, `getRule` and
35+
`findRuleRowByName` (three sites, one shape) now take the execution context,
36+
and an authenticated caller with no resolvable organization is **refused** with
37+
`PERMISSION_DENIED` (HTTP 403) naming the missing organization. A refusal
38+
rather than an empty list, because `manage_sharing` is declared `scope: 'org'`:
39+
with no organization there is no scope in which it grants anything, and an
40+
empty answer over rules that exist and are actively granting access reads as
41+
"this deployment has no sharing rules".
42+
43+
**Unchanged**, and covered by tests: system contexts keep the unfiltered read
44+
and the unfiltered seed (boot seeding is untouched); **platform operators**
45+
(`manage_platform_settings`, or the `platform_admin` position) keep it too,
46+
with or without an active organization — that is what the platform-only Setup
47+
sharing pages are, and a single-tenant deployment before its default
48+
organization is bootstrapped has exactly that caller; and an org-bound admin
49+
still sees its own organization's rules plus the platform-global ones, exactly
50+
as #7676 / #7761 left it.

content/docs/kernel/runtime-services/sharing-service.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ mask AND-ed with object CRUD, not a fourth `access_level`.
5757

5858
- `FORBIDDEN` (403) — a write denied by the `canEdit` gate. Thrown by the sharing engine middleware; `canEdit` itself returns `false` rather than throwing.
5959
- `VALIDATION_FAILED` (400) — `grant`/`revoke` called without a required field (`object`, `recordId`, `recipientId`, or `shareId`), or `grant` with a non-`user` `recipientType` (only `user` rows are enforced by the gates; group/position recipients are delivered via sharing rules).
60-
- `PERMISSION_DENIED` (403) — the caller does not hold `canManageShares` on the record (ADR-0111 D1).
60+
- `PERMISSION_DENIED` (403) — the caller does not hold `canManageShares` on the record (ADR-0111 D1). On the sharing-**rule** surface (`ISharingRuleService`, declared in the same canonical source — `listRules` / `getRule` / `defineRule` / `deleteRule` / `evaluateRule`) the same code carries a second condition: the caller holds `manage_sharing` but their session resolves **no active organization**, and an org-scoped capability with no organization has no tenant whose rules it authorizes. System contexts and platform operators (`manage_platform_settings`, or the `platform_admin` position) are unaffected — see [Rule administration](/docs/permissions/sharing-rules).
6161
- `NOT_FOUND` (404) — the record is missing **or not visible to the caller** (indistinguishable by design), or a `revoke` share id does not exist / does not belong to the `scope` record.
6262
- `CONFLICT` (409) — `revoke` on a rule-materialised share (`source != 'manual'`); the next rule reconciliation would silently re-grant it. Deactivate or edit the sharing rule instead.
6363
- `SHARING_NOT_ENABLED` (422) — `grant` on an object the sharing gates never consult (public sharing model, no `owner_id` field, a bypass object, `controlled_by_parent`, or a **federated** object whose `owner_id` is the platform's injected anchor rather than a real remote column — the platform provisions no storage for a federated object, so the gates read that column off a table that has not got it and can never admit).

content/docs/permissions/sharing-rules.mdx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,22 @@ covered; an unauthorized call fails with `403 PERMISSION_DENIED`. Boot
161161
seeding, lifecycle hooks, and backfills run as system context and are
162162
unaffected.
163163

164+
**and an organization to be scoped by.** `manage_sharing` is declared
165+
`scope: 'org'`, so the capability alone is not enough: the caller's session
166+
must also resolve an **active organization**, which is what scopes every rule
167+
read to "this organization ∪ the platform-global rows". A session that carries
168+
nonea user who has not selected an organization, or whose active
169+
organization was clearedis refused with the same `403 PERMISSION_DENIED`,
170+
naming the missing organization rather than answering with an empty list.
171+
Answering unscoped would hand that caller **every** organization's rules, and
172+
`evaluate` would reconcile grants across all of them (objectstack#8158).
173+
Two callers are deliberately unaffected, because neither is an org-scoped
174+
principal: **system** contexts, and **platform operators**a holder of
175+
`manage_platform_settings` or of the built-in `platform_admin` position
176+
administers rules across the deployment whether or not an organization is
177+
selected, which is also what a single-tenant deployment looks like before its
178+
default organization is bootstrapped (ADR-0081 D1).
179+
164180
### Switching a rule off withdraws the access it granted
165181

166182
A sharing rule's grants are **materialized** — evaluating a rule writes real

0 commit comments

Comments
 (0)