Repository navigation
fix(plugin-sharing): re-evaluate position and team sharing rules for the user whose membership changes - #24790
Conversation
…the user whose membership changed A write to sys_user_position, to the sys_member.role transition source, or to sys_team_member now re-evaluates every sharing rule whose recipient the row names, for the one user the row names, in the same write path. A new holder gains the rule's grants and a former holder loses them before the write returns. WIP: tests and changeset follow. Claude-Session: https://claude.ai/code/session_01KqkTQNLcxVc588eZpJmp1r Co-authored-by: Claude <noreply@anthropic.com>
…et 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>
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>
…on-recompute Claude-Session: https://claude.ai/code/session_01KqkTQNLcxVc588eZpJmp1r Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KqkTQNLcxVc588eZpJmp1r Co-authored-by: Claude <noreply@anthropic.com>
…e test double The user-scoped withdrawal reuses the sharing service's revoke, as the whole-rule pass does, so the change adds no new engine write call site. The position-graph equivalence double applies the caller's limit. Claude-Session: https://claude.ai/code/session_01KqkTQNLcxVc588eZpJmp1r Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KqkTQNLcxVc588eZpJmp1r Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 40 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 12 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ca02625e0e7f3b8376e86c4d52a755dd99f56b34 && git checkout ca02625e0e7f3b8376e86c4d52a755dd99f56b34
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 46bb5e3e5405fad31ccba6fe571e98b9234aac78 99997e91e92721fc4950855a0b2e7a43ca1ad5dd && git checkout -B drift-repro 46bb5e3e5405fad31ccba6fe571e98b9234aac78 && git merge --no-ff 99997e91e92721fc4950855a0b2e7a43ca1ad5dd
node scripts/docs-audit/affected-docs.mjs --json 46bb5e3e5405fad31ccba6fe571e98b9234aac78
|
席位核对ACCEPT · PR #24790 at
Landing: once every check on the head is green, this seat brings Generated by Claude Code |
|
Landed ·
State: there is no card to close. This was the maintainer's direct dispatch, with no public card by their ruling. The membership recompute ships in the next Generated by Claude Code |
Security fix (maintainer-ordered; no public card; detail withheld under the disclosure ruling).
Clause-②: no
What changed
@objectstack/plugin-sharingnow re-evaluates position-recipient and team-recipient sharing rules for the affected user when the membership behind the recipient changes.recipient-membership-recompute.ts.afterInsert,afterUpdateandafterDeleteonsys_user_positionandsys_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.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 throughSharingService.revoke, one grant at a time, the same way the whole-rule pass withdraws.PositionGraphService.holdsPositionandteamHasMember/holdsRoleinteam-graph.tsread 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.content/docs/permissions/sharing-rules.mdxnow lists the new reconcile moment in its "Switching a rule off" table. The sentence inpermissions-matrix.mdxabout 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:
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 realObjectQLengine overSqlDriver(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:buildReadFilter) and edit (checkEdit→deny) are refused;evaluateRulehas nothing left to create, update or revoke;src/position-graph.test.ts:holdsPositionanswers exactlyexpandPositionUsers(...).includes(user)across users, positions, organization scopes, validity windows and the membership-role source, and it reads one user only.scripts/ablation-replace.mjsand restored to the HEAD blob (git diff HEADempty):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, andtest-typecheck-debt.jsonholds its 3 existing pinned signatures with no change.6d46619c7; the lines they mutate are unchanged since then. Ablation 4 ran at99997e91e.Gates
All gate families that
node scripts/pm/dispatch-gates.mjs --commandsderives for this change set: 99 families, run locally at99997e91e.check:type-check-debtran under the verify lock: exit 0, 26 raw tsc errors, none above its recorded number.node scripts/check-plugin-teardown-shape.mjs --self-testexits 3 (PREREQUISITE NOT MEASURED). Its pinned positive control is not reachable from this shallow clone. CI runs it.dispatch-gates --ranreconciliation: 99 derived, 99 run, 0 unrun.pnpm --filter @objectstack/spec buildandcheck:generatedran after mergingorigin/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