Skip to content

Commit 40cf544

Browse files
committed
test(plugin-sharing): pin the membership recompute invariant; changeset and docs
Real-engine pins: a new holder gains the rule's grants, a removed holder loses them with read and edit refused, moves and window changes follow the holding, an unrelated position and other holders are untouched, an organization's rule is pinned to its own rows, a bulk import stays user-scoped. holdsPosition is pinned equal to the expansion. Claude-Session: https://claude.ai/code/session_01KqkTQNLcxVc588eZpJmp1r Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8dc26b3 commit 40cf544

5 files changed

Lines changed: 516 additions & 2 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/plugin-sharing': patch
3+
---
4+
5+
fix(plugin-sharing): position- and team-recipient sharing rules are re-evaluated for the affected user when the membership behind the recipient changes
6+
7+
Clause-②: no
8+
9+
A sharing rule's grants are materialised per user. When a position assignment (`sys_user_position`), a membership role (`sys_member.role`, the position transition source) or a team membership (`sys_team_member`) is inserted, deleted, or updated in a column the recipient expansion reads (the user, the position or team, the organization, the validity window), every sharing rule whose recipient the row names is now re-evaluated for the user the row names, before the write returns. A new holder gains the rule's grants and a former holder loses them. Other users' grants, and rules naming other recipients, are not touched.
10+
11+
The re-evaluation is scoped to that one user and to the rules naming the recipient: an indexed holder check, that user's grants of the rule, and the rule's criteria read only when the rule reaches the user. A bulk assignment import therefore costs one user-scoped pass per row and never a whole-rule pass. An organization's rule is re-evaluated only for rows of its own organization; a platform-global rule for every row.
12+
13+
`SharingRuleService` gains `reconcileRuleForUser(rule, userId)`, the user-scoped pass these hooks run. No spec key, endpoint or configuration changes.

‎content/docs/permissions/permissions-matrix.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ Sharing rules extend access beyond ownership and the depth axis. The declarative
173173
</Callout>
174174

175175
<Callout type="info">
176-
**Beyond declarative rules:** Two other sharing mechanisms exist but are **not** `SharingRule` types. **Manual sharing** is a runtime grant — `sys_record_share` rows created with `source: 'manual'` (see `packages/plugins/plugin-sharing/src/sharing-service.ts`). **Matrix / territory-shaped access** is served today by multi-position assignment anchored on business units, plus pre-resolved membership sets: a registered `rls-membership-resolver` (ADR-0105 D11) stages e.g. `territory_account_ids` into `ExecutionContext.rlsMembership`, and a policy references it as `account_id IN (current_user.territory_account_ids)`. The aspirational `TerritorySchema` was removed in ADR-0105 D11 — it had no runtime object, stack field or resolver; a generalized dimension-security module will arrive with its own ADR. Criteria rules are re-evaluated on record insert/update via internal sharing rule hooks, on every write to the rule itself, and once per boot — so deactivating or deleting a rule withdraws the grants it materialized (see [Sharing Rules](/docs/permissions/sharing-rules#switching-a-rule-off-withdraws-the-access-it-granted)).
176+
**Beyond declarative rules:** Two other sharing mechanisms exist but are **not** `SharingRule` types. **Manual sharing** is a runtime grant — `sys_record_share` rows created with `source: 'manual'` (see `packages/plugins/plugin-sharing/src/sharing-service.ts`). **Matrix / territory-shaped access** is served today by multi-position assignment anchored on business units, plus pre-resolved membership sets: a registered `rls-membership-resolver` (ADR-0105 D11) stages e.g. `territory_account_ids` into `ExecutionContext.rlsMembership`, and a policy references it as `account_id IN (current_user.territory_account_ids)`. The aspirational `TerritorySchema` was removed in ADR-0105 D11 — it had no runtime object, stack field or resolver; a generalized dimension-security module will arrive with its own ADR. Criteria rules are re-evaluated on record insert/update via internal sharing rule hooks, on every write to the rule itself, when the business-unit graph or the position or team membership behind a rule's recipient changes, and once per boot — so deactivating or deleting a rule withdraws the grants it materialized (see [Sharing Rules](/docs/permissions/sharing-rules#switching-a-rule-off-withdraws-the-access-it-granted)).
177177
</Callout>
178178

179179
### Configuration Example

‎content/docs/permissions/sharing-rules.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,13 +213,14 @@ default organization is bootstrapped (cloud ADR-0081 D1).
213213
A sharing rule's grants are **materialized** — evaluating a rule writes real
214214
`sys_record_share` rows with `source: 'rule'` and `source_id` set to the rule.
215215
Because those rows outlive the evaluation that produced them, withdrawal has to
216-
be an explicit act, and it happens at **four** moments:
216+
be an explicit act, and it happens at **five** moments:
217217

218218
| When | What is reconciled |
219219
|:--|:--|
220220
| **The write that deactivates or edits the rule** | That rule's grants, immediately — deactivating with `active: false` (or `POST {basePath}/sharing/rules` with the same name) revokes them before the call returns |
221221
| **The next insert/update of a matching record** | That record's grants for every rule on the object — an inactive rule desires nothing, so its rows are revoked |
222222
| **A `sys_business_unit` or `sys_business_unit_member` write** | The grants of rules whose recipients read the BU graph — `unit_and_subordinates` and `business_unit`, and only those two. Recipients the rule no longer reaches are revoked **before the write returns**; the grant direction (a unit moved *into* a shared subtree) is queued and coalesced per rule (objectstack#7729) |
223+
| **A `sys_user_position`, `sys_member` or `sys_team_member` write** | The grants of rules whose recipient is the position or team the row names (`sys_member.role` is the position transition source), re-evaluated for the user the row names — inserts, deletes, and updates to the user, the recipient, the organization or the validity window. A new holder gains the rule's grants and a former holder loses them **before the write returns**; other users' grants and rules naming other recipients are not touched |
223224
| **Every boot** | All rules, plus a sweep of `source: 'rule'` rows whose `source_id` no longer resolves to any rule |
224225

225226
Deleting a rule withdraws its grants too, whether you delete it through

‎packages/plugins/plugin-sharing/src/position-graph.test.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,57 @@ describe('PositionGraphService (ADR-0090 D3 — flat expansion)', () => {
9696
expect(calls).toBe(before); // second hit served from cache
9797
});
9898
});
99+
100+
describe('PositionGraphService.holdsPosition — the user-scoped twin of the expansion', () => {
101+
const past = '2000-01-01T00:00:00Z';
102+
const future = '2999-01-01T00:00:00Z';
103+
const ASSIGN = [
104+
{ position: 'rep', user_id: 'u_a', organization_id: 'org_a' },
105+
{ position: 'rep', user_id: 'u_b', organization_id: 'org_b' },
106+
{ position: 'rep', user_id: 'u_global', organization_id: null },
107+
{ position: 'rep', user_id: 'u_expired', organization_id: 'org_a', valid_until: past },
108+
{ position: 'rep', user_id: 'u_pending', organization_id: 'org_a', valid_from: future },
109+
{ position: 'rep', user_id: 'u_mixed', organization_id: 'org_a', valid_until: past },
110+
{ position: 'rep', user_id: 'u_mixed', organization_id: 'org_a', valid_from: past, valid_until: future },
111+
{ position: 'mgr', user_id: 'u_a', organization_id: 'org_a' },
112+
];
113+
const MEMBER = [
114+
{ role: 'rep', user_id: 'u_role_a', organization_id: 'org_a' },
115+
{ role: 'rep', user_id: 'u_role_b', organization_id: 'org_b' },
116+
];
117+
/** Honours every predicate key both reads send: position / role, organization_id, user_id. */
118+
const engine = {
119+
async find(object: string, options: any) {
120+
const f = options?.filter ?? options?.where ?? {};
121+
const rows: any[] = object === 'sys_user_position' ? ASSIGN : object === 'sys_member' ? MEMBER : [];
122+
return rows.filter((r) => Object.entries(f).every(([k, v]) => r[k] === v));
123+
},
124+
} as any;
125+
const USERS = ['u_a', 'u_b', 'u_global', 'u_expired', 'u_pending', 'u_mixed', 'u_role_a', 'u_role_b', 'u_none'];
126+
127+
it('answers exactly expandPositionUsers(...).includes(user) for every user, position and organization scope', async () => {
128+
const verdicts = { holds: 0, not: 0 };
129+
for (const org of [undefined, 'org_a', 'org_b']) {
130+
for (const position of ['rep', 'mgr', 'nope']) {
131+
const expanded = await new PositionGraphService({ engine, organizationId: org ?? null }).expandPositionUsers(position, org);
132+
for (const user of USERS) {
133+
const g = new PositionGraphService({ engine, organizationId: org ?? null });
134+
const holds = await g.holdsPosition(user, position, org);
135+
expect({ org, position, user, holds }).toEqual({ org, position, user, holds: expanded.includes(user) });
136+
verdicts[holds ? 'holds' : 'not'] += 1;
137+
}
138+
}
139+
}
140+
// Both answers occur, so the matrix cannot pass by answering one of them.
141+
expect(verdicts.holds).toBeGreaterThan(5);
142+
expect(verdicts.not).toBeGreaterThan(5);
143+
});
144+
145+
it('reads one user, never the position\'s whole holder list', async () => {
146+
const seen: any[] = [];
147+
const spy = { async find(object: string, options: any) { seen.push({ object, f: options?.filter ?? options?.where }); return engine.find(object, options); } } as any;
148+
await new PositionGraphService({ engine: spy, organizationId: 'org_a' }).holdsPosition('u_role_a', 'rep', 'org_a');
149+
expect(seen.map((s) => s.object)).toEqual(['sys_user_position', 'sys_member']);
150+
expect(seen.every((s) => s.f.user_id === 'u_role_a')).toBe(true);
151+
});
152+
});

0 commit comments

Comments
 (0)