Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .changeset/sharing-rule-org-less-caller-scope.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
---
"@objectstack/plugin-sharing": patch
---

security(plugin-sharing): a `manage_sharing` holder with no ACTIVE organization no longer reads every tenant's sharing rules (#8158)

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

```ts
if (!orgId) return where; // unscoped — every tenant's rows
```

That unfiltered branch exists for the system context — boot seeding, the
reconcile hooks, the backfills — which legitimately reads across tenants. But
it was reached by any caller whose context happened to carry no organization,
and the ADR-0111 D6 gate admits any caller holding the **org-scoped**
`manage_sharing` capability. So an authenticated, non-system caller arriving
with neither `organizationId` nor `tenantId` received the system read scope:
`listRules` returned **every organization's** rules, `getRule` resolved any of
them by id or by name, and `evaluateRule` reached those rows too — a
cross-tenant **write**, since it reconciles `sys_record_share` grants.

**That session is reachable in a real deployment**, measured end to end over
HTTP rather than inferred: a permission-set grant is independent of
organization membership, and an org-scoped grant still resolves when the caller
has no active organization to compare it against. A user holding
`manage_sharing` with no `sys_member` row — a multi-organization deployment
(whose membership reconciler binds nobody), an `invite-only` deployment, a user
removed from their organization, an SSO JIT user pending placement — signs in,
carries the capability, and carries no tenant.

**The fix** distinguishes "system context" from "no organization id" at the
decision point instead of conflating them: `adminOrgScope`, `getRule` and
`findRuleRowByName` (three sites, one shape) now take the execution context,
and an authenticated caller with no resolvable organization is **refused** with
`PERMISSION_DENIED` (HTTP 403) naming the missing organization. A refusal
rather than an empty list, because `manage_sharing` is declared `scope: 'org'`:
with no organization there is no scope in which it grants anything, and an
empty answer over rules that exist and are actively granting access reads as
"this deployment has no sharing rules".

**Unchanged**, and covered by tests: system contexts keep the unfiltered read
and the unfiltered seed (boot seeding is untouched); **platform operators**
(`manage_platform_settings`, or the `platform_admin` position) keep it too,
with or without an active organization — that is what the platform-only Setup
sharing pages are, and a single-tenant deployment before its default
organization is bootstrapped has exactly that caller; and an org-bound admin
still sees its own organization's rules plus the platform-global ones, exactly
as #7676 / #7761 left it.
4 changes: 2 additions & 2 deletions content/docs/kernel/runtime-services/sharing-service.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,10 +57,10 @@ mask AND-ed with object CRUD, not a fourth `access_level`.

- `FORBIDDEN` (403) — a write denied by the `canEdit` gate. Thrown by the sharing engine middleware; `canEdit` itself returns `false` rather than throwing.
- `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).
- `PERMISSION_DENIED` (403) — the caller does not hold `canManageShares` on the record (ADR-0111 D1).
- `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).
- `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.
- `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.
- `SHARING_NOT_ENABLED` (422) — `grant` on an object the sharing gates never consult (public sharing model, no `owner_id` field, a bypass object, or `controlled_by_parent`).
- `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).

## Enforcement is automatic — do not re-check it in a hook

Expand Down
16 changes: 16 additions & 0 deletions content/docs/permissions/sharing-rules.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -161,6 +161,22 @@ covered; an unauthorized call fails with `403 PERMISSION_DENIED`. Boot
seeding, lifecycle hooks, and backfills run as system context and are
unaffected.

**…and an organization to be scoped by.** `manage_sharing` is declared
`scope: 'org'`, so the capability alone is not enough: the caller's session
must also resolve an **active organization**, which is what scopes every rule
read to "this organization ∪ the platform-global rows". A session that carries
none — a user who has not selected an organization, or whose active
organization was cleared — is refused with the same `403 PERMISSION_DENIED`,
naming the missing organization rather than answering with an empty list.
Answering unscoped would hand that caller **every** organization's rules, and
`evaluate` would reconcile grants across all of them (objectstack#8158).
Two callers are deliberately unaffected, because neither is an org-scoped
principal: **system** contexts, and **platform operators** — a holder of
`manage_platform_settings` or of the built-in `platform_admin` position
administers rules across the deployment whether or not an organization is
selected, which is also what a single-tenant deployment looks like before its
default organization is bootstrapped (ADR-0081 D1).

### Switching a rule off withdraws the access it granted

A sharing rule's grants are **materialized** — evaluating a rule writes real
Expand Down
Loading
Loading