Skip to content

Commit 8669e5d

Browse files
os-helpclaude
andauthored
fix(plugin-sharing): make package-seeded (org-null) sharing rules visible and addressable by name (#7760)
Rules seeded from an app or package are defined under the system context, so they are stored with `organization_id = null` (platform-global). `listRules` and the by-name fallback of `getRule` scoped their reads with a strict `organization_id = <caller org>` equality, which such a row can never satisfy: an org-scoped admin saw `GET /api/v1/sharing/rules` answer `{"data":[]}` over a table of active seeded rules, and by-name GET/evaluate answered 404 `RULE_NOT_FOUND`. Only the by-id branch, which carries no org filter, worked. Enforcement was unaffected — the boot reconcile also reads under the system context — which is exactly why this stayed invisible. Both admin reads now match "this organization OR platform-global", the same predicate `sys_business_unit` approver expansion settled on in #3807 and the one `sys_metadata`'s pending-draft listing uses. Another organization's row still fails the match; only rows belonging to no organization become visible. `defineRule` is deliberately NOT widened. Its existence lookup decides upsert-vs-insert, so widening it would let one organization's admin rewrite a row every other organization reads — a cross-tenant WRITE, a different act from a cross-tenant read of a platform-global row. A same-named create therefore still produces a row stamped with the caller's own organization, and `findRuleRowByName` prefers that row over the platform-global one via two sequenced lookups rather than one `$or` with `limit: 1`, so the preference is a decision rather than whichever row a dialect happened to reach first. The test fake engine's filter matcher short-circuited on `$or` and DROPPED its sibling field keys, so `listRules`'s `{object_name, active, $or:[…]}` would have matched the whole table there while driver-sql and driver-memory conjoin the two. A fake looser than the contract it stands in for is how a green suite ships a broken filter, so it now conjoins. Fixes #7676 Claude-Session: https://claude.ai/code/session_01BVc1ekPpi6yaWywAUhfzfd Co-authored-by: Claude <noreply@anthropic.com>
1 parent 08cd163 commit 8669e5d

3 files changed

Lines changed: 246 additions & 10 deletions

File tree

.changeset/spotty-pans-visit.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/plugin-sharing': patch
3+
---
4+
5+
Make package-seeded sharing rules visible and addressable by name to org-scoped admins
6+
7+
Sharing rules seeded from an app or package are defined under the system context, so they are stored with `organization_id = null` (platform-global). `SharingRuleService.listRules` and the by-name fallback of `getRule` scoped their reads with a strict `organization_id = <caller org>` equality, which such a row can never satisfy. An authenticated org-scoped admin therefore saw `GET /api/v1/sharing/rules` return an empty list over a table of active seeded rules, and by-name `GET` and `evaluate` answered 404 `RULE_NOT_FOUND`; only the by-id branch, which was never org-scoped, still worked.
8+
9+
Both admin reads now match "this organization OR platform-global", mirroring how enforcement has always read these rows under the system context. Consequences worth knowing:
10+
11+
- Seeded rules now appear in the admin rule list and can be fetched, evaluated and deleted by name. An org admin could already do all three **by row id** — the by-id branch carries no org filter — so this adds an address form and discoverability, not a new authority. Deleting a package-seeded rule remains reversible: the next boot reseeds it.
12+
- Rules belonging to a **different** organization remain invisible and unresolvable by name; only rows belonging to no organization at all become visible.
13+
- `defineRule` is deliberately **not** widened. Its existence lookup decides upsert-vs-insert, so widening it would let one organization's admin rewrite a row every other organization reads. A same-named create still produces a row stamped with the caller's own organization, and by-name lookups prefer that row over the platform-global one.
14+
- Callers passing a context with no organization (boot seeding, rule hooks, backfills, the boot reconcile) are unaffected — that path was already unfiltered and is unchanged.

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

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,41 @@ export class SharingRuleService implements ISharingRuleService {
221221
return rowFromRule(newRow);
222222
}
223223

224+
/**
225+
* [#7676] Tenant scope for a sharing-rule ADMIN read: "this org ∪
226+
* platform-global".
227+
*
228+
* `organization_id = null` on `sys_sharing_rule` means "owned by no
229+
* organization" — a row written by the package/app seeder
230+
* (`bootstrapDeclaredSharingRules`, which defines under `SYSTEM_CTX` and so
231+
* stamps `organization_id: null`) before any org id exists. A strict
232+
* `organization_id = <request org>` equality made every such row invisible to
233+
* the admin API while enforcement kept reading them under `SYSTEM_CTX`: on a
234+
* stock boot `GET /api/v1/sharing/rules` answered `{data: []}` over four
235+
* active seeded rules, by-name GET and evaluate 404'd `RULE_NOT_FOUND`, and
236+
* only the org-unfiltered by-id branch of {@link getRule} still worked. Rules
237+
* that grant access but cannot be listed, inspected or deactivated are the
238+
* worst half of both properties.
239+
*
240+
* Widening the READ leaks nothing across tenants: another org's row still
241+
* fails the match, and a null-org row is platform-global by construction —
242+
* every org already receives the grants it materialises. This is the same
243+
* predicate, for the same reason, that `sys_business_unit` approver expansion
244+
* settled on in #3807 and that `sys_metadata`'s pending-draft listing uses.
245+
*
246+
* ⚠️ It is deliberately NOT applied to {@link defineRule}'s existence lookup.
247+
* That lookup decides UPSERT-or-insert, so widening it would let one org's
248+
* admin overwrite the label, criteria, recipient and access level of a row
249+
* every OTHER org reads — a cross-tenant WRITE, which is a different act from
250+
* a cross-tenant read of a platform-global row. A same-named POST therefore
251+
* still creates an org-stamped row of the tenant's own, and
252+
* {@link findRuleRowByName} prefers it.
253+
*/
254+
private adminOrgScope(where: Record<string, unknown>, orgId: string | null | undefined): Record<string, unknown> {
255+
if (!orgId) return where;
256+
return { ...where, $or: [{ organization_id: orgId }, { organization_id: null }] };
257+
}
258+
224259
async listRules(
225260
filter: { object?: string; activeOnly?: boolean },
226261
context: ExecutionContext,
@@ -231,9 +266,8 @@ export class SharingRuleService implements ISharingRuleService {
231266
if (filter.activeOnly) where.active = true;
232267
// `organizationId` is not on the envelope — see defineRule().
233268
const orgId = (context as any)?.organizationId ?? context?.tenantId;
234-
if (orgId) where.organization_id = orgId;
235269
const rows = await this.engine.find('sys_sharing_rule', {
236-
where,
270+
where: this.adminOrgScope(where, orgId),
237271
orderBy: [{ field: 'name', order: 'asc' }],
238272
limit: 1000,
239273
context: SYSTEM_CTX,
@@ -252,15 +286,34 @@ export class SharingRuleService implements ISharingRuleService {
252286
context: SYSTEM_CTX,
253287
});
254288
if (Array.isArray(byId) && byId[0]) return rowFromRule(byId[0]);
255-
const byName = await this.engine.find('sys_sharing_rule', {
256-
where: orgId ? { name: idOrName, organization_id: orgId } : { name: idOrName },
257-
limit: 1,
258-
context: SYSTEM_CTX,
259-
});
260-
if (Array.isArray(byName) && byName[0]) return rowFromRule(byName[0]);
289+
const byName = await this.findRuleRowByName(idOrName, orgId);
290+
if (byName) return rowFromRule(byName);
261291
return null;
262292
}
263293

294+
/**
295+
* [#7676] Resolve a rule by NAME for an admin read: this org first, the
296+
* platform-global (`organization_id IS NULL`) row second.
297+
*
298+
* Two sequenced lookups rather than one `$or` with `limit: 1`, because when
299+
* BOTH rows exist the answer must be the caller's own: a single disjunctive
300+
* query with a row cap picks whichever row the driver happened to reach
301+
* first, so an org that had authored its own `share_red_projects_with_execs`
302+
* could get the platform row back on one dialect and its own on another.
303+
* Preference is a decision, so it is written as one.
304+
*
305+
* No `orgId` (SYSTEM_CTX — boot seeding, hooks, backfills) keeps the
306+
* unfiltered by-name lookup it has always had.
307+
*/
308+
private async findRuleRowByName(name: string, orgId: string | null | undefined): Promise<any | null> {
309+
const first = async (where: Record<string, unknown>): Promise<any | null> => {
310+
const rows = await this.engine.find('sys_sharing_rule', { where, limit: 1, context: SYSTEM_CTX });
311+
return Array.isArray(rows) && rows[0] ? rows[0] : null;
312+
};
313+
if (!orgId) return first({ name });
314+
return (await first({ name, organization_id: orgId })) ?? (await first({ name, organization_id: null }));
315+
}
316+
264317
async deleteRule(idOrName: string, context: ExecutionContext): Promise<void> {
265318
this.assertCanManageRules(context); // [ADR-0111 D6]
266319
const row = await this.getRule(idOrName, context);

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

Lines changed: 171 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,16 @@ function makeEngine() {
2323
const ensure = (n: string) => (tables[n] ??= []);
2424
function matches(row: Row, f: any): boolean {
2525
if (!f || typeof f !== 'object') return true;
26-
if (Array.isArray(f.$or)) return f.$or.some((x: any) => matches(row, x));
27-
if (Array.isArray(f.$and)) return f.$and.every((x: any) => matches(row, x));
26+
// [#7676] A combinator is CONJOINED with its sibling field keys, never a
27+
// short-circuit that returns before they are read. This used to
28+
// `return f.$or.some(...)`, silently DROPPING every sibling — so
29+
// `listRules`'s `{object_name, active, $or:[…org scope…]}` would have
30+
// matched the whole table here while the real drivers (driver-sql's
31+
// `applyFilterCondition`, driver-memory's mingo document) AND the two.
32+
// A fake looser than the contract it stands in for is how a green suite
33+
// ships a broken filter (the #4434 lesson, applied to reads).
34+
if (Array.isArray(f.$or) && !f.$or.some((x: any) => matches(row, x))) return false;
35+
if (Array.isArray(f.$and) && !f.$and.every((x: any) => matches(row, x))) return false;
2836
for (const [k, v] of Object.entries(f)) {
2937
if (k === '$or' || k === '$and') continue;
3038
const rv = row[k];
@@ -850,3 +858,164 @@ describe('[ADR-0111 D6] sharing-rule management gate', () => {
850858
expect(r.id).toBeTruthy();
851859
});
852860
});
861+
862+
// ─────────────────────────────────────────────────────────────────────
863+
// [#7676] Package-seeded rules (`organization_id = null`) are visible and
864+
// addressable BY NAME to an org-scoped admin.
865+
//
866+
// The QA run's finding: on a stock boot `GET /api/v1/sharing/rules` answered
867+
// `{data: []}` over four active seeded rules, by-name GET/evaluate 404'd
868+
// `RULE_NOT_FOUND`, and only the org-unfiltered by-id branch still worked —
869+
// because the seeder defines under SYSTEM_CTX (`organization_id: null`) while
870+
// an authenticated admin's context carries `organizationId: 'org_…'`, and a
871+
// strict equality can never match. Enforcement was unaffected (boot reconcile
872+
// reads under SYSTEM_CTX, no org filter), which is exactly why it stayed
873+
// invisible: rules that grant access but cannot be listed or deactivated.
874+
// ─────────────────────────────────────────────────────────────────────
875+
876+
describe('[#7676] admin visibility of package-seeded (org-null) sharing rules', () => {
877+
let engine: ReturnType<typeof makeEngine>;
878+
let rules: SharingRuleService;
879+
880+
/** An authenticated org-scoped sharing admin — the shape the REST layer builds. */
881+
const ORG1_ADMIN = { userId: 'admin', organizationId: 'org1', systemPermissions: ['manage_sharing'] } as any;
882+
const SEEDED = 'share_red_projects_with_execs';
883+
/**
884+
* The seeder's own context — `bootstrapDeclaredSharingRules` defines under
885+
* plugin-sharing's `SYSTEM_CTX`, which carries NO organization, and that is
886+
* precisely what stamps `organization_id: null` on the row. Not this file's
887+
* older `SYS`, which is `{isSystem: true, organizationId: 'org1'}` — system
888+
* only bypasses the ADR-0111 D6 capability gate, never the org scope.
889+
*/
890+
const BOOT = { isSystem: true, positions: [], permissions: [] } as any;
891+
892+
beforeEach(async () => {
893+
engine = makeEngine();
894+
engine._tables.project = [
895+
{ id: 'p_red', status: 'red', owner_id: 'someone' },
896+
{ id: 'p_green', status: 'green', owner_id: 'someone' },
897+
];
898+
rules = new SharingRuleService({ engine: engine as any, sharing: new SharingService({ engine: engine as any }) });
899+
900+
// Package seed — defined under SYSTEM_CTX exactly as
901+
// `bootstrapDeclaredSharingRules` does, so `organization_id` lands null.
902+
await rules.defineRule({
903+
name: SEEDED, label: 'Red projects → execs', object: 'project',
904+
criteria: { status: 'red' }, recipientType: 'user', recipientId: 'exec',
905+
managedBy: 'package',
906+
} as any, BOOT);
907+
// A rule belonging to a DIFFERENT organization — the isolation pin.
908+
await rules.defineRule({
909+
name: 'other_org_rule', label: 'Other org', object: 'project',
910+
criteria: { status: 'green' }, recipientType: 'user', recipientId: 'mallory',
911+
} as any, { userId: 'other', organizationId: 'org2', systemPermissions: ['manage_sharing'] } as any);
912+
// An API-created rule stamped with THIS org — must keep working as today.
913+
await rules.defineRule({
914+
name: 'org1_rule', label: 'Org1 own', object: 'project',
915+
criteria: { status: 'red' }, recipientType: 'user', recipientId: 'alice',
916+
} as any, ORG1_ADMIN);
917+
});
918+
919+
it('the fixture really does store a null organization_id on the seeded row', () => {
920+
const seeded = engine._tables.sys_sharing_rule.find((r) => r.name === SEEDED);
921+
expect(seeded?.organization_id).toBeNull();
922+
expect(seeded?.managed_by).toBe('package');
923+
// …and the org-stamped rows really are stamped, or the pins below prove nothing.
924+
expect(engine._tables.sys_sharing_rule.find((r) => r.name === 'org1_rule')?.organization_id).toBe('org1');
925+
expect(engine._tables.sys_sharing_rule.find((r) => r.name === 'other_org_rule')?.organization_id).toBe('org2');
926+
});
927+
928+
// ── visibility (the defect) ──────────────────────────────────────────
929+
930+
it('listRules shows the seeded rule to an org-scoped admin', async () => {
931+
const names = (await rules.listRules({}, ORG1_ADMIN)).map((r) => r.name);
932+
expect(names).toContain(SEEDED);
933+
// Exact set — "this org ∪ platform-global", and nothing else. Kept HERE
934+
// rather than on the isolation pins below so that each test has ONE
935+
// predicted direction under the ablation: this one flips red, they do not.
936+
expect(names.sort()).toEqual([SEEDED, 'org1_rule'].sort());
937+
});
938+
939+
it('getRule resolves the seeded rule BY NAME for an org-scoped admin', async () => {
940+
const row = await rules.getRule(SEEDED, ORG1_ADMIN);
941+
expect(row?.name).toBe(SEEDED);
942+
expect(row?.organization_id).toBeNull();
943+
});
944+
945+
it('evaluateRule works BY NAME for the seeded rule (no RULE_NOT_FOUND)', async () => {
946+
const res = await rules.evaluateRule(SEEDED, ORG1_ADMIN);
947+
expect(res.matchedRecords).toBe(1);
948+
expect(res.grantsCreated).toBe(1);
949+
// The control the QA run used: by-id already worked, and must still agree.
950+
const byId = await rules.getRule(SEEDED, ORG1_ADMIN);
951+
expect((await rules.evaluateRule(byId!.id, ORG1_ADMIN)).ruleId).toBe(byId!.id);
952+
});
953+
954+
// ── isolation pins (must stay green under the ablation) ──────────────
955+
956+
it('another organization’s rule stays invisible — list', async () => {
957+
const names = (await rules.listRules({}, ORG1_ADMIN)).map((r) => r.name);
958+
expect(names).not.toContain('other_org_rule');
959+
// Positive half, so this cannot pass by listing nothing at all — and it is
960+
// the org's OWN row deliberately, which survives the ablation.
961+
expect(names).toContain('org1_rule');
962+
});
963+
964+
it('another organization’s rule stays unresolvable — by name', async () => {
965+
expect(await rules.getRule('other_org_rule', ORG1_ADMIN)).toBeNull();
966+
await expect(rules.evaluateRule('other_org_rule', ORG1_ADMIN)).rejects.toThrow(/RULE_NOT_FOUND/);
967+
});
968+
969+
it('the org scope is CONJOINED with the object/activeOnly filters, not substituted for them', async () => {
970+
await rules.defineRule({
971+
name: 'seeded_other_object', label: 'Other object', object: 'account',
972+
criteria: { tier: 'gold' }, recipientType: 'user', recipientId: 'exec',
973+
managedBy: 'package',
974+
} as any, BOOT);
975+
await rules.defineRule({
976+
name: 'seeded_inactive', label: 'Inactive seed', object: 'project',
977+
criteria: { status: 'red' }, recipientType: 'user', recipientId: 'exec',
978+
managedBy: 'package', active: false,
979+
} as any, BOOT);
980+
981+
// Both halves assert on the org's OWN row for the positive side, so this
982+
// test stays GREEN under the ablation and goes red only for its own cause:
983+
// an org scope that SUBSTITUTED for the object/activeOnly predicates
984+
// instead of being conjoined with them (the exact way a top-level `$or`
985+
// fails when a filter evaluator short-circuits on the combinator).
986+
const projects = (await rules.listRules({ object: 'project' }, ORG1_ADMIN)).map((r) => r.name);
987+
expect(projects).not.toContain('seeded_other_object');
988+
expect(projects).toContain('org1_rule');
989+
const active = (await rules.listRules({ object: 'project', activeOnly: true }, ORG1_ADMIN)).map((r) => r.name);
990+
expect(active).not.toContain('seeded_inactive');
991+
expect(active).toContain('org1_rule');
992+
});
993+
994+
// ── unchanged behaviour ──────────────────────────────────────────────
995+
996+
it('an API-created org-stamped rule is still listed and gettable by name', async () => {
997+
expect((await rules.getRule('org1_rule', ORG1_ADMIN))?.organization_id).toBe('org1');
998+
expect((await rules.listRules({}, ORG1_ADMIN)).map((r) => r.name)).toContain('org1_rule');
999+
});
1000+
1001+
it('a no-org (SYSTEM_CTX / boot) context still sees every rule, unfiltered', async () => {
1002+
const names = (await rules.listRules({}, BOOT)).map((r) => r.name).sort();
1003+
expect(names).toEqual([SEEDED, 'org1_rule', 'other_org_rule'].sort());
1004+
expect((await rules.getRule('other_org_rule', BOOT))?.organization_id).toBe('org2');
1005+
});
1006+
1007+
it('when both a platform-global and an own-org row share a name, by-name resolves the OWN row', async () => {
1008+
// `defineRule` is deliberately NOT widened, so a same-named POST from an
1009+
// org admin creates its own row instead of overwriting the shared seed.
1010+
const own = await rules.defineRule({
1011+
name: SEEDED, label: 'Org1 override', object: 'project',
1012+
criteria: { status: 'red' }, recipientType: 'user', recipientId: 'alice',
1013+
} as any, ORG1_ADMIN);
1014+
expect(engine._tables.sys_sharing_rule.filter((r) => r.name === SEEDED)).toHaveLength(2);
1015+
expect(engine._tables.sys_sharing_rule.find((r) => r.organization_id === null)?.label)
1016+
.toBe('Red projects → execs'); // the seed row is untouched
1017+
const resolved = await rules.getRule(SEEDED, ORG1_ADMIN);
1018+
expect(resolved?.id).toBe(own.id);
1019+
expect(resolved?.organization_id).toBe('org1');
1020+
});
1021+
});

0 commit comments

Comments
 (0)