Skip to content

Commit 6d46619

Browse files
committed
fix(plugin-sharing): watch sys_user_position and sys_team_member only
The membership-role transition source stays part of the holder check (a user who still holds the position there keeps the grants), but its writes are not watched: its values are the closed set of membership grades. Claude-Session: https://claude.ai/code/session_01KqkTQNLcxVc588eZpJmp1r Co-authored-by: Claude <noreply@anthropic.com>
1 parent 40cf544 commit 6d46619

5 files changed

Lines changed: 22 additions & 27 deletions

File tree

‎.changeset/sharing-membership-recompute.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ fix(plugin-sharing): position- and team-recipient sharing rules are re-evaluated
66

77
Clause-②: no
88

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.
9+
A sharing rule's grants are materialised per user. When a position assignment (`sys_user_position`) 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.
1010

1111
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.
1212

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ be an explicit act, and it happens at **five** moments:
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 |
223+
| **A `sys_user_position` or `sys_team_member` write** | The grants of rules whose recipient is the position or team the row names, 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 |
224224
| **Every boot** | All rules, plus a sweep of `source: 'rule'` rows whose `source_id` no longer resolves to any rule |
225225

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

‎packages/plugins/plugin-sharing/src/recipient-membership-recompute.test.ts‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
* user when the membership behind the recipient changes
66
* (`recipient-membership-recompute.ts`).
77
*
8-
* The invariant: a write to `sys_user_position`, to the `sys_member.role`
9-
* transition source, or to `sys_team_member` re-evaluates every sharing rule
8+
* The invariant: a write to `sys_user_position` or to `sys_team_member`
9+
* re-evaluates every sharing rule
1010
* whose recipient the row names, for the user the row names, before the write
1111
* returns — a new holder gains the rule's grants, a former holder loses them,
1212
* and no other user's grants and no other rule move.
@@ -360,15 +360,16 @@ describe('position assignment writes re-evaluate position-recipient rules for th
360360
});
361361
});
362362

363-
describe('the membership role transition source and team membership move the holding too', () => {
364-
it('a membership role naming the position grants, and a role change away from it revokes', async () => {
363+
describe('team membership writes re-evaluate team-recipient rules, and the transition source still counts', () => {
364+
it('a holder who still holds the position through the membership role keeps the grants when the assignment goes', async () => {
365365
const t = await boot();
366-
await t.ql.insert('sys_member', { id: 'm_1', organization_id: ORG_A, user_id: 'u_member', role: 'eu_sales' }, { context: SYSTEM } as never);
367-
expect(await t.grantsOf('u_member')).toEqual(EU_GRANTS);
366+
await t.driver.create('sys_member', { id: 'm_1', organization_id: ORG_A, user_id: 'u_both', role: 'eu_sales' } as never);
367+
await t.assign('up_both', 'u_both', 'eu_sales');
368+
expect(await t.grantsOf('u_both')).toEqual(EU_GRANTS);
368369

369-
await t.ql.update('sys_member', { id: 'm_1', role: 'member' }, { context: SYSTEM } as never);
370+
await t.ql.delete('sys_user_position', { where: { id: 'up_both' }, context: SYSTEM } as never);
370371

371-
expect(await t.grantsOf('u_member')).toEqual([]);
372+
expect(await t.grantsOf('u_both')).toEqual(EU_GRANTS);
372373
});
373374

374375
it('a new team member gains the team rule\'s grants, and a removed one loses them', async () => {
@@ -416,7 +417,6 @@ describe('binding and write classification', () => {
416417
it('watches exactly the membership tables the position and team expansions read', () => {
417418
expect(RECIPIENT_MEMBERSHIP_SOURCES.map((s) => `${s.object}.${s.recipientColumn}->${s.recipientType}`)).toEqual([
418419
'sys_user_position.position->position',
419-
'sys_member.role->position',
420420
'sys_team_member.team_id->team',
421421
]);
422422
});
@@ -434,7 +434,7 @@ describe('binding and write classification', () => {
434434
});
435435

436436
it('pins a rule to the organizations of the rows it can read', () => {
437-
const [positions, , teams] = RECIPIENT_MEMBERSHIP_SOURCES;
437+
const [positions, teams] = RECIPIENT_MEMBERSHIP_SOURCES;
438438
const orgs = new Set<string | null>([ORG_A]);
439439
expect(ruleReadsRowsOf(positions, { organization_id: ORG_A }, orgs)).toBe(true);
440440
expect(ruleReadsRowsOf(positions, { organization_id: ORG_B }, orgs)).toBe(false);

‎packages/plugins/plugin-sharing/src/recipient-membership-recompute.ts‎

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
* | recipient_type | membership table | recipient column | read by |
1616
* |----------------|---------------------|------------------|-------------------------------------------|
1717
* | `position` | `sys_user_position` | `position` | `PositionGraphService` (source of truth, ADR-0057 D4) |
18-
* | `position` | `sys_member` | `role` | `PositionGraphService` (transition source, ADR-0057 D4 addendum) |
1918
* | `team` | `sys_team_member` | `team_id` | `TeamGraphService.expandUsers` |
2019
*
2120
* So when one of those rows is inserted, deleted, or updated in a column the
@@ -32,6 +31,12 @@
3231
* and `field` (per record, re-derived by the record's own write) have no
3332
* membership table to watch.
3433
*
34+
* The position expansion also unions `sys_member.role`, the ADR-0057 D4
35+
* transition source. The holder check reads it — a user who still holds the
36+
* position there keeps the rule's grants when an assignment row goes — but its
37+
* writes are not watched here: its values are the closed set of membership
38+
* grades (ADR-0108), not business positions.
39+
*
3540
* ## Cost: scoped to one user and to the rules that name the recipient
3641
*
3742
* The BU module refuses to recompute every rule on every graph write, and
@@ -73,8 +78,8 @@
7378
* delete still knows whose membership it removed.
7479
*
7580
* System writes are NOT skipped. The realistic producers of these rows are an
76-
* HRIS or directory sync, a seed, and better-auth's own adapter (which writes
77-
* through the engine as a system context) — skipping them would leave the
81+
* HRIS or directory sync, a seed, and — for team membership — better-auth's own
82+
* adapter, which writes through the engine as a system context. Skipping them would leave the
7883
* invariant false on the paths most likely to exercise it. The rule-hook
7984
* materialisation reached the same conclusion (`rule-hooks.ts`, the retired
8085
* `isSystem` skip).
@@ -127,8 +132,6 @@ export interface RecipientMembershipSource {
127132
* for an organization rule, `organization_id`; it projects `user_id` and
128133
* drops a holder outside `valid_from` / `valid_until` (ADR-0091 D2), so a
129134
* change to the window moves the holding as surely as a change of user.
130-
* - `sys_member` — the same service unions `role` as the position
131-
* transition source, filtered by `organization_id` the same way.
132135
* - `sys_team_member` — `TeamGraphService.expandUsers` filters on `team_id`
133136
* alone, with no organization scope.
134137
*/
@@ -140,13 +143,6 @@ export const RECIPIENT_MEMBERSHIP_SOURCES: readonly RecipientMembershipSource[]
140143
organizationScoped: true,
141144
expansionColumns: ['user_id', 'position', 'organization_id', 'valid_from', 'valid_until'],
142145
},
143-
{
144-
object: 'sys_member',
145-
recipientType: 'position',
146-
recipientColumn: 'role',
147-
organizationScoped: true,
148-
expansionColumns: ['user_id', 'role', 'organization_id'],
149-
},
150146
{
151147
object: 'sys_team_member',
152148
recipientType: 'team',

‎packages/plugins/plugin-sharing/src/sharing-plugin.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -854,9 +854,8 @@ export class SharingServicePlugin implements Plugin {
854854
bindBusinessUnitTreeRecompute(engine, this.ruleService, ctx.logger as any);
855855

856856
// …and a rule whose recipient is a POSITION or a TEAM changes
857-
// audience when a membership row (`sys_user_position`, the
858-
// `sys_member.role` transition source, `sys_team_member`) is
859-
// written. Each such write re-evaluates the rules naming that
857+
// audience when a membership row (`sys_user_position`,
858+
// `sys_team_member`) is written. Each such write re-evaluates the rules naming that
860859
// recipient for the one user the row names, so a new holder gains
861860
// the rule's grants and a former holder loses them before the write
862861
// returns. Bound outside the rebind seam for the same reason as the

0 commit comments

Comments
 (0)