Uh oh!
There was an error while loading. Please reload this page.
fix(security): walled postures elevate only the env-declared platform owner, never the first registrant - #11211
Conversation
… owner, never the first registrant (#11184) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
…changeset (#11184) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
…ture-aware promotion log (#11184) check:test-source-alias / check:type-source-resolution: plugin-security now imports @objectstack/types as a value, so tests and typecheck must read the producer's source in the checkout (vitest alias + tsconfig paths, per the packages/rest #9960 precedent). check:engine-double-contract: the new fake's update() opens with assertEngineUpdateDispatch; ledger learned the pin via --write. The promotion log now names which policy selected the target. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
📓 Docs Drift CheckThis PR changes 4 package(s): 28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 21 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 9024b07236e77dbe7063b9ab8de88e2192b5651a && git checkout 9024b07236e77dbe7063b9ab8de88e2192b5651a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9cc1940a11e1805fc88a1fb943ec41529d2f0b9b 076bf67589a0bb6d8aa0e5c68722e641517faf86 && git checkout -B drift-repro 9cc1940a11e1805fc88a1fb943ec41529d2f0b9b && git merge --no-ff 076bf67589a0bb6d8aa0e5c68722e641517faf86
node scripts/docs-audit/affected-docs.mjs --json 9cc1940a11e1805fc88a1fb943ec41529d2f0b9b
|
Contract review PASS at Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
…l match, and the bootstrap replays on the verifying update Closes the remainder #11211 left open (issue #11343): under walled postures the platform-admin elevation matched the env-declared owner email as a raw string, and email verification is off by default — so an account created with the owner's address before the owner registered would be elevated. Both halves land together, deliberately: 1. bootstrapPlatformAdmin: the walled owner-email match must be VERIFIED (fail-closed allow-list over email_verified representations; absent reads unverified). Unverified holder => refused with the new reason walled_owner_not_verified, logged loudly with the unblock in the line, never falling back — the same direction as the undeclared-owner refusal. 2. security-plugin bootstrap-replay middleware: also fires on sys_user updates touching email_verified / email (trigger set extracted as shouldReplayBootstrapFor, consumed by middleware and pins alike). Verification is an UPDATE; with the old insert-only replay, half 1 alone would refuse the genuine owner at sign-up and never look again, leaving the platform without any administrator. single posture is untouched in both directions. Pins cover both invariant directions: the unverified holder is refused AND the verified owner is elevated, including across the refuse-then-verify-then-re-run sequence. Fixes#11343 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
Fixes#11184
Framework leg of the p0 measured in objectstack-ai/cloud#1509 (that card is the acceptance leg and stays open). Maintainer ruling 2026-08-23, verbatim and untranslated: 「1509 选择 env 指定 owner 邮箱」.
The map (establish-first, measured on
origin/main@ 52a41b7)plugin-security/src/bootstrap-platform-admin.ts(promotion step)admin_full_access, posture-blind. Runs onkernel:readyand re-runs after everysys_userinsert (security-plugin.tsmiddleware), so the first post-boot sign-up was promoted without a restart.claimSeedOwnership, invoked right after promotionsingleplugin-auth/src/auth-plugin.ts(ensureDefaultOrganizationonkernel:ready+ after everysys_user_permission_setinsert)!postureEnforcesWall(resolveTenancyPosture())— plugin-auth never binds under walled postures.@objectstack/organizations(cloud repo — not in this tree)plugin-auth/src/reconcile-membership.tsinvite-onlyand resolves no target org under walled postures — not implicated.resolveTenancyPosture()inpackages/types/src/env.ts(canonical read site; throws on invalid values)Refinement of the dispatch's mechanism assumption: ruling clause 1 and clause 2 do land in different packages, but the walled-posture Default-Org bind is owned by the enterprise
@objectstack/organizationspackage (cloud repo), not by plugin-auth — plugin-auth's call site is already posture-gated tosingle. Inside this repo, clause 2 is fully derived from clause 1: only the platform admin is ever bound, so once self-registrants can no longer become platform admin they can no longer be bound. The cloud acceptance leg re-verifies the composed deployment.The fix
OS_PLATFORM_OWNER_EMAIL—OS_{DOMAIN}_{NAME}config-value shape per AGENTS.md Prime Directive [WIP] Create a new release version #9, domainPLATFORMbecause the value designates the platform owner account, not an auth-protocol setting. Declared inpackages/types/src/env.tsasresolvePlatformOwnerEmail()+PLATFORM_OWNER_EMAIL_ENV, beside the posture family.bootstrap-platform-admin.ts): under walled postures the promotion targets exactly the human account matching the declared owner email (case-insensitive, queried by email so arrival order and user count are irrelevant);reason: 'walled_owner_not_registered'until that account exists. Undeclared owner refuses elevation withreason: 'walled_owner_email_undeclared'and anerrorlog naming the variable — never a fallback to first-registrant.singlekeeps the oldest-human promotion byte-for-byte (ruling clause 3).auth-plugin.tsinit()): walled requested posture + undeclared owner throws, naming the variable and the way out —init()failures abort kernel boot (Phase 1 propagates), matching the walled fail-fast style of ADR-0093 D5 andresolveTenancyPosture()'s own throw. Keyed to the REQUESTED posture deliberately: a walled-requested deployment running degraded (OS_ALLOW_DEGRADED_TENANCY=1) still refuses first-registrant elevation.claimSeedOwnershipstill rides the promotion, which under walled now happens when the owner registers (the bootstrap re-runs persys_userinsert). Until then seeded rows keepowner_id NULL/usr_system— the same state as today's zero-user boot; nothing is orphaned and no self-registrant can claim them. Pre-fix DBs where a first registrant was already wrongly promoted are NOT retroactively demoted (the bootstrap is insert-once by design); remediation of the measured deployment belongs to the cloud acceptance leg.Both directions pinned
plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts(9 tests): walled+declared promotes ONLY the owner (stranger-first arrival, case-insensitive match); walled refusals write no grant row and carry exactreasonvalues;groupbehaves as walled; over-denial:singlepromotes the first human unchanged, and never consults the variable even when set.plugin-auth/src/auth-plugin-walled-owner-boot-refusal.test.ts(6 tests):isolated/group/legacy-boolean/blank-value refusals pin the message (variable name, posture, "Refusing to boot", "first-registrant elevation"); over-denial: walled+declared init succeeds and registersauth+tenancy;singleboots with nothing declared.walled_owner_email_undeclared=0 /PLATFORM_OWNER_EMAIL_ENV=0 on disk, then re-run): plugin-security pins 6 failed / 3 passed — the 3 passes are exactly the unchanged-direction controls; plugin-auth pins 4 failed / 2 passed — the 2 passes are the positive controls. Restored viagit restore --source=HEAD(markers 1 / 3 back), both suites 9/9 and 6/6 green.These refusals are boot/bootstrap outcomes, not HTTP answers, so there is no ADR-0112 envelope to carry
code+status; the machine-checkable pins are the exactreasonvalues, the absence of the grant write, and the thrown message naming the variable (the same shape the ADR-0093 D5 fail-fast pins use). No new error code is registered (the ADR-0112 catalog governs failing-request answers; its ledger lives inpackages/spec, untouched).Consumers swept (fixture triage — declared file-surface amendment)
The claim surface was the elevation site, the plugin-auth auto-join site, the knob declaration site, and their tests. The new walled contract ("a walled boot declares its owner") reaches every fixture that boots AuthPlugin under a walled posture; each was re-judged and DECLARES the owner rather than being weakened:
packages/verify/src/harness.ts— the chokepoint every walled dogfood fixture boots through (multiTenant: true | 'posture-only'): declaresOS_PLATFORM_OWNER_EMAIL= the dev admin it seeds, caller override wins, restored onstop()alongside the posture knob. Observable state of every walled fixture is unchanged (the seeded admin is promoted; a freshsignUpstays a plain member).packages/plugins/plugin-auth/src/auth-plugin.test.ts— the "multi-org: bootstrap is NOT wired" fixture (the existing clause-2 gate pin) declares an owner; its subject is the wiring gate, and the refusal is pinned in the dedicated file.packages/cli/test/serve-organizations-*.e2e.test.ts(2 files) — walledSERVE_ENVfixtures declare an owner; their subject is the organizations-package resolution and the ADR-0093 D5 fail-fast ordering, which is unchanged.Repo-wide sweep evidence:
new AuthPluginoutside plugin-auth exists only incli/serve.ts(production path — the refusal is the intended behavior),verify/harness.ts(fixed above) andplugin-dev(its walled tests mock plugin-auth away; a real walled dev boot now gets the refusal, which is the ruled contract).Source-resolution wiring that rode along
plugin-security now imports
@objectstack/typesas a value, so per the shrink-only registries ofcheck:test-source-alias/check:type-source-resolutionits tests and typecheck must read the producer's SOURCE in the checkout, notdist/: an anchored vitest alias and a tsconfigpathsrule (+ the consequentrootDirwidening, thepackages/rest#9960 precedent, its traps documented in place). The new fake engine'supdate()opens withassertEngineUpdateDispatchfrom@objectstack/metadata-coreand thecheck:engine-double-contractpinned ledger learned it via the gate's own--write.Verification (all on the final commit
076bf67589; full command log in the report comment on #11184)packages/*workspace closure via turbo — green before any verdict was read.pnpm --filter @objectstack/plugin-security test— 73 files / 1386 tests passed (re-run after the alias wiring).pnpm --filter @objectstack/plugin-auth test— 70 files / 1455 tests passed (after the one fixture triage above).pnpm --filter @objectstack/verify test— 8 files / 36 passed;pnpm --filter @objectstack/types test— 13 files / 362 passed.no-active-organization-write-refusal.dogfood.test.ts6/6 passed underOS_TENANCY_POSTURE=isolated, boot log shows the declared owner promoted;rls-multitenant.dogfood.test.tsskipped by its own pre-existingskipIf(!organizationsAvailable)(open workspace, unrelated to this change).tsc --noEmitexit 0.node scripts/pm/dispatch-gates.mjs(no hand-supplied paths) at076bf67589; every named family run to a real verdict — the list and exits are in the report comment.Review status
Clause-②: yes— this PR is draft and stays draft; it awaits a contract-review seat at tier (needs:contract-reviewstays on #11184). Grounds: it removes an elevation path under walled postures, adds a startup refusal, and introduces a new publicOS_*knob. Please do not flip it ready, queue it, or arm auto-merge.Generated by Claude Code