fix(plugin-sharing): a seeded business unit is a usable sharing-rule recipient, and its members are tenant-screened - #14949
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…recipient, and its members are tenant-screened (#14547) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…ted read sites Repaired with the gate's own `--fix` path, never by hand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 8 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 d82c7543db408fcc8e54656d30e611eace07d52d && git checkout d82c7543db408fcc8e54656d30e611eace07d52d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin aae0cb395f50c5491442540bbb453265519a1e57 7e27ab5122974b2970ce69d7c295223425a987d8 && git checkout -B drift-repro aae0cb395f50c5491442540bbb453265519a1e57 && git merge --no-ff 7e27ab5122974b2970ce69d7c295223425a987d8
node scripts/docs-audit/affected-docs.mjs --json aae0cb395f50c5491442540bbb453265519a1e57
|
Docs Drift Check disposed — ⛔ do not edit the release page, and no issue is owed either
⛔ The page is read-only, full stop
And the escape hatch does not apply, because the line is not wrongThe guardrail's remedy when a release page is actually wrong is to file an issue. I checked whether one is owed. It is not. The anchoring line (
That is a claim about authoring-surface completeness: every recipient a user can author has a branch that implements it. This card's defect is that two of those branches — ⇒ The honest reading is the same shape as PR #14930's drift row, and in the same direction: this PR moves reality toward what the page already claims, rather than away from it. After it lands, "enforced" is more true than it is today, not less. Filing an issue to report that a sentence slightly overclaims in exactly the edge case an open Control
Generated by Claude Code |
ACCEPT — the asymmetric pair is verified at the tree, and the security half is pinned independently of the functional half
⛔ Verified against the repository, not against the report. The load-bearing claim on a
Both member reads carry it — What earns the ACCEPT beyond the code being right
The out-of-scope finding is the most valuable thing in this round#14946 — Filed rather than fixed in place, and the judgement is right: different package, so it adds a verification surface and may be held by another lane. ⛔ Not folded in here. LandingCI green on Generated by Claude Code |
…before 17.3 Restore `BusinessUnitGraphService.orgScope` to the strict `organization_id = <rule org>` equality 17.2.0 ships, byte-identical to its form before e560b4d (#14949), and delete the docblock that argued for the NULL-inclusive shape. Maintainer ruling, ADR-0131 D8: that shape re-implements the predicate `SqlDriver.applyTenantScope` already owns, a second time in a second place, which is the duplication ADR-0131 exists to retire (#10103 cause 1) — and it had not shipped, so reverting costs nothing while releasing it would owe v18 a breaking change and a migration. KEPT, untouched: `memberScope` (byte-identical to origin/main) and `SharingRuleService.warnOnEmptyUnitExpansion`. Both member reads previously carried no organization predicate at all; a strict unit screen narrows which units are reachable but does not close that, because other organizations' member rows sit on org-stamped units too. #14547 therefore remains open and behaves as in 17.2.0. It is fixed structurally on the v18 line by ADR-0131 C1. Tests: the cases that pinned the NULL-inclusive unit screen now pin the restored behaviour and name #14547 as the open defect they reproduce. The member-screen pins are re-anchored from a seeded (org-less) unit onto an org-stamped one — with the unit screen strict, an org-less unit is invisible to an org-stamped rule, so those assertions would otherwise pass with `memberScope` deleted. See the PR body. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
… reverted screens #14547 is CLOSED as completed — closed by #14949, whose unit half this branch reverts — so calling it "the OPEN defect" in the pins would have been wrong on a checkable fact. The tests name #14547 as the defect they reproduce and say plainly that its tracker state no longer matches the 17.x runtime, leaving the reopen decision to the maintainer rather than asserting it. Comment-only; no assertion, fixture or method body moves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Fixes #14547
Implements recommendation A as ruled: the null-inclusive
orgScopeand a tenant screen on the member queries, in one PR, preceded by the write-path measurement the triage names as its prerequisite. Draft, as the ruling directs.Re-lands the work of the closed PR #14572, which was closed unmerged for a jurisdiction reason and not a technical one. Every measurement below was re-run against today's
origin/mainrather than carried forward.1. The prerequisite measurement, re-run
Question: are
sys_business_unit_memberrows organization-stamped on every write path? Answer: no. Re-measured atorigin/main431979e67:tenantIdintoDriverOptions;SqlDriver.injectTenantOnInsertfills the injected columnpackages/metadata-protocol/src/seed-loader.ts:926withholds the single-orgfallbackOrgIdfrom every object matching `/^(sys_sys_business_unit_memberis absent fromPLATFORM_OBJECT_TENANCY, so it classifiesunclassifiedandEngine.resolveSystemInsertOrganization(packages/objectql/src/engine.ts:3944) returns earlymainhas moved since the first round took this reading — notably #14484 landed and addedsys_record_sharetoPLATFORM_OBJECT_TENANCYastenant-scoped. That entry coverssys_record_shareand no other table, so it does not move the member-row answer; the residual classification gap forsys_business_unit_memberis tracked at #14570 and is not closed here.Because the answer is still "no", the two screens are asymmetric. A NULL organization on a unit row is the documented platform/seeded class; a NULL on a membership row means unknown tenancy.
2. The premise still holds
Verified before the first edit, at
431979e67:business-unit-graph.ts—orgScopeis still the strict equality, verbatim as the card quotes it;seedIsUsablestill runs it as the first screen, so an org-NULL unit reads as "does not exist";expandUnitMembersandexpandUsers— still querysys_business_unit_memberwith no organization predicate at all, under aSYSTEM_CTXthat carries no tenant. The member queries are unscoped by organization.3. What changed
The UNIT screen is now null-inclusive.
orgScopeemits(organization_id = rule org OR organization_id IS NULL)— the predicateSqlDriver.applyTenantScopewrites, whose own comment names business units among the populations its NULL arm exists for (#2734). The same predicate is already written twice in this codebase for these rows:SharingRuleService.adminOrgScope(#7676) andApprovalService.businessUnitOrgScope(#3807). This file was the outlier, andsharing-rule-service.tsnames the mistake in prose while this file made it.The MEMBER screen is new, and strict. A new
memberScopeappliesorganization_id = rule orgto both member reads. This is the half that matters most: the strict unit screen was the only thing keeping an org-stamped rule away from those unscoped queries, so widening the unit screen alone would have converted a silent under-grant into a silent cross-tenant over-grant — a seeded unit id exists identically in every tenant. Strict rather than null-inclusive because of the measurement in section 1: a grant fails closed on unknown tenancy. The sibling recipient widths already read their membership rows this way (TeamGraphServicescreenssys_team_member,PositionGraphServicescreenssys_user_position, both strict).Every recipient width was checked, not only the narrow one the card names.
business_unitandunit_and_subordinatesboth route through this graph and both carried the hole;user,team,positionandqueuedo not reach it.An active business-unit rule that expands to nobody now warns, once per rule per process, naming the rule, the object, the recipient kind, the unit and the organization. That is the half independent of any screen — the reported failure was not merely that the expansion was empty, it was that nothing recorded it — and it also covers the case the screens deliberately leave empty: a rule whose unit and membership rows were both seeded still grants nobody, and now says so.
4. Tests — the security half pinned apart from the functional half
A fix that expands the right members while also expanding another organization's members satisfies the functional pin completely, so the two are pinned in separate blocks and demonstrated separately by ablation.
Two stale pins were replaced, not flipped.
business-unit-graph.test.tscarried a[divergence]test asserting that an org-scoped rule does not see a null-org unit, on the stated grounds that the case was unreachable. It was reachable — that is this card. Both it andthe narrow width is org-predicated exactly like the wide onewould have kept passing after the fix for a different reason (the member screen answering where the unit screen used to), which is the class of assertion that stops guarding anything; they are rewritten to state which screen answers.Membership fixtures in
sharing-rule.test.tsandbusiness-unit-graph.test.tsgainedorganization_id. They were org-less while their units and rules were org-stamped, and expanded anyway — precisely because the member read had no predicate. The fixture became faithful; no assertion was relaxed.Ablations, direction predicted before running
orgScopeback to the strict equalitysharing-rule.test.tsgreen (its units are org-stamped, strict still matches)memberScoperemoved from both member readsB's asymmetry is the evidence the security half is independently pinned: removing the member screen leaves every functional assertion green and reds only the cross-tenant ones.
Both ablations proved the mutation reached disk before reading any result (removed-text occurrence count driven to 0, injected marker counted, mutated blob hash differing from the HEAD blob — a
//comment was never used, esbuild strips them) and proved each restore (blob hash equal to the HEAD blob and an emptygit diff HEAD). The mutation was applied to a committed implementation, so the restore leg had something true to restore to. Ablation validity: these suites import the subject through same-package relative specifiers, so vitest resolves them fromsrcand nodistsits between the mutation and the run.Runs
At final head
7e27ab512:pnpm --filter @objectstack/plugin-sharing build—check-dts-emitted: 1/1 declared declaration file(s) presentpnpm --filter @objectstack/plugin-sharing test— Test Files 32 passed (32) · Tests 790 passed (790)pnpm --filter @objectstack/plugin-sharing typecheck— green, and it covers the test layer: the package excludes*.test.tsfromtsconfig.json, but itstypecheckscript chainscheck:test-typecheck, which reportedOK — the test layer compiles under tsconfig.test.json. So the edited test files are measured, not merely adjacent to a green check.5. Gate family
Derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, re-derived after the commit that added the docs file: 66 commands. All 66 run; every exit code captured by redirect-then-read, never across a pipe.61 green. 0 findings. Five are NOT MEASURED — each refuses to reach a verdict without a whole-tree build, in its own words, and CI supplies that build:
check-test-completeness.mjscheck:dual-build-cjs-loadscheck:type-check-debtcheck:i18ncheck:skill-examplesThe last two exit 1 rather than 3, so they read like findings and are not; their own text is quoted above so a reviewer need not re-derive that.
check:system-context-censusdid red — pure line rot, my insertion insharing-rule-service.tsshifted twoisSystemread sites. Repaired only with the gate's ownpnpm check:system-context-census --fix, never by hand; it re-anchored:157to:165and:382to:390and now reportsOK — 109 elevation read sites... all anchored.No new rows were added to
scripts/engine-double-contract.pinned.json: the end-to-end tests were written intorecipient-width.test.ts, whose engine double is already pinned for both write verbs, rather than into a new file that would have needed fresh ledger entries.6. Clause-② (contract review): no
Measured, not asserted — built
dist/index.d.tson both sides of the diff (BASE source built, restored, verified by src-tree hash equality and an emptygit diff HEAD):export/declarelines: 62 before, 62 after;.d.tsadditions areprivate readonly emptyUnitExpansionSeen,private warnOnEmptyUnitExpansionandprivate memberScope. TypeScript emitsprivatemembers as name-only placeholders; they are not callable, nameable or assignable from outside the class, so they are neither an exported symbol nor a payload key.SharingRuleEvaluationResultis untouched, and the warn's structured fields go to a logger sink, not a result type.This disagrees with the prior round's shape, deliberately. PR #14572 exposed a public getter
emptyUnitExpansionRuleKeysonSharingRuleService. That is a new exported symbol and would have flipped clause-② toyes. It is dropped here — the once-per-rule dedup is asserted through the logger spy the tests already use — so the whole diff stays internal toplugin-sharing.7. Out of scope, filed not fixed
#14946 —
plugin-approvalshas the identical defect one plugin over:ApprovalService.expandBusinessUnitUsersscreens the unit rows null-inclusively (since #3807) but readssys_business_unit_memberatapproval-service.ts:1771with no organization predicate, under aSYSTEM_CTXcarrying no tenant. On a seeded org chart adepartmentapprover on one tenant's request resolves to another tenant's users. Not fixed here: different package, different tests, different gate surface. That issue carries the evidence and the suggested asymmetric shape.🤖 Generated with Claude Code
https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Generated by Claude Code