Skip to content

fix(security): walled postures elevate only the env-declared platform owner, never the first registrant - #11211

Merged
os-sam merged 3 commits into
mainfrom
claude/issue-11184-env-owner-email-walled-elevation
Aug 23, 2026
Merged

fix(security): walled postures elevate only the env-declared platform owner, never the first registrant#11211
os-sam merged 3 commits into
mainfrom
claude/issue-11184-env-owner-email-walled-elevation

Conversation

@os-sam

Copy link
Copy Markdown
Collaborator

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)

SiteWhereRole
Elevationplugin-security/src/bootstrap-platform-admin.ts (promotion step)Promoted the OLDEST human user to cross-tenant admin_full_access, posture-blind. Runs on kernel:ready and re-runs after every sys_user insert (security-plugin.ts middleware), so the first post-boot sign-up was promoted without a restart.
Seed handoffclaimSeedOwnership, invoked right after promotionRides the promotion; under walled it now simply fires when the declared owner registers (see "seeded records" below).
Default-Org bind, singleplugin-auth/src/auth-plugin.ts (ensureDefaultOrganization on kernel:ready + after every sys_user_permission_set insert)Already gated to !postureEnforcesWall(resolveTenancyPosture()) — plugin-auth never binds under walled postures.
Default-Org bind, walledenterprise @objectstack/organizations (cloud repo — not in this tree)Reuses the same plugin-auth helper, which binds the platform admin (oldest cross-tenant grant). The measured "first registrant merged into the Default Organization" was therefore a consequence of the wrongful elevation, not a second defect.
Sign-up auto-joinplugin-auth/src/reconcile-membership.tsAlready honors invite-only and resolves no target org under walled postures — not implicated.
Posture knobresolveTenancyPosture() in packages/types/src/env.ts (canonical read site; throws on invalid values)The CHANGELOG hits are prose; this is where the knob is actually read.

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/organizations package (cloud repo), not by plugin-auth — plugin-auth's call site is already posture-gated to single. 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

  1. New knob (name proposed per the ruling's open slot): OS_PLATFORM_OWNER_EMAILOS_{DOMAIN}_{NAME} config-value shape per AGENTS.md Prime Directive [WIP] Create a new release version #9, domain PLATFORM because the value designates the platform owner account, not an auth-protocol setting. Declared in packages/types/src/env.ts as resolvePlatformOwnerEmail() + PLATFORM_OWNER_EMAIL_ENV, beside the posture family.
  2. Elevation is posture-keyed (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 with reason: 'walled_owner_email_undeclared' and an error log naming the variable — never a fallback to first-registrant. single keeps the oldest-human promotion byte-for-byte (ruling clause 3).
  3. Fail-closed boot refusal (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 and resolveTenancyPosture()'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.
  4. Seeded records: claimSeedOwnership still rides the promotion, which under walled now happens when the owner registers (the bootstrap re-runs per sys_user insert). Until then seeded rows keep owner_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 exact reason values; group behaves as walled; over-denial: single promotes 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 registers auth+tenancy; single boots with nothing declared.
  • Reverse verification (fix files restored to the BASE commit, mutation proven by marker greps 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 via git 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 exact reason values, 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 in packages/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'): declares OS_PLATFORM_OWNER_EMAIL = the dev admin it seeds, caller override wins, restored on stop() alongside the posture knob. Observable state of every walled fixture is unchanged (the seeded admin is promoted; a fresh signUp stays 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) — walled SERVE_ENV fixtures 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 AuthPlugin outside plugin-auth exists only in cli/serve.ts (production path — the refusal is the intended behavior), verify/harness.ts (fixed above) and plugin-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/types as a value, so per the shrink-only registries of check:test-source-alias / check:type-source-resolution its tests and typecheck must read the producer's SOURCE in the checkout, not dist/: an anchored vitest alias and a tsconfig paths rule (+ the consequent rootDir widening, the packages/rest#9960 precedent, its traps documented in place). The new fake engine's update() opens with assertEngineUpdateDispatch from @objectstack/metadata-core and the check:engine-double-contract pinned 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)

  • Build: dependency closures of both plugins, then the full 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.
  • Walled dogfood proof through the patched harness: no-active-organization-write-refusal.dogfood.test.ts 6/6 passed under OS_TENANCY_POSTURE=isolated, boot log shows the declared owner promoted; rls-multitenant.dogfood.test.ts skipped by its own pre-existing skipIf(!organizationsAvailable) (open workspace, unrelated to this change).
  • Typechecks: types, plugin-security, plugin-auth, verify, cli — all tsc --noEmit exit 0.
  • Gate union derived by node scripts/pm/dispatch-gates.mjs (no hand-supplied paths) at 076bf67589; 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-review stays on #11184). Grounds: it removes an elevation path under walled postures, adds a startup refusal, and introduces a new public OS_* knob. Please do not flip it ready, queue it, or arm auto-merge.


Generated by Claude Code

… owner, never the first registrant (#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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/plugin-auth, @objectstack/plugin-security, @objectstack/types, @objectstack/verify, touching 9 documentable anchor(s).

28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 9cc1940a11e1805fc88a1fb943ec41529d2f0b9b.

5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/plugins/plugin-security/package.json, packages/plugins/plugin-security/tsconfig.json, packages/plugins/plugin-security/vitest.config.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 21 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 9cc1940a11e1805fc88a1fb943ec41529d2f0b9bpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 9024b07236e77dbe7063b9ab8de88e2192b5651a — the merge of head 076bf67589a0bb6d8aa0e5c68722e641517faf86 into base 9cc1940a11e1805fc88a1fb943ec41529d2f0b9b, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 9cc1940a11e1805fc88a1fb943ec41529d2f0b9b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Aug 23, 2026
@claude

claudeBot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Contract review PASS at CONTRACT_REVIEW_TIER — verdict with the tier reading and clause-by-clause basis is on the card (#11184). needs:contract-review cleared; the dispatching seat may proceed to landing.


Generated by Claude Code

@os-sam
os-sam deleted the claude/issue-11184-env-owner-email-walled-elevation branch August 23, 2026 13:02
os-sam pushed a commit that referenced this pull request Aug 24, 2026
…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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-sam@claude