Skip to content

feat(auth): walled deployment's declared owner is email-verified at operator-provisioned creation - #12761

Merged
hotlong merged 2 commits into
mainfrom
claude/issue-12751-walled-owner-operator-verified
Aug 27, 2026
Merged

feat(auth): walled deployment's declared owner is email-verified at operator-provisioned creation#12761
hotlong merged 2 commits into
mainfrom
claude/issue-12751-walled-owner-operator-verified

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#12751
Part of objectstack-ai/cloud#1653

What this does

On a walled deployment, the account whose email equals the declared platform owner (OS_PLATFORM_OWNER_EMAIL) is stamped emailVerifiedat creation when it comes into existence through an operator provisioning path — the maintainer's ruling on cloud#1677 (2026-08-28, verbatim): 「运营方创建即视为已验证」. The trust anchor is the operator's env-var declaration plus the operator-executed creation, not a mailbox round-trip; SMTP stays required only for inviting others. This extends the #11343 precedent (dev-boot seeded admin) to production walled boots, whose declared owner previously could NEVER verify — and therefore never elevate — when the deployment wired no mail transport and no federated sign-in (the shipped EE .env.example shape).

Mechanism

  • Decision (plugin-auth/src/walled-owner-operator-stamp.ts): walled posture family (postureEnforcesWall over the REQUESTED posture — the elevation gate's own input) + declared-owner email match (compared exactly the way bootstrapPlatformAdmin compares: trimmed, case-insensitive) + operator-provisioned creation class.
  • Staging (AuthManager.validateAudienceAdmission): the one admission seam every creation path flows through, where the vendor's source.method signal and the bootstrap probe already live. An admitted creation matching the decision stages a one-shot stamp keyed by the lowercased address (TTL-pruned, mirroring the feat(spec,auth)!: one declared audience posture for who may self-register into an app — invite_only|email_domain|open, default invite_only #11739 staged-grant pattern).
  • Consumption (composed user.create.before database hook): the staged stamp is consumed once and the row is BORN emailVerified: true — the same at-creation shape as a trusted-SSO insert. A later email UPDATE to the owner address structurally cannot inherit it: the seam only a creation traverses.
  • Elevation needs no new trigger: the creation write already replays bootstrapPlatformAdmin (shouldReplayBootstrapFor, create arm), which now finds a verified owner match and elevates — the same proven chain a clicked verification link used to drive.
  • Shared predicate: the [security] 围墙提权只按未验证邮箱匹配 —— #11184 落地后仍存在一条更窄的同形路径:抢在 owner 之前用其邮箱注册即可被提权 #11343 verified-representation allow-list moves from plugin-security's private copy to @objectstack/types (isEmailVerifiedUserRow) so the elevation gate and the boot diagnostic read ONE resolution. plugin-security behaviour is unchanged (alias to the shared function).

Which operator paths qualify, and why (for the EE docs)

The classification is deliberately the existing #11739 audience taxonomy (classifyCreationMethod), not a second parallel reading:

  1. The bootstrap carve-out (self-serve class with zero human users — the very first account on a fresh install) — qualifies. On a walled deployment self-registration is closed; the one self-serve creation a fresh walled boot admits is the bootstrap carve-out, which exists precisely because the first account is presumed to be the operator standing the deployment up ("a fresh install must never lock its operator out"). The declared-owner match narrows that presumption to the single address only the operator's environment declaration names. Residual exposure — a stranger reaching the sign-up endpoint of a freshly booted walled deployment before the operator, typing the operator's own declared address — is the same first-account trust the carve-out already extends; the pre-existing outcome of that race was already a deployment dead for its owner (address squatted unverified, elevation refused forever), requiring operator intervention either way. The ruling accepts declaration + creation act as the anchor.
  2. Admin create-user / bulk import (method: 'admin') — qualifies. This creation only exists inside an authenticated admin session; it IS the provisioning mechanism the closed postures point operators at.
  3. SCIM (method: 'scim') — qualifies. Provisioning executed by the operator-registered directory: registering the IdP is the operator declaring "this directory is my audience", and the provisioning request is that declaration acting.

Never qualifying:

The boot warning follows the store now

WALLED_OWNER_NO_VERIFICATION_PATH previously fired on every walled boot with no transport and no federated sign-in — including the fresh deployment this change makes healthy, and every later boot of a settled, verified deployment. The predicate now reads a probed owner-account state (probeWalledOwnerAccountState, mirroring the elevation gate's reads and the shared allow-list):

Consumer acceptance (cloud EE e2e — the two red cause-2 cases)

Ran apps/objectos-ee/test/signup-membership-policy.e2e.test.ts (cloud repo, origin/main fixtures, unmodified) on a dedicated detached cloud worktree whose node_modules/@objectstack/* symlinks were repointed (177 links, prefix substitution) to THIS branch's full build — verified by content: the CLI-resolved plugin-auth/dist/index.js carries the stamp code (marker grep, 2 hits) before any result was trusted.

  • Before (diagnosed on cloud#1677): both walled cases died in foundAdmin — "the platform admin never received their default-organization owner row" (owner registers, unverified, elevation refused forever).
  • After: both walled cases pass foundAdmin — the declared owner registers through the bootstrap carve-out on a production-mode serve (real license, OS_TENANCY_POSTURE=isolated, no transport), is born verified, elevated, and receives the default-organization owner row. The residual failure in each case is the SECOND user's bare self-registration (SELF_REGISTRATION_CLOSED) — that is cause 1, owned by the in-flight fixture PR objectstack-ai/cloud#1680; cloud#1677 remains open until both halves land per its recorded sequencing.
  • Cross-check on the cause-1 branch tip (08adcb30, fixture + this framework): 2/5 pass; the 3 failures are all bare self-registrations of second users (including its new cloud#1509-shaped case, where the declared owner arrives AFTER a bystander) — an intermediate branch state. Sequencing note for that PR: under the ruled contract an invitation-admitted owner deliberately does NOT inherit the stamp, so a late-arriving owner in that new case must arrive through an operator act (or a wired transport) to elevate.

Tests

  • plugin-auth/src/walled-owner-operator-stamp.test.ts (21): the pure decision matrix (every bound), the store probe over a real ObjectQL engine, and the wiring end-to-end through the real better-auth pipeline — owner-via-bootstrap born verified (the consumer lane), owner-via-admin-create born verified with a non-owner control, seed lane (walled dev boot) unchanged in effect, invited owner NOT stamped, unwalled NOT stamped, non-owner NOT stamped, email-change inherits nothing. Read-back uses the shared elevation-gate predicate, so green means "the gate would elevate this row".
  • plugin-auth/src/auth-plugin-walled-owner-verification-path.test.ts (24): the warning's two-sided contract, including the flipped pin (fresh production walled boot now SILENT) and every keep-warning shape.
  • @objectstack/types/src/email-verified.test.ts: the allow-list pinned representation by representation.
  • Full suites green: plugin-auth 1686, plugin-security 1578, types 377 (each package's own vitest verdict line, exit codes captured before any pipe).
  • Ablation (mutation proven on disk): decision short-circuited to false (anchored replace; anchor count 1 to 0, marker 0 to 1 verified by grep before running) — 7 failed / 14 passed, exactly the stamp cells red and the "never" cells green; restore proven byte-identical to the HEAD blob (git hash-object match + clean git diff HEAD + marker count 0), re-run 21/21 green. Tests exercise package src via relative imports (no dist in the loop), so no rebuild leg applies — stated per contract. A first ablation attempt was a proven no-op (perl delimiter collision, anchor count unchanged) and was discarded without taking a measurement.
  • Derived gates at fdffe4a32 (scripts/pm/dispatch-gates.mjs, no paths passed): all path-derived and convention-derived families run green locally, including check:cross-package-test-inputs, check:engine-double-contract, check:where-matcher, check:type-check-coverage, check:type-check-debt (its own line: "31 ledger entries re-measured … none above its recorded number"), check:i18n ("OK (9 packages)"), check:nul-bytes, changeset gates. The two docs-drift-check scripts are advisory and need PR context — left to CI. check-half-states is a report-only board patrol (exit 0 with an authenticated token).

No new feature-gated env inputs are introduced (the change reads the existing OS_PLATFORM_OWNER_EMAIL / OS_TENANCY_POSTURE), so the bidirectional feature-gate ledger is not applicable.

Verification note: heavy runs were executed through scripts/pm/os-verify-lock.sh where possible; on this macOS host the entry point itself declares UNLOCKED MODE (no usable flock), so all runs above executed unserialised — declared here per the resource contract.

Session: session_71836b57-5db6-459d-ae4d-c6d9d7dd2cc6

Generated by Claude Code

os-zhuangand others added 2 commits August 28, 2026 00:51
…perator-provisioned creation
The declared platform owner (OS_PLATFORM_OWNER_EMAIL), created through an
operator provisioning path on a walled deployment — the bootstrap first
account, admin create-user, SCIM — is stamped emailVerified at creation
(maintainer ruling 2026-08-28, cloud#1677). The decision lives in
walled-owner-operator-stamp.ts; AuthManager stages it at the admission gate
and consumes it in the composed user.create.before hook, so the row is born
verified and the existing create-replay elevates it. The
WALLED_OWNER_NO_VERIFICATION_PATH warning now follows the probed owner
account state: silent on a fresh walled boot (the stamp covers it), loud on
an owner account already existing unverified or a spent bootstrap window.
The 11343 verified predicate moves to @objectstack/types so the gate and the
diagnostic read one allow-list.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/plugin-auth, @objectstack/plugin-security, @objectstack/types, touching 18 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/types/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/contracts/auth-service.mdx(via AuthManager (symbol))
  • content/docs/kernel/services-checklist.mdx(via AuthManager (symbol))
  • content/docs/permissions/authentication.mdx(via AuthManager (symbol))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/types/src/index.ts) — pages documenting those are invisible to this run
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 20 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 5700d83b3c8b1c9396de96f87611c6f128f26528packageMentionDocs.

Which tree this was computed on

This run read content/docs from 89ef1b0e9c824b98c577eb7633fa9d5912c5cacc — the merge of head fdffe4a3291a4ac31eb0d01a120e14362250de33 into base 5700d83b3c8b1c9396de96f87611c6f128f26528, 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 89ef1b0e9c824b98c577eb7633fa9d5912c5cacc && git checkout 89ef1b0e9c824b98c577eb7633fa9d5912c5cacc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5700d83b3c8b1c9396de96f87611c6f128f26528 fdffe4a3291a4ac31eb0d01a120e14362250de33 && git checkout -B drift-repro 5700d83b3c8b1c9396de96f87611c6f128f26528 && git merge --no-ff fdffe4a3291a4ac31eb0d01a120e14362250de33
node scripts/docs-audit/affected-docs.mjs --json 5700d83b3c8b1c9396de96f87611c6f128f26528

⚠️ 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 5700d83b3c8b1c9396de96f87611c6f128f26528 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 27, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 27, 2026 17:28
@hotlong
hotlong added this pull request to the merge queueAug 27, 2026
Merged via the queue into main with commit 4d5b4f8Aug 27, 2026
40 checks passed
@hotlong
hotlong deleted the claude/issue-12751-walled-owner-operator-verified branch August 27, 2026 18:33
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(auth): walled deployment's declared owner is email-verified at operator-provisioned creation (extends #11343 to production walled boots)

2 participants

@hotlong@os-zhuang