Skip to content

plugin-sharing: getRule's by-id branch has no org filter — an org admin can read, evaluate and DELETE another organization's sharing rule (and its grants) #7761

Description

@os-help

Symptom

SharingRuleService.getRule resolves an id without any organization filter, so an org-scoped sharing admin holding another organization's rule id can read, evaluate and delete that organization's rule.

packages/plugins/plugin-sharing/src/sharing-rule-service.ts, getRule:

const byId = await this.engine.find('sys_sharing_rule', {
  where: { id: idOrName },   // ← no organization_id predicate
  limit: 1,
  context: SYSTEM_CTX,
});
if (Array.isArray(byId) && byId[0]) return rowFromRule(byId[0]);

The read runs under SYSTEM_CTX, so nothing downstream re-scopes it either. Three verbs inherit the hole, because all three resolve through getRule:

  • GET /api/v1/sharing/rules/:id — read another org's rule (criteria, recipient, access level).
  • POST /api/v1/sharing/rules/:id/evaluate — materialise that org's grants on demand.
  • DELETE /api/v1/sharing/rules/:iddeleteRule calls getRule and then purges the row and every sys_record_share grant it materialised, i.e. silently revoke another tenant's record access.

The by-name path is correctly scoped ({name, organization_id: orgId}), so this is specifically the id branch. The caller still needs manage_sharing (or the legacy manage_platform_settings) in their own org, and needs to know an opaque srule_… id — but neither is a tenant boundary, and ids leak through logs, exports, support tickets and evaluation responses ({ruleId} is returned by the evaluate endpoint).

Not a regression from #7676

Found while auditing callers for #7676 / PR #7760, which widens the two org-scoped reads (listRules, getRule's by-name fallback) from organization_id = orgId to organization_id = orgId OR organization_id IS NULL. That change deliberately does not touch this branch and does not make it worse: it never admits another organization's rows. This defect is present on origin/main today and predates it.

Worth noting the interaction, though: #7760 makes seeded rules listable, and a rule's id is in the listing. That is intended (they are platform-global), but it does mean rule ids become easier to come by generally, which raises the value of closing this.

Suggested direction

Scope the by-id branch the same way the by-name path is scoped once #7760 lands — id = idOrName AND (organization_id = orgId OR organization_id IS NULL) when the caller carries an organization, unfiltered when it does not (system/boot contexts must keep working). Points to check before implementing:

  1. Whether any existing caller depends on by-id reaching an arbitrary org's row. From the record-share-grant-revoke: package-seeded sharing rules are unaddressable by name (org filter excludes org_id-null rows) #7676 audit, every non-REST caller passes a system context (no org id), so they take the unfiltered branch regardless.
  2. Whether a platform admin (as opposed to an org admin) should retain cross-org by-id access, and if so which capability expresses that — manage_sharing is an org-level capability and should not.
  3. deleteRule and evaluateRule inherit whatever getRule decides, so no separate change is needed for them, but both want a test.

Tests should pin both directions: another org's rule is unreachable by id (404 rather than 200/204), and a platform-global (organization_id = null) rule stays reachable by id.

Source

Found during the mandatory caller/tenant-isolation audit for #7676 (PR #7760). Filed unassigned.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions