Repository navigation
Commit 0b7286f
fix(plugin-sharing): re-evaluate position and team sharing rules for the user whose membership changes (#24790)
Security fix (maintainer-ordered; no public card; detail withheld under
the disclosure ruling).
Clause-②: no
## What changed
`@objectstack/plugin-sharing` now re-evaluates position-recipient and
team-recipient sharing rules for the affected user when the membership
behind the recipient changes.
- **New hooks, `recipient-membership-recompute.ts`.** `afterInsert`,
`afterUpdate` and `afterDelete` on `sys_user_position` and
`sys_team_member`, bound under their own package id so a rule rebind
cannot remove them. The plugin binds them next to the business-unit
recompute.
- **Which writes trigger it.** Each insert and each delete triggers it.
An update triggers it only when its patch touches a column the expansion
reads: the user, the position or team, the organization, or the validity
window. For each such write, every rule whose recipient is the position
or team the row names (before or after the write) is re-evaluated for
the user the row names.
- **Organization scope.** An organization's rule is re-evaluated only
for rows of its own organization. A platform-global rule is re-evaluated
for every row. Team expansion has no organization scope, so neither does
its trigger.
- **`SharingRuleService.reconcileRuleForUser(rule, userId)`.** The
user-scoped pass. Its outcome equals the whole-rule pass restricted to
that user. If the rule reaches the user, the user ends up with one grant
per matched record at the rule's level. If it does not reach the user
(inactive rule, switched-off position, or the user no longer holds the
recipient), the user ends up with none. Withdrawal goes through
`SharingService.revoke`, one grant at a time, the same way the
whole-rule pass withdraws.
- **User-scoped holder checks.** `PositionGraphService.holdsPosition`
and `teamHasMember` / `holdsRole` in `team-graph.ts` read the same rows
the expansions read, narrowed to one user. The expansions and the checks
build their predicates from one shared function per table, so the two
cannot drift apart.
- **Docs.** `content/docs/permissions/sharing-rules.mdx` now lists the
new reconcile moment in its "Switching a rule off" table. The sentence
in `permissions-matrix.mdx` about when rules are re-evaluated now names
it too.
## The invariant restored
When a position is assigned to a user or removed from one, every sharing
rule whose recipient is that position is re-evaluated for that user in
the same write path. A new holder gains the rule's grants. A removed
holder loses them before the write returns, and nobody has to
re-evaluate the rule by hand. Team membership follows the same rule.
Other users' grants are not touched, and neither are rules that name
other recipients.
## Cost
The business-unit recompute does not recompute every rule on every graph
write, and it queues its grant half. The reason is that a BU write can
move many recipients at once. This change addresses the same cost
concern by narrowing the scope instead of deferring the work. A
membership row moves exactly one user. Per written row the pass costs:
- one rule listing;
- for each rule that names the recipient: an indexed holder check (never
the recipient's whole member list), that user's grants of the rule, and,
only when the rule reaches the user, the rule's criteria read plus the
grants the user lacks.
A bulk import of N rows therefore costs N user-scoped passes and never
runs a whole-rule pass.
## Tests
- `src/recipient-membership-recompute.test.ts`: a real `ObjectQL` engine
over `SqlDriver` (better-sqlite3), so the engine itself supplies each
hook context (inserted row, update and delete pre-image, per-row
dispatch of a batch insert). Pins:
- a new holder gains the rule's grants;
- a removed holder loses them, and both read (`buildReadFilter`) and
edit (`checkEdit` → `deny`) are refused;
- moving an assignment to another user, or to another position, moves
the grants with it;
- a validity window that closes counts as a removal;
- a second assignment of the same position, or a membership role naming
it, keeps the grants;
- an unrelated position's grants are untouched and its rule is never
re-evaluated;
- other holders' grants are untouched;
- an assignment in another organization does not re-evaluate an
organization's rule;
- an update that cannot change who holds the position re-evaluates
nothing;
- a bulk import of 40 assignments makes 20 user-scoped passes (for the
named position), no whole-rule pass, and only user-scoped assignment
reads;
- after the user-scoped passes, a whole-rule `evaluateRule` has nothing
left to create, update or revoke;
- team: a new member gains, a removed member loses;
- binding, write classification and organization pinning.
- `src/position-graph.test.ts`: `holdsPosition` answers exactly
`expandPositionUsers(...).includes(user)` across users, positions,
organization scopes, validity windows and the membership-role source,
and it reads one user only.
- **Ablations**, each run once with `scripts/ablation-replace.mjs` and
restored to the HEAD blob (`git diff HEAD` empty):
1. No membership hook bound: 13 of 18 red.
2. Organization pin removed: 2 of 18 red.
3. Holder check replaced by the full expansion: the bulk pin red.
4. The user-scoped withdrawal revokes nothing: 7 of 18 red (every
removal, move and team-removal pin).
Readings at `99997e91e`:
- `pnpm --filter @objectstack/plugin-sharing test`: 51 files, 1192 tests
passed.
- `pnpm --filter @objectstack/plugin-sharing typecheck`: exit 0. The
test layer compiles, and `test-typecheck-debt.json` holds its 3 existing
pinned signatures with no change.
- The new and changed files on their own: 26 tests passed.
- Ablations 1 to 3 ran at `6d46619c7`; the lines they mutate are
unchanged since then. Ablation 4 ran at `99997e91e`.
## Gates
All gate families that `node scripts/pm/dispatch-gates.mjs --commands`
derives for this change set: 99 families, run locally at `99997e91e`.
- 98 families exit 0. `check:type-check-debt` ran under the verify lock:
exit 0, 26 raw tsc errors, none above its recorded number.
- `node scripts/check-plugin-teardown-shape.mjs --self-test` exits 3
(PREREQUISITE NOT MEASURED). Its pinned positive control is not
reachable from this shallow clone. CI runs it.
- `dispatch-gates --ran` reconciliation: 99 derived, 99 run, 0 unrun.
- `pnpm --filter @objectstack/spec build` and `check:generated` ran
after merging `origin/main`: exit 0, all 14 generated artifacts up to
date.
The full test battery and the type-check lanes are CI's.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01KqkTQNLcxVc588eZpJmp1r)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent faead58 commit 0b7286f
11 files changed
Lines changed: 1130 additions & 13 deletions
File tree
- .changeset
- content/docs/permissions
- packages/plugins/plugin-sharing/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
173 | 173 | | |
174 | 174 | | |
175 | 175 | | |
176 | | - | |
| 176 | + | |
177 | 177 | | |
178 | 178 | | |
179 | 179 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
213 | 213 | | |
214 | 214 | | |
215 | 215 | | |
216 | | - | |
| 216 | + | |
217 | 217 | | |
218 | 218 | | |
219 | 219 | | |
220 | 220 | | |
221 | 221 | | |
222 | 222 | | |
| 223 | + | |
223 | 224 | | |
224 | 225 | | |
225 | 226 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
51 | 51 | | |
52 | 52 | | |
53 | 53 | | |
54 | | - | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
55 | 57 | | |
56 | 58 | | |
57 | 59 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
96 | 96 | | |
97 | 97 | | |
98 | 98 | | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3 | 3 | | |
4 | 4 | | |
5 | 5 | | |
6 | | - | |
| 6 | + | |
7 | 7 | | |
8 | 8 | | |
9 | 9 | | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
10 | 26 | | |
11 | 27 | | |
12 | 28 | | |
| |||
73 | 89 | | |
74 | 90 | | |
75 | 91 | | |
76 | | - | |
77 | 92 | | |
78 | | - | |
79 | 93 | | |
80 | 94 | | |
81 | 95 | | |
82 | 96 | | |
83 | 97 | | |
84 | | - | |
85 | | - | |
| 98 | + | |
| 99 | + | |
86 | 100 | | |
87 | 101 | | |
88 | 102 | | |
| |||
105 | 119 | | |
106 | 120 | | |
107 | 121 | | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
108 | 160 | | |
0 commit comments