Repository navigation
fix(core,plugin-auth)!: the last-admin guard judges a sys_organization insert that moves the Default Organization; one Default Organization definition - #25265
Conversation
…efinition; the last-admin guard judges a sys_organization insert Claude-Session: https://claude.ai/code/session_01ERm1aSqQLiSN87PFmwRfUV Co-authored-by: Claude <noreply@anthropic.com>
…eremony's attribution of a NULL admin_full_access grant Claude-Session: https://claude.ai/code/session_01ERm1aSqQLiSN87PFmwRfUV Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERm1aSqQLiSN87PFmwRfUV Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 27 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 60 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 c17d4745c364a07326caa8a359dd182ba1aad10c && git checkout c17d4745c364a07326caa8a359dd182ba1aad10c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0f1eeeeeb754fc7bbfe64d89e2a305048a75ac7c 6f6e90033c775667aeb3a6bb20fa88d74f1cfd08 && git checkout -B drift-repro 0f1eeeeeb754fc7bbfe64d89e2a305048a75ac7c && git merge --no-ff 6f6e90033c775667aeb3a6bb20fa88d74f1cfd08
node scripts/docs-audit/affected-docs.mjs --json 0f1eeeeeb754fc7bbfe64d89e2a305048a75ac7c
|
Contract reviewServed-tier: Inputs read: card #25208 (body, claim 6345294692, os-dev-report 6348218751); record 6253205661 on #15212; PR #25265 body and its 13-file list; the net diff ① Derived judgments1. The
2. One Default Organization definition — right; all seven former copies compute the same organization.
3. The ceremony pin — right, as carrier 2 asked. 4. Published surface of 5. The dev's residual — 6. R1–R4. R1 (a fresh install boots and serves under ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
|
Seat ACCEPT · epic PM Seat check
|
Fixes #25208
Clause-②: yes (narrowing)
Metadata-protocol: none
The three 8c-2 review carriers of record 6253205661 (owed scope of #15212, claim 6345294692).
1. The anchor guard: measured, then a
beforeInserthalfMeasurement first, on
mainate58bd0e3. I used a real ObjectQL engine over better-sqlite3, with the real last-admin guard and the realresolveAuthzContext. Thesingleposture has a sole organization sluggedacme, and anadmin_full_accessgrant owned by it is the only standing ofusr_admin.busr_adminisplatform_adminusr_adminis no longerplatform_admin; a ban ofusr_adminis then admitted (the guard counts nobody, so it reads the bootstrap window)default, then a grant of the set owned by it tousr_busr_adminadmin,usr_bnotusr_adminloses standing;usr_bgains itSo the anchor can move. (A) is the fail-open the review named: the silent loss also disarms the guard for the next write.
What the code and ADR-0131 D5 support: refuse, judged like the update and delete halves. D5 makes the Default Organization's grant the anchor under
single. D3/D5 define the Default Organization as the one sluggeddefault, else the only one. So keeping the anchor fixed by writing to an organization on the caller's behalf is not available: re-slugging a row the caller did not touch would be a write nobody asked for. The guard is a break-glass invariant (ADR-0135 D5.2). Its update and delete halves already refuse a Default Organization re-slug or delete only when no administrator who can sign in would remain. The insert half asks the same question with the same enumerate, simulate, enumerate machinery, so it cannot drift from them:guardOrganizationInsertis registered onbeforeInsertofsys_organization, priority 20, with the guard's package.PendingStandingWrite.inserted, as the activation-ledger insert half does). A payload with no id yet is simulated under a placeholder id that no grant can name.PERMISSION_DENIED/ 403 onsys_organization. The refusal says what moved and names the remedy: give the current Default Organization the slugdefaultfirst (while it is the only one, that changes nothing else), or grant another account standing.OS_PLATFORM_OWNER_EMAILadministrator, or adefault-slugged Default Organization remains. ⛔ It is not standing preservation. A platform administrator who also owns an organization keeps that standing, which the invariant counts, so the insert is admitted and the grant stops anchoring. That is the same verdict the update half gives a re-slug.Reach, measured through
os serve(production,single). Both public create doors already refuse a second organization: better-authPOST /auth/organization/createanswers403 ORGANIZATION_CREATION_DISABLED, andPOST /api/v1/data/sys_organizationanswers405 OBJECT_API_METHOD_NOT_ALLOWED(that object's allowed list still namesimport, which I did not measure). So the half guards the writers below HTTP: seeds, scripts, a host's own engine calls and imports.2. The ceremony pin
The acceptance fixture of
organization-ownership-apply.test.tsnow holdsgrant_promoted: anadmin_full_accessgrant with a NULLorganization_id, in the 17.x shape (by id to anadmin_full_accessmirror row). The test readsreadDefaultOrganizationId(@objectstack/core) over the same SQLite database before the ceremony. It pins that the grant is attributed to that answer (org_default), and that under a wall it stays unattributed.Two fixture consequences:
admin_full_access, as the composed security plugin ships it.catalog({ adminSetDeclared: false })), which is its premise. The walled test'ssys_permission_setcount moves 3 to 4 (the extra mirror, not deleted under a wall).3. One Default Organization definition
@objectstack/coreexports the rule once, plus the resolver's read:findDefaultOrganization(read):slug = 'default', else the only organization, else none. The answer reportsseveraltoo. It is parameterised by the read, because the readers differ on purpose in how they read, never in what they pick.DEFAULT_ORGANIZATION_SLUG.readDefaultOrganizationId(ql): the resolver's former private function, now exported. Its read fails loud on an unreachable store and reads a never-provisioned one as none.resolve-authz-context.ts(readDefaultOrganizationId)tryFind, exportedAuthzStoreUnavailableError, registry check)default-organization-invariant.tsambiguousreport)ensure-default-organization.ts(plugin-auth)tryFindslug = 'default'; under a wall no sole-organization leg applies (comment in place)last-admin-guard.tsscan's fail-closed ceiling, which is why it cannot call the resolver's readtenancy-service.ts(resolveDefaultOrgId)findRowsnull, and a failed read reads as nonesuggested-audience-bindings.ts(resolveDefaultOrganizationId)scopedSystemCtx(), and a failed read throws (the promotion decides)organization-ownership-plan.tsslugcolumn has nodefaultto findTwo edge answers move to the resolver's:
default-slugged row with a blank id now falls through to the only-organization reading. It used to name nothing.Tests and proof
All runs are at
6f6e9003(after mergingmain) unless noted.last-admin-guard.default-organization.test.ts: 6 cases, 15/15 in the file.default-organization.test.ts(core): 8.scripts/ablation-replace.mjs, each restored to the HEAD blob,git diff HEADempty):enforceStanding: 3 pins red.=== 1changed to>= 1: 2 red.:584) fails ahead of the new pin, because the same attribution moves that grant's access line.2c165f8b: plugin-auth 185 files, 3493 passed. plugin-security 208 files, 4274 passed. core 110 files, 2638 passed. Each part exited 0.&&, exit 0.typecheckof core, plugin-auth, plugin-security and cli: exit 0.node scripts/pm/dispatch-gates.mjs --commandsat2c165f8b:check-plugin-teardown-shape --self-testneeds an unshallow clone;check:dual-build-cjs-loadsneeds the full build.--ran, with exit codes recorded: 76 accounted.check-changeset-fixedandcheck:optional-error-sinkexit 0.check-governed-merges --branch: 0 of 13 paths governed. 633 changed lines (+516/−117). Not Tier H or S.os serveofexamples/app-crmundersingle, on a fresh SQLite file: health 200, sign-up 200, sign-in 200, session 200. The harness's fresh-store leg passes too.os serveofexamples/app-crmunderisolatedand undergroup.@objectstack/organizationswas declared in the app for the run and reverted after. Both: health 200, sign-up 200, sign-in 200, two organizations created throughPOST /auth/organization/create(200 each, through the new hook's wall path), session 200, 0 organization-less grants.upgrade-18x-harness.dogfood.test.ts, both fixture legs, 18.0 and 18.0.0, plus the fresh store): 16/16.6f6e9003when this body was written. The result is in the os-dev-report on fix(core,plugin-security): ADR-0131 8c-2 review carriers — guard a sys_organization insert that moves the platform-admin anchor, pin the ceremony's NULL admin_full_access attribution, one Default Organization definition #25208.Acceptance notes
admin_full_accessanchor predicate stays in two places. The guard'sanchorOwnerTestand the resolver'sgrantAnchorsPlatformStandingeach spell it. Record 6253205661 proposed exporting it with the definition. This card's surface is the Default Organization definition, and the guard's form reads the simulated Default Organization, so it is not the same function.ambiguous.organizationCountfield is always 2. That is what the boot invariant could measure: the rule reads at most two rows. plugin-auth's boot warning therefore says "holds 2 organizations" for any number above one. This is unchanged and pre-existing.importonsys_organization(listed as allowed by the data API undersingle) reaches the engine'sbeforeInsert.Generated by Claude Code