Skip to content

Commit fa341b1

Browse files
os-helpclaude
andauthored
fix(plugin-sharing): scope getRule's by-id branch to the caller's organization (#7797)
* fix(plugin-sharing): scope getRule's by-id branch to the caller's organization `SharingRuleService.getRule` resolved an id with a bare `{id: idOrName}` predicate under SYSTEM_CTX, so nothing re-scoped it downstream. An org-scoped sharing admin holding another organization's `srule_...` id could read that org's rule, evaluate it, and — because `deleteRule` resolves through `getRule` — delete it together with every `sys_record_share` grant it had materialised, silently revoking another tenant's record access. The by-id lookup now carries the same `adminOrgScope` predicate #7760 gave the by-name path: `id = {id} AND (organization_id = {orgId} OR organization_id IS NULL)` when the caller carries an organization, and unfiltered when it does not, so system/boot contexts are unchanged. A platform-global (organization_id = null) row stays reachable by id. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVc1ekPpi6yaWywAUhfzfd * test(plugin-sharing): pin by-id tenant isolation for getRule/evaluateRule/deleteRule Adds the [#7761] describe block: another organization's rule is unreachable by id across all three verbs — and the delete pin asserts the victim's `sys_record_share` grants survive, not just its rule row, because grant purging is the actual harm. Platform-global (organization_id = null) rows, the caller's own rows, and no-org boot contexts are pinned as unchanged. Also adds the changeset (patch, @objectstack/plugin-sharing). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVc1ekPpi6yaWywAUhfzfd --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 098b629 commit fa341b1

3 files changed

Lines changed: 202 additions & 1 deletion

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/plugin-sharing": patch
3+
---
4+
5+
fix(plugin-sharing): scope `getRule`'s by-id branch to the caller's organization (#7761)
6+
7+
**Cross-tenant security fix.** `SharingRuleService.getRule` resolved a rule id
8+
with a bare `{id: idOrName}` predicate and no organization filter, executed
9+
under the service's `SYSTEM_CTX` so nothing re-scoped it downstream. An
10+
org-scoped sharing admin who held another organization's opaque `srule_…` id
11+
could therefore reach that organization's rule through all three verbs that
12+
resolve through `getRule`:
13+
14+
- `GET /api/v1/sharing/rules/:id` — read another tenant's rule, including its
15+
criteria, recipient and access level;
16+
- `POST /api/v1/sharing/rules/:id/evaluate` — materialise that tenant's grants
17+
on demand;
18+
- `DELETE /api/v1/sharing/rules/:id` — delete the rule **and purge every
19+
`sys_record_share` grant it had materialised**, silently revoking another
20+
tenant's record access.
21+
22+
The caller still needed `manage_sharing` (or the legacy
23+
`manage_platform_settings`) in their own organization, but that is an
24+
org-scoped capability — `scope: 'org'` in the spec's capability registry — and
25+
a rule id is not a tenant boundary: ids leak through logs, exports, support
26+
tickets, and the evaluate endpoint's own `{ruleId}` response.
27+
28+
The by-id lookup now carries the same tenant predicate the by-name path has
29+
carried since #7676: `id = {id} AND (organization_id = {orgId} OR
30+
organization_id IS NULL)` when the caller carries an organization. Two
31+
behaviours are deliberately preserved: a no-org (system / boot) context still
32+
resolves any row by id, so boot seeding, hooks and backfills are unaffected;
33+
and a platform-global (`organization_id = null`) row stays reachable by id, for
34+
symmetry with the by-name path.
35+
36+
Reaching another organization's rule by id is now indistinguishable from
37+
addressing one that does not exist — `getRule` answers `null` (REST: 404),
38+
`evaluateRule` throws `RULE_NOT_FOUND`, and `deleteRule` is a no-op that leaves
39+
the row and its grants intact.

packages/plugins/plugin-sharing/src/sharing-rule-service.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,12 @@ export class SharingRuleService implements ISharingRuleService {
250250
* a cross-tenant read of a platform-global row. A same-named POST therefore
251251
* still creates an org-stamped row of the tenant's own, and
252252
* {@link findRuleRowByName} prefers it.
253+
*
254+
* [#7761] It IS applied to {@link getRule}'s by-**id** branch, which the
255+
* second paragraph above records as the one read that still worked while
256+
* everything else was over-scoped. That was never a feature: unfiltered
257+
* meant an org admin could resolve — and, through {@link deleteRule},
258+
* destroy — another organization's rule from its id alone.
253259
*/
254260
private adminOrgScope(where: Record<string, unknown>, orgId: string | null | undefined): Record<string, unknown> {
255261
if (!orgId) return where;
@@ -280,8 +286,20 @@ export class SharingRuleService implements ISharingRuleService {
280286
if (!idOrName) return null;
281287
// `organizationId` is not on the envelope — see defineRule().
282288
const orgId = (context as any)?.organizationId ?? context?.tenantId;
289+
// [#7761] The by-id branch carries the SAME tenant scope as the by-name
290+
// path — it used to be a bare `{id: idOrName}`, resolved under SYSTEM_CTX
291+
// so nothing downstream re-scoped it. An org-scoped sharing admin holding
292+
// another organization's opaque `srule_…` id could therefore read that
293+
// org's rule, `evaluate` it, and — because {@link deleteRule} resolves
294+
// through here — DELETE it along with every `sys_record_share` grant it
295+
// had materialised, i.e. silently revoke another tenant's record access.
296+
// `manage_sharing` is an org-level capability (`scope: 'org'` in the spec's
297+
// capability registry) and an id is not a tenant boundary: ids leak through
298+
// logs, exports, support tickets and the evaluate response's `{ruleId}`.
299+
// A platform-global (`organization_id = null`) row stays reachable, for
300+
// symmetry with the by-name path — see {@link adminOrgScope}.
283301
const byId = await this.engine.find('sys_sharing_rule', {
284-
where: { id: idOrName },
302+
where: this.adminOrgScope({ id: idOrName }, orgId),
285303
limit: 1,
286304
context: SYSTEM_CTX,
287305
});

packages/plugins/plugin-sharing/src/sharing-rule.test.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1019,3 +1019,147 @@ describe('[#7676] admin visibility of package-seeded (org-null) sharing rules',
10191019
expect(resolved?.organization_id).toBe('org1');
10201020
});
10211021
});
1022+
1023+
// ─────────────────────────────────────────────────────────────────────
1024+
// [#7761] `getRule`'s BY-ID branch is scoped to the caller's organization.
1025+
//
1026+
// The by-name path has been scoped since #7676; the by-id branch was a bare
1027+
// `{id: idOrName}` resolved under SYSTEM_CTX, so nothing re-scoped it
1028+
// downstream. An org-scoped sharing admin holding another organization's
1029+
// opaque `srule_…` id could read that org's rule, `evaluate` it, and — because
1030+
// `deleteRule` resolves through `getRule` — DELETE it along with every
1031+
// `sys_record_share` grant it had materialised: silently revoking another
1032+
// tenant's record access. `manage_sharing` is an org-level capability
1033+
// (`scope: 'org'`) and an id is not a tenant boundary — ids leak through logs,
1034+
// exports, support tickets and the evaluate response's `{ruleId}`.
1035+
//
1036+
// The delete assertions pin the ROW **and its grants**, because grant purging
1037+
// is the actual harm: a fix that kept the row but still purged the grants
1038+
// would satisfy a row-only assertion while leaving the damage intact.
1039+
//
1040+
// Ablation (predicted in advance): restore `where: {id: idOrName}` and the
1041+
// three "unreachable by id" tests flip red, while the platform-global,
1042+
// own-org and BOOT pins below stay green.
1043+
// ─────────────────────────────────────────────────────────────────────
1044+
1045+
describe('[#7761] getRule by-id is scoped to the caller organization', () => {
1046+
let engine: ReturnType<typeof makeEngine>;
1047+
let rules: SharingRuleService;
1048+
1049+
/** Authenticated org-scoped sharing admins — the shape the REST layer builds. */
1050+
const ORG1_ADMIN = { userId: 'admin', organizationId: 'org1', systemPermissions: ['manage_sharing'] } as any;
1051+
const ORG2_ADMIN = { userId: 'other', organizationId: 'org2', systemPermissions: ['manage_sharing'] } as any;
1052+
/**
1053+
* The seeder's / boot context — carries NO organization, which is what
1054+
* stamps `organization_id: null`. Deliberately not this file's older `SYS`
1055+
* (`{isSystem: true, organizationId: 'org1'}`): `isSystem` bypasses the
1056+
* ADR-0111 D6 capability gate, never the org scope, so `SYS` would silently
1057+
* defeat every fixture below.
1058+
*/
1059+
const BOOT = { isSystem: true, positions: [], permissions: [] } as any;
1060+
1061+
const SEEDED = 'share_red_projects_with_execs';
1062+
let seededId = '';
1063+
let otherOrgRuleId = '';
1064+
let org1RuleId = '';
1065+
1066+
/** The `sys_record_share` rows a given rule materialised. */
1067+
const grantsOf = (ruleId: string): Row[] =>
1068+
(engine._tables.sys_record_share ?? []).filter((g) => g.source === 'rule' && g.source_id === ruleId);
1069+
1070+
beforeEach(async () => {
1071+
engine = makeEngine();
1072+
engine._tables.project = [
1073+
{ id: 'p_red', status: 'red', owner_id: 'someone' },
1074+
{ id: 'p_green', status: 'green', owner_id: 'someone' },
1075+
];
1076+
rules = new SharingRuleService({ engine: engine as any, sharing: new SharingService({ engine: engine as any }) });
1077+
1078+
// Platform-global package seed — defined with no org, as the boot seeder does.
1079+
seededId = (await rules.defineRule({
1080+
name: SEEDED, label: 'Red projects → execs', object: 'project',
1081+
criteria: { status: 'red' }, recipientType: 'user', recipientId: 'exec',
1082+
managedBy: 'package',
1083+
} as any, BOOT)).id;
1084+
// The victim: a rule owned by a DIFFERENT organization.
1085+
otherOrgRuleId = (await rules.defineRule({
1086+
name: 'other_org_rule', label: 'Other org', object: 'project',
1087+
criteria: { status: 'green' }, recipientType: 'user', recipientId: 'mallory',
1088+
} as any, ORG2_ADMIN)).id;
1089+
// The caller's own rule — the positive half.
1090+
org1RuleId = (await rules.defineRule({
1091+
name: 'org1_rule', label: 'Org1 own', object: 'project',
1092+
criteria: { status: 'red' }, recipientType: 'user', recipientId: 'alice',
1093+
} as any, ORG1_ADMIN)).id;
1094+
1095+
// Materialise org2's grants under BOOT, so the fixture does not depend on
1096+
// the very scoping decision these tests are measuring.
1097+
await rules.evaluateRule(otherOrgRuleId, BOOT);
1098+
});
1099+
1100+
it('the fixture really does have three distinct rows and live grants on the victim', () => {
1101+
const rows = engine._tables.sys_sharing_rule;
1102+
expect(rows.find((r) => r.id === seededId)?.organization_id).toBeNull();
1103+
expect(rows.find((r) => r.id === otherOrgRuleId)?.organization_id).toBe('org2');
1104+
expect(rows.find((r) => r.id === org1RuleId)?.organization_id).toBe('org1');
1105+
expect(new Set([seededId, otherOrgRuleId, org1RuleId]).size).toBe(3);
1106+
// Without this, the delete pin below could "pass" over a rule that never
1107+
// had any grants to lose.
1108+
expect(grantsOf(otherOrgRuleId)).toHaveLength(1);
1109+
});
1110+
1111+
// ── the defect: another organization's row, addressed by id ──────────
1112+
1113+
it('another organization’s rule is unreachable BY ID — read', async () => {
1114+
expect(await rules.getRule(otherOrgRuleId, ORG1_ADMIN)).toBeNull();
1115+
});
1116+
1117+
it('another organization’s rule is unreachable BY ID — evaluate', async () => {
1118+
await expect(rules.evaluateRule(otherOrgRuleId, ORG1_ADMIN)).rejects.toThrow(/RULE_NOT_FOUND/);
1119+
// Evaluate is a WRITE (it reconciles grants), so the refusal has to leave
1120+
// the victim's grants exactly as they were, not merely return an error.
1121+
expect(grantsOf(otherOrgRuleId)).toHaveLength(1);
1122+
});
1123+
1124+
it('another organization’s rule is unreachable BY ID — delete leaves the row AND its grants intact', async () => {
1125+
await rules.deleteRule(otherOrgRuleId, ORG1_ADMIN);
1126+
expect(engine._tables.sys_sharing_rule.find((r) => r.id === otherOrgRuleId)).toBeTruthy();
1127+
// The harm in this defect is grant purging — `deleteRule` revokes every
1128+
// `sys_record_share` row the rule materialised — so the grants are the
1129+
// assertion that matters, not just the surviving rule row.
1130+
expect(grantsOf(otherOrgRuleId)).toHaveLength(1);
1131+
});
1132+
1133+
// ── platform-global stays reachable (what #7760 enabled by name) ─────
1134+
1135+
it('a platform-global (organization_id = null) rule stays reachable BY ID — read', async () => {
1136+
const row = await rules.getRule(seededId, ORG1_ADMIN);
1137+
expect(row?.name).toBe(SEEDED);
1138+
expect(row?.organization_id).toBeNull();
1139+
});
1140+
1141+
it('a platform-global rule stays reachable BY ID — evaluate', async () => {
1142+
const res = await rules.evaluateRule(seededId, ORG1_ADMIN);
1143+
expect(res.ruleId).toBe(seededId);
1144+
expect(res.matchedRecords).toBe(1);
1145+
expect(res.grantsCreated).toBe(1);
1146+
});
1147+
1148+
// ── unchanged behaviour ──────────────────────────────────────────────
1149+
1150+
it('the caller’s OWN org rule is still fully addressable by id', async () => {
1151+
expect((await rules.getRule(org1RuleId, ORG1_ADMIN))?.organization_id).toBe('org1');
1152+
expect((await rules.evaluateRule(org1RuleId, ORG1_ADMIN)).ruleId).toBe(org1RuleId);
1153+
// Delete works on the caller's own row — the control proving the refusal
1154+
// above comes from the org scope and not from a delete path that stopped
1155+
// working for everyone.
1156+
await rules.deleteRule(org1RuleId, ORG1_ADMIN);
1157+
expect(engine._tables.sys_sharing_rule.find((r) => r.id === org1RuleId)).toBeUndefined();
1158+
});
1159+
1160+
it('a no-org (SYSTEM_CTX / boot) context still resolves ANY row by id', async () => {
1161+
expect((await rules.getRule(otherOrgRuleId, BOOT))?.organization_id).toBe('org2');
1162+
expect((await rules.getRule(seededId, BOOT))?.organization_id).toBeNull();
1163+
expect((await rules.getRule(org1RuleId, BOOT))?.organization_id).toBe('org1');
1164+
});
1165+
});

0 commit comments

Comments
 (0)