Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-sharing): recompute unit_and_subordinates grants on business-unit tree and membership changes - #7806
Conversation
…s-unit graph writes `bindRuleHooks` registers recompute hooks scoped to each rule's own `object_name` and nothing bound one on `sys_business_unit` or `sys_business_unit_member`, so a rule whose recipient resolves through the business-unit graph only moved its materialised `sys_record_share` grants when the shared RECORD was next written. A business unit moved OUT of a shared subtree therefore kept its members' read access until that happened — a revocation with no bound in time. Adds `bu-tree-recompute.ts`, binding afterInsert/afterUpdate/afterDelete on both BU tables, and `revokeRuleGrantsForRetiredRecipients` on `SharingRuleService` — the recipient-axis twin of the existing record-axis revokes. Applies the #4779 split on the new axis and reuses its queue: the revoke is synchronous and complete (no record scan; cost does not grow with how many records a rule matches), the re-grant is queued on the shared `ruleRegrantQueue`, coalesced per rule. Co-Authored-By: Claude <noreply@anthropic.com>
Reproduces the QA matrix end to end over an in-memory engine that dispatches hooks on write: inSubtree grants, re-parent OUT revokes, re-parent back IN re-grants, membership add/remove moves the same way — every `(no touch)` cell asserted against a write log proving the shared record was never touched. The security pin blocks the asynchronous re-grant queue with a gate before re-parenting, so a grant that is gone while the gate is shut can only have been withdrawn on the write path. Non-regression: a `user` / `team` / `position` / `queue` rule is not recomputed AT ALL by a BU write, an inactive BU-tree rule is not woken, and a BU rename or an `is_primary` flip skips the recompute entirely. Co-Authored-By: Claude <noreply@anthropic.com>
…dispatch `check:engine-double-contract` caught the new double's `update()` accepting call shapes `ObjectQL.update` refuses. Routes it through the producer's own `assertEngineUpdateDispatch` alongside the delete half, and seeds the row the `is_primary` narrowing case operates on — under the corrected dispatch a write to a non-existent id fires no hook at all, so that case would have passed without testing the narrowing. Also releases the re-grant queue gate from `afterEach` rather than inline. The queue is module-scoped and shared by the file: a gate left shut by a FAILING assertion blocked the chain and turned one clear failure into 15 timeouts, measured during this fix's own ablation. Adds the changeset. Co-Authored-By: Claude <noreply@anthropic.com>
…tree-share-recompute
…ld keys Mirrors the looseness #7760 removed from `sharing-rule.test.ts`'s fake one commit earlier: the matcher returned on `$or` alone and dropped every sibling key, so `listRules`'s `{object_name, active, $or:[…org scope…]}` would have matched the whole table here while driver-sql and driver-memory conjoin them. Dormant in this file today (its reads all run under a system context, which carries no org and so composes no `$or`), fixed anyway — a double looser than the contract it stands in for is how a green suite ships a broken filter. Co-Authored-By: Claude <noreply@anthropic.com>
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 7 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
…al moment
`sharing-rules.mdx` enumerated withdrawal as happening at exactly THREE
moments, in a definitive table. This change adds a fourth — a
`sys_business_unit` / `sys_business_unit_member` write — so an author
reasoning about revocation timing from that table was reading an incomplete
list, on the one topic the change is about.
The new row states the asymmetry rather than flattening it: recipients the
rule no longer reaches are revoked before the write returns, while the grant
direction is queued and coalesced per rule. It also scopes itself to the two
recipient kinds whose expansion reads the BU graph, so nobody infers that
every rule now recomputes on every business-unit write.
Deliberately unchanged, both checked rather than assumed:
- The "always recoverable from the API surface" paragraph below the table.
Its subject is an over-granting RULE recovered by switching it off or
deleting it — the rule-write axis, which this change does not touch — so it
stays exactly true, and its "not on the next time somebody happens to touch
the record" phrasing is the same line the new row echoes.
- The recipient table's `business_unit` row ("exactly that business unit (no
subtree)"). That states the DECLARED contract, matching the spec enum and
the ADR-0105 lint red-line. The runtime diverges by walking the subtree for
both BU kinds, which is filed as #7807 — rewriting the doc to match would
document an over-grant as intended behaviour. The new row is consistent with
it either way: a unit-only expansion still reads `sys_business_unit` for its
own active flag and `sys_business_unit_member` for its members, so it needs
the recompute under both readings.
`sharing-service.mdx` says nothing about recipient kinds or the BU graph;
absent is not wrong, so it is left alone.
Co-Authored-By: Claude <noreply@anthropic.com>`check:type-check-debt` went red: plugin-sharing's TEST_DEBT records 3 raw tsc errors and the new guard took it to 5. src/bu-tree-recompute.test.ts(388,38): error TS7006: Parameter 'c' implicitly has an 'any' type. src/bu-tree-recompute.test.ts(389,40): error TS7006: Parameter 'c' implicitly has an 'any' type. Cause: the spies were declared `ReturnType<typeof vi.spyOn>`, the unparameterised spelling, which erases the signature — so `mock.calls` degrades to an implicit `any` per element. Declaring them as `MockInstance<SharingRuleService['...']>` derives the real types, which fixes both errors and lets the `as any` cast on the first assertion go: `c[0].id` is now the checked argument type rather than an unchecked cast. The ledger entry is NOT raised. It is a shrink-only ratchet (#5278) and these errors are hours old; the measured count is back to exactly 3, and its composition again matches the entry's note verbatim (TS6133 x2, TS18048 x1). Why local verification missed it: this package's tsconfig excludes `**/*.test.ts`, so `pnpm --filter @objectstack/plugin-sharing typecheck` never compiles its own tests — only `check:type-check-debt` measures that layer. Co-Authored-By: Claude <noreply@anthropic.com>
os-help
commented
Aug 11, 2026
Patch round: The two errors, verbatim: Cause. The spies were declared Fix. Declared them from the service methods instead, The entry stays at 3. The measured count is back to exactly 3 and its composition again matches the note verbatim ( Final gate output: Behaviour unchanged: One observation worth recording, since it will bite the next person measuring this gate locally. A second entry, Still draft, auto-merge not enabled. Generated by Claude Code Generated by Claude Code |
Fixes#7729
The defect
Business-unit subtree expansion for
unit_and_subordinatessharing rules already resolved correctly through three levels and was symmetric on placement, so the tree was never the problem. The defect was timing: the placement or re-parent alone moved nothing. Grants only materialised, or de-materialised, when the shared record was next written.Measured matrix from the QA run:
inSubtree=1,reparentedOut(no touch)=1,reparentedOut(touched)=0,restored(no touch)=0,restored(touched)=1.The security column is the second one. After a business unit is moved out of a shared subtree, its members keep read access — and keep it until somebody happens to write the shared record, which puts no bound at all on how long a revoked recipient keeps reading.
Root cause confirmed on
origin/mainby symbol:bindRuleHooksinrule-hooks.tsregisters its recompute hooks scoped to each rule's ownobject_name, and a repo-wide search finds no hook registered onsys_business_unitanywhere. The only hooks onsys_business_unit_memberare inprimary-bu-projection.ts, which maintains thesys_user.primary_business_unit_idprojection and never touches the rule recompute.The fix
New
bu-tree-recompute.tsbindsafterInsert/afterUpdate/afterDeleteon both business-unit tables, under its own package id sounbindAllRuleHooks(which every rule rebind calls) cannot tear it down. It carries no rule snapshot — the handler readslistRules()live — so a runtime-authored rule is picked up with no rebind wiring.unit_and_subordinatesandbusiness_unit, and only those two. Auser,team,positionorqueuerule is not recomputed by a business-unit write at all. It is emphatically not the case that every rule now recomputes on every BU write; that would close the security hole and replace it with a load problem.This applies the #4779 ruling on a new axis and reuses its seam rather than adding a second one:
SharingRuleService.revokeRuleGrantsForRetiredRecipientsre-expands the rule's recipients and set-deletes the grants of everyone who dropped out. Scoped by recipient, which is what makes it affordable on a write path: one grant query, one subtree walk, one member query and a chunked delete per affected rule, with no record scan — so the cost does not grow with how many records the rule matches. A unit moved out of a shared subtree has lost its access before the write returns.ruleRegrantQueueas a fullevaluateRulepass, at most one outstanding per rule.That split follows the maintainer's standing direction on this subsystem: over-granting is a security incident, under-granting is an availability wobble. The revocation window is therefore zero; only the grant direction is deferred, and
kernel:bootstrapped's backfill repairs a re-grant lost to a crash.Recipient kinds audited, not assumed
expandRecipientis the one switch that decides, and it reads the business-unit graph for two of the six kinds:userteamTeamGraphService(sys_team_member,sys_member,sys_user)business_unitBusinessUnitGraphService.expandUserspositionPositionGraphService(sys_user_position,sys_member)unit_and_subordinatesBusinessUnitGraphService.expandUsersqueuebusiness_unitis in scope on the strength of what the code does today:expandRecipientroutes it through the sameexpandUserscall asunit_and_subordinates, so it walks the subtree and is equally exposed. Covering it is correct under either reading of its intended semantics — even a unit-only expansion still readssys_business_unitfor its ownactiveflag andsys_business_unit_memberfor its members.Everything else is deliberately not recomputed at all — not more cheaply, not at all. That exclusion is a requirement rather than an optimisation, and it is asserted directly.
Docs
content/docs/permissions/sharing-rules.mdxenumerated withdrawal as happening at exactly three moments, in a definitive table. This change adds a fourth, so that list would otherwise have been incomplete on precisely the topic this card is about. The count word is fixed and a row added, at the table's existing granularity, stating the asymmetry (revocation before the write returns; the grant direction queued and coalesced per rule) and scoping itself to the two BU-reading recipient kinds.Two things checked in that file rather than assumed, both deliberately left unchanged:
business_unitrow ("exactly that business unit (no subtree)"). That states the declared contract and matches the spec enum and the ADR-0105 lint red-line. The runtime diverges by walking the subtree for both BU kinds — filed separately as sharing rules: thebusiness_unitrecipient expands the whole SUBTREE, but the spec declares it as exactly one unit (no subtree) #7807 — so rewriting the doc to match the runtime would document an over-grant as intended behaviour. The doc stays; sharing rules: thebusiness_unitrecipient expands the whole SUBTREE, but the spec declares it as exactly one unit (no subtree) #7807 owns the resolution.sharing-service.mdxsays nothing about recipient kinds or the BU graph. Absent is not wrong, so it is left alone.Verification
pnpm --filter @objectstack/plugin-sharing test— 446 passed / 17 files, on top of a mergedorigin/main.typecheckclean.pnpm --filter @objectstack/plugin-security test— 973 passed / 47 files (coverssys_record_sharematerialisation and the write-path composition).showcase-bu-hierarchy-sharing.dogfood.test.ts+sharing-rule-criteria-required.dogfood.test.ts— 9 passed. These boot the real showcase stack and insert BU and membership rows under a system context, which is precisely where the new hooks fire.pnpm check:engine-double-contract— OK, 150 pinned (the new fake is pinned to the producer's dispatch predicates for both write verbs).check:docs-audit-scopeOK.check:nul-bytesOK.Ablation. Predicted first, then run: neutering the hook registration should flip the new behaviour and binding cases red while every pre-existing rule-recompute pin stays green. Measured, twice, identically: 10 failed / 426 passed, all 10 in the new file, all 16 pre-existing files fully green. The cases that stayed green are the ones that should —
inSubtree(materialised by the explicit boot-backfill analog, not by a hook), "member moved within the subtree keeps access", the four pure non-regression assertions, and the two pure-function cases.The first ablation run also caught a flaw in the new test file itself: the security pin installs a gate on the module-scoped re-grant queue, and a gate left shut by a failing assertion blocked the chain so every later test died in
whenIdle()— one clear failure arriving as 15 timeouts. The release moved toafterEach, so a future real regression stays legible as the one test it is.Notes
sys_business_unit_memberrows, so the fixture places its member explicitly, three levels down.granted.size === 0short-circuit absorbs the common case. The asynchronous half is coalesced per rule, so a thousand-row import collapses to one re-grant pass per rule.sharing-rule-service.ts, the file this change also touches. The merge is textually clean and semantically checked, not merely clean: fix(plugin-sharing): make package-seeded (org-null) sharing rules visible and addressable by name for org-scoped admins #7760'sadminOrgScopewidening applies only when the caller carries an org, and this change's handler readslistRulesunder a system context which carries none, so the predicate is unchanged on that path. This change to that file is purely additive (+80 lines, 0 deletions) and far from fix(plugin-sharing): make package-seeded (org-null) sharing rules visible and addressable by name for org-scoped admins #7760's region. fix(plugin-sharing): make package-seeded (org-null) sharing rules visible and addressable by name for org-scoped admins #7760 also removed a loose$ormatcher from the sibling test fake; the same looseness was present in the new fake here and has been fixed the same way.Generated by Claude Code