You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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.
constbyId=awaitthis.engine.find('sys_sharing_rule',{where: {id: idOrName},// ← no organization_id predicatelimit: 1,context: SYSTEM_CTX,});if(Array.isArray(byId)&&byId[0])returnrowFromRule(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/:id — deleteRule 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).
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:
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.
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.
Symptom
SharingRuleService.getRuleresolves 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:The read runs under
SYSTEM_CTX, so nothing downstream re-scopes it either. Three verbs inherit the hole, because all three resolve throughgetRule: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/:id—deleteRulecallsgetRuleand then purges the row and everysys_record_sharegrant 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 needsmanage_sharing(or the legacymanage_platform_settings) in their own org, and needs to know an opaquesrule_…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) fromorganization_id = orgIdtoorganization_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 onorigin/maintoday and predates it.Worth noting the interaction, though: #7760 makes seeded rules listable, and a rule's
idis 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:manage_sharingis an org-level capability and should not.deleteRuleandevaluateRuleinherit whatevergetRuledecides, 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.