Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-sharing): require platform authority to delete a platform-global sharing rule (#7795) - #8156
Conversation
…global sharing rule (#7795) `deleteRule` on a row with `organization_id = null` now refuses a caller who holds only the org-scoped `manage_sharing` capability, with a `PERMISSION_DENIED` the REST layer answers as 403. `manage_platform_settings`, the built-in `platform_admin` position and system contexts stay permitted. Such a rule belongs to no organization and its criteria query runs unscoped, so the delete purged every tenant's `sys_record_share` grants under it — a cross-tenant destructive act authorized by a `scope: 'org'` capability. Both measurements the ruling rests on were re-verified against this build first: the boot seeder revives the rule under a NEW id on the next restart (a revocation wearing removal's clothes), and an org admin's `active: false` creates a second org-stamped row while the shared rule keeps running (the safe lever refused, the destructive one permitted). 403 and not 404 is deliberate: #7760 opened listing, reading and evaluating seeded rules to org admins on purpose, and all three are untouched here, so a 404 would contradict a read the same caller can perform one call earlier. Maintainer ruling 2026-08-12 (方向 B), quoted verbatim in the guard's docblock. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
…tform-global-sharing-rule-delete-gate
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:
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#7795
Implements the maintainer ruling of 2026-08-12 — 方向 B: read/evaluate stay open, delete requires platform authority.
What changed
SharingRuleService.deleteRuleresolves the row as before, then — for a row whoseorganization_idis null — requires the caller to carry platform authority. Everything else on the surface is untouched.manage_sharingonlyPERMISSION_DENIED(HTTP 403)manage_platform_settingscapabilityplatform_adminpositionThe guard sits after
getRuleresolves, so a row the caller cannot see at all keeps its existing silent no-op rather than gaining a refusal that would disclose the row's existence.Both premises re-verified before building
The card was ruled at 03:04Z and
plugin-sharinghas taken two merges since (#8097, #8120 — neither toucheddeleteRule; everything here was located by symbol, not by line). Both measurements the ruling rests on were re-run against this build first:defineRulecall revives it under a different id (srule_4472403f…→srule_050a3107…) with its grants re-materialised. Mechanism:defineRule's existence lookup under a null org is{name}, which matches nothing after the delete, so the insert branch mints a freshuid('srule').active: falseproduced two rows — the platform-global one stillactive: true, plus a new org1-stamped rowactive: false— whiledeleteRuleon the shared row succeeded and purged its grants. The scoped, reversible operation refused; the cross-tenant, irreversible-until-reboot one permitted.Both reproduce exactly as the card describes, so the ruling's basis is intact.
Which platform-permission mechanism, and why both
The ruling names two, and they are not synonyms — they are two independent channels by which the same underlying grant (an unscoped
admin_full_access) reaches anExecutionContext:manage_platform_settings— ascope: 'platform'capability oncontext.systemPermissions.admin_full_accesscarries it;organization_admindeliberately withholds it (it gets onlymanage_org_users/setup.access/setup.write), which is exactly what makes it a platform-vs-tenant discriminator — the same reasoning plugin-security'sPLATFORM_ADMIN_ONLY_CAPABILITIESprobe already encodes.platform_admin— a built-in position (ADR-0068 D2) oncontext.positions, derived by the shared authz resolver from that unscoped grant, never a stored boolean.A context built by the shared resolver carries both; a hand-built one (the population ADR-0096 D3 is still eliminating, and which plugin-security's probe comment names the sharing service as part of) may carry only one. Accepting either is therefore the fail-safe reading of a ruling that names both, and cannot silently over-refuse a genuine platform operator. The check deliberately does not widen to the whole
PLATFORM_ADMIN_ONLY_CAPABILITIESset — that would authorize more than was ruled.403, not 404 — asserted as two separate facts
The row is deliberately visible (that is what #7760 opened), so a 404 would be the platform lying about a row the caller can list and read one call earlier. The tests assert the ADR-0112 code token exactly, then pair it against the spec's own
HttpStatusErrorCodeMap— it must be the code the platform pairs with 403, and must not be either 404-shaped code (RESOURCE_NOT_FOUNDor this route'sRULE_NOT_FOUND). A 404 regression therefore fails as a named, different assertion instead of sliding through a loose "it threw" check. One test makes the ruling's reason itself executable: the same refused caller can still read the row it may not delete.Ablation — direction predicted before the fix existed
Predicted: the four refusal tests flip red, every permitted-side and #7760 read-surface pin stays green; plain red, not "more diagnostics" and not inverted. Measured by removing the guard call from
deleteRuleand re-running:Red for the right reason — the delete succeeds instead of being refused — and every read-surface pin held. Restored from the commit (
git checkout branch -- path), back to 514/514.One pre-existing fixture re-triaged, not batch re-spelled
[ADR-0111 D6] manage_sharing authorizes the full surfacewent red. ItsRULE_ADMINpersona carried noorganizationId, sodefineRulestampedorganization_id: nulland the rule it created to exercise the capability gate was incidentally platform-global — a shape no org admin can author through the API at all. Disposition: add the missing declaration (the REST layer never builds a tenant admin without an org), so the test keeps measuring its own subject. Not re-spelled away, and not silenced.Swept the rule's consumption radius rather than the edited package:
deleteRulehas no other service-level caller —packages/client's test drives a mocked HTTP endpoint, and theISharingRuleServicesignature is unchanged.Verification
pnpm --filter @objectstack/plugin-sharing test— 514 passed (20 files), 15 newpnpm --filter @objectstack/plugin-sharing typecheck— cleanscripts/pm/dispatch-gates.mjs:check:engine-double-contract(OK — no new fake engine, the existing one is reused),check:test-source-alias,check:changeset-gate-self-tests,check:objectui-changeset,check:docs-audit-scope,check:nul-bytes,check-changeset-no-major— all greencheck:query-options-erasure(ratchet holds, no files added, test surface at the ceiling) andcheck:type-check-debt(OK, none above its recorded number — ledger untouched), both run after a fullpnpm buildclosureeslintclean on both changed files; control-byte self-scan cleanMerged
origin/main(5 commits) before opening — none touchedplugin-sharing,packages/specdid not move, suite re-run green after the merge.Recorded, not built
The ruling explicitly declines direction D (a per-org suppression mechanism) absent measured demand, and accepts the consequence it names: after this lands, an org admin has no lever at all over a platform-global rule. That consequence is real and is recorded in the guard's docblock — not worked around here.
Generated by Claude Code