Repository navigation
fix(security): the single-posture platform-admin grant is owned by the Default Organization, and every reader of platform standing reads it - #24373
Conversation
…ault Organization, and the resolver and holder scans read it Claude-Session: https://claude.ai/code/session_01A3vH4wwSVnJvvx5BcfUpPf Co-authored-by: Claude <noreply@anthropic.com>
…e Default Organization's grant Claude-Session: https://claude.ai/code/session_01A3vH4wwSVnJvvx5BcfUpPf Co-authored-by: Claude <noreply@anthropic.com>
… boot creates Claude-Session: https://claude.ai/code/session_01A3vH4wwSVnJvvx5BcfUpPf Co-authored-by: Claude <noreply@anthropic.com>
…ollow the Default Organization's grant Claude-Session: https://claude.ai/code/session_01A3vH4wwSVnJvvx5BcfUpPf Co-authored-by: Claude <noreply@anthropic.com>
…e Default Organization, and every reader of platform standing reads it Under the single tenancy posture the first-user promotion writes its admin_full_access grant owned by the Default Organization (ADR-0131 D5), never NULL. The authorization resolver, the holder scan, the default-organization bootstrap and the last-administrator guard read that row beside the organization-less one, so a database the ownership ceremony migrated keeps its administrator and is not promoted again. Claude-Session: https://claude.ai/code/session_01A3vH4wwSVnJvvx5BcfUpPf Co-authored-by: Claude <noreply@anthropic.com>
…te; the guard reads the map's entry Claude-Session: https://claude.ai/code/session_01A3vH4wwSVnJvvx5BcfUpPf Co-authored-by: Claude <noreply@anthropic.com>
…fault-org-admin-grant
📓 Docs Drift CheckThis PR changes 3 package(s): 33 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 — 42 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 2e194e71b39aa8a51fec8197351a8bbf55e6c130 && git checkout 2e194e71b39aa8a51fec8197351a8bbf55e6c130
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6868514b1c73c5fb2f7e1112a4749f182825447d 2af8fd548e43ae089975dfdc45e7e74507e290ed && git checkout -B drift-repro 6868514b1c73c5fb2f7e1112a4749f182825447d && git merge --no-ff 2af8fd548e43ae089975dfdc45e7e74507e290ed
node scripts/docs-audit/affected-docs.mjs --json 6868514b1c73c5fb2f7e1112a4749f182825447d
|
Contract reviewServed-tier: Head reviewed is the one the brief named; it had not moved ( Check-runs on the head: 40, all Face. ① Derived judgments(a)
(b) The Default Organization definition — seven copies, all agree, no reachable topology makes them disagree. Read: core (c) The A3 fix — right; pinned against the ceremony's output SHAPE, not against the ceremony. The ceremony attributes a (d) Cost of Resolver: runs once per Other accept-set and permission changes, each named:
② Semver levelThe declaration is wrong, on the PR and on the changeset, and so are the bumps. Three narrowings and one widening ship under The narrowings the diff performs:
The widening (①a): FROM: under Required text:
③ Boundary flagsQ1 (os-dev-report) — "every Default-Organization Q2 — a multi-organization The organization INSERT that moves the anchor is not guarded — flag, non-blocking, with a named remedy. The guard judges Deviation — widened surface ( Deviation — Deviation — line budget (638 source + 425 test vs ~200 + ~400): accepted. The guard's three read sites, the two organization hooks and their copy account for it; 1,065 changed lines is far from the 3,000-line Tier H threshold. A3 pin is one step removed from the ceremony (①c): the fresh-boot golden models the ceremony's output; the cli acceptance fixture carries no admin grant. Recommend 8c-2 (or a two-line follow-up) adds Four spellings of the anchor predicate, seven of the Default Organization definition (①a, ①b): correct today, lockstep-edit risk for 8c-2, when the Cross-lane pointers (not this PR's): Blocking items (the fix each needs):
Everything else in the diff is right as judged in ①; the non-blocking flags above carry their own remedies and carriers. A re-render is owed on the new head once the changeset and PR body are re-cut; the code paths need no change for this verdict to flip. Implemented-by: VERDICT: FAIL |
…s (narrowing), minor, with its FROM → TO pairs and ADR-0087 disposition Claude-Session: https://claude.ai/code/session_01A3vH4wwSVnJvvx5BcfUpPf Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Rendered by an isolated at-tier reviewer subagent of the adopting epic seat ( What moved since 6253115144, verified: Check-runs on the head: 47, all Face. No ① Derived judgmentsEvery judgment is on the same code 6253115144 read; re-read at head line numbers and re-judged, not inherited. (a) (b) The Default Organization definition — seven copies, one definition; the resolver's and the guard's are read-only. Re-read at head: core (c) The A3 fix — right, and the live defect it fixes is real on base. The ceremony attributes a (d) The cost of Every other accept-set, permission and surface change the diff implies, named:
② Semver levelThe declaration, the bumps, the banner, the pairs and the disposition now match what the diff publishes, and the two gates that read them concluded
No wrong text remains in the changeset or the PR body. One optional precision, not a blocker: the PR body's "No spec, REST answer or package entry changes" could read "no spec, REST answer or export-list changes" (①, the two exported maps gain a key). ③ Boundary flagsEvery dev flag and Q1 (report) — every Default-Organization Q2 (report) — a multi-organization Deviation — file surface widened beyond the claim ( Deviation — Deviation — line budget (638 source + 425 test vs about 200 + 400): accepted; 1,074 changed lines on the head, under the 3,000-line Tier H threshold; the overrun is the guard's three read sites, two hooks and their copy. Deviation — worktree left detached and re-attached; harness trailer replaced by AGENTS.md's model-free pair: process notes with no bearing on the diff. Verified on the head's seven non-merge commits: each ends with Addendum deviation — PR body rewritten through the relay's Out-of-scope findings the dev carried, each with its carrier on this head:
Card residue: the Constraints paragraph's "config now required for admin" is the superseded clause the ruling did not name (card body, comment 5725699684); the PR requires no configuration under Acceptance criterion, as rewritten under ruling A: a production-mode Blocking items: none. The two items 6253115144 listed are discharged on this head: the changeset carries Implemented-by: VERDICT: PASS |
Fixes #11979
Part of #15212
Clause-②: yes (narrowing)
C8 stage 8b-0a (plan 6251466182 on #15212). Under the
singletenancy posture the first-user platform-admin promotion keeps its zero-config behaviour, and theadmin_full_accessgrant row it writes is now owned by the Default Organization (ADR-0131 D5, maintainer ruling A on #18707). Every reader of platform standing reads that row beside the organization-less one. The walled posture is unchanged.What changes
The promotion writes the Default Organization (
packages/plugins/plugin-security/src/bootstrap-platform-admin.ts). The grant carriesorganization_id= the Default Organization:slug = 'default', else the only organization. That is plugin-auth's boot-invariant definition, resolved through the module's existingresolveDefaultOrganizationId. A deployment that names no Default Organization (several organizations, nonedefault) is not promoted. It logs oneerrorline and returnsreason: 'default_organization_unresolved', and writes no grant without an owner. The holder scan (leg A, and the predicate legs B and C apply) reads the Default Organization's grant and the organization-less one. A user holding both is one holder, and whichever row sorts first byidanswers.The resolver's anchor is one predicate with the posture as its parameter (
packages/core/src/security/resolve-authz-context.ts).grantAnchorsPlatformStanding(grant, { walled, defaultOrganizationId }): under a wall nothing anchors (unchanged); undersinglethe Default Organization's grant and the organization-less row anchor. The anchoring grant applies in whichever organization the principal acts in, as the organization-less row always did. Any other organization's grant of the set confers the set, never standing.sys_organizationis read only for a principal holding an organization-ownedadmin_full_accessgrant undersingle, so the batch-equivalence goldens do not move.plugin-auth reads the same row (
ensure-default-organization.ts). The grant anchor reads the Default Organization's grant. Undersingle, step 4 reuses the Default Organization by the invariant's definition (a sole organization with another slug included) instead of minting a second,default-slugged one that would take the anchor off the promoted grant. Under a wall only thedefault-slugged one is reused, as before.The extra
sys_organizationreads, and when they run. The resolver reads it only undersingle, and only for a principal holding an organization-ownedadmin_full_accessgrant: one small indexed read when adefault-slugged organization exists, two otherwise, per uncached request (deduplicated within a request; the cross-request grants cache is off by default). Every other principal reads nothing more, which is why the batch-equivalence goldens (whose fixture holds the organization-less row) do not move. The last-administrator guard adds one or two reads per enumeration whenever anadmin_full_accessgrant names an owner, so about three to six small reads per guarded write onsys_user,sys_member,sys_user_permission_set, the activation ledger andsys_organization.Landing points outside the claim's file surface, and why
packages/plugins/plugin-auth/src/last-admin-guard.tsis the break-glass guard. Its enumeration, its "emptied rather than fresh" evidence reads and its switched-off-set read all keyed on!organization_id. Left alone, the promoted admin's grant would count as no administrator, and the guard would wave through the ban or revocation that ends the last one. It now reads the anchor the resolver reads. Because the anchor now depends onsys_organization, it also judges the two organization writes that move it: changing the Default Organization'sslugwhile another organization exists, and deleting it.packages/core/src/security/admin-standing-surface.tsgainssys_organizationas aderivestable (columnsid,slug, measured byadmin-standing-surface.test.ts). The f8eb736 correspondence gate turned red without it, and is green with the guard's new standing-key list.A3 — measured: the ceremony attributes the promoted admin's grant to the Default Organization
On
18.0.0-next.3semantics (this branch's basee4bcced0), with the cli acceptance fixture oforganization-ownership-apply.test.tsplus one promoted-admin grant (grant_admin,organization_idNULL,permission_setadmin_full_access). This was a scratch copy of the fixture run under--project integrationand deleted afterwards:So after
os migrate organization-ownership --applythe grant is the Default Organization's. On the base tree that row conferred nothing. The resolver read!ownGrantonly: the new[ADR-0131 D5]resolver tests fail on base (2 of 6). And the holder scan found no NULL holder and promoted again: the new fresh-boot golden "an upgraded database whose administrator's grant the ceremony gave the Default Organization promotes nobody else" fails on base and passes here. That is the live defect the dispatch suspected. Therewritestep blocked in that scratch run only because the fixture's catalog double serves noadmin_full_access, which has nothing to do with attribution.Tests
On the final head
bc90a53af6(after mergingorigin/main):pnpm --filter @objectstack/core test: 104 files, 2558 passed.pnpm --filter @objectstack/plugin-security test: 188 files, 3739 passed, 45 skipped.pnpm --filter @objectstack/plugin-auth test: 162 files, 3140 passed, 10 skipped.typecheckfor core, plugin-security and plugin-auth: exit 0 (plugin-auth after its build, which its examples project reads).packages/qa/dogfood, on a full build):admin-platform-admin-standing,org-admin-affordance-reachandadmin-credential-lifecyclepass, 26 tests. The boot log showsfirst user promoted to platform admin: admin@objectos.aithree times and no withheld promotion.New or flipped pins assert the substance:
nullbecomesorg_default;errorand writes nothing;default, and never under a wall, which reads no organization;ensureDefaultOrganizationbinds the Default Organization's grant holder.The test fixtures that promote now hold the Default Organization the
singleboot creates.Reverse verification. The fix was committed first. The four source files were then restored to base
e4bcced0and the new tests run, and the files restored to HEAD (proved by an emptygit diff HEADand HEAD-blob hash equality). Results on base: core D5 suite 2 failed; plugin-security fresh-boot golden 3 failed; plugin-auth guard and default-org suites 7 failed. All green on HEAD.Gates
node scripts/pm/dispatch-gates.mjs --commandsderived 73 families at11016ab9. All 73 ran with exit 0 after a full build (check:i18nandcheck:dual-build-cjs-loadsanswered PREREQUISITE NOT MET before it, and green after).--ranreconciliation: "73 derived famil(ies) accounted for — 73 run, 0 NOT-MEASURED (a DERIVED zero)". The derivation is unchanged on the final head. A subset (check:published-files,check:cross-package-test-inputs,check:durability-log-level,check:startup-registry-verdict,check:issue-citations,check:doc-authoring) was re-run after the last source commit, all exit 0. Every family CI runs is read from this PR's check-runs.Acceptance notes
minoron all three packages under the launch-window convention; the changeset carries the three FROM → TO pairs with their fixes and the ADR-0087 dispositionnot-required (no-migration-prescription). Narrowings: (1) plugin-security — asingledeployment with several organizations and none sluggeddefaultis no longer promoted (oneerrorline,reason: 'default_organization_unresolved', no row); fix: slug the intended organizationdefault. (2) plugin-auth — asys_organizationslugchange or delete that would leave no administrator who can sign in is refused (403PERMISSION_DENIED); fix: give another account administrator standing first. (3) plugin-auth —ensureDefaultOrganizationundersinglereuses a sole organization of any slug instead of creating a seconddefault-slugged one. Widening: undersinglea Default-Organization-ownedadmin_full_accessgrant now confersPLATFORM_ADMINand applies in every organization (a pre-existing Default-Organization-scoped grant gains the rung on upgrade), andsys_organizationbecomes a standing-deriving table. No spec, REST answer or package entry changes: the new core helpers and the guard's organization key list are module-private; the bootstrap's return gains onereasonstring.single, by D5's "until D10's migration clears it".singleposture — first-user promotion becomes development-only fallback #11979's Constraints paragraph ("config now required for admin") is superseded by ruling A, as the card's discharged note says. Nothing here requires config undersingle.Generated by Claude Code