Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .changeset/walled-owner-operator-verified.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
---
"@objectstack/plugin-auth": minor
"@objectstack/types": minor
"@objectstack/plugin-security": patch
---

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

On a **walled** deployment (`OS_TENANCY_POSTURE` in the wall-enforcing
family), the account whose email equals the declared platform owner
(`OS_PLATFORM_OWNER_EMAIL`) is stamped `emailVerified` **at creation** when
it comes into existence through an **operator provisioning path** — extending
the #11343 dev-boot seeded-admin precedent to production walled boots
(maintainer ruling 2026-08-28, cloud#1677: 「运营方创建即视为已验证」; 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).

**Which creation paths qualify** (the [#11739] audience taxonomy, not a
second classification):

- the **bootstrap carve-out** — the very first account on a fresh install
(zero human users), the one self-serve creation a walled boot admits;
- **admin create-user / bulk import** (`method: 'admin'`) — an act only an
authenticated admin session can perform;
- **SCIM** (`method: 'scim'`) — provisioning executed by the
operator-registered directory.

**Never**: non-bootstrap self-registration (including an
invitation-admitted registration typing the owner address), provider-class
JIT (the IdP asserts its own `emailVerified` at insert), any non-owner
address, any unwalled posture, and a later email **update** to the owner
address (the stamp is staged at the admission gate and consumed once by the
`user.create` before-hook — a seam an update cannot traverse). Dev-boot
behaviour (#11343) is unchanged.

The `WALLED_OWNER_NO_VERIFICATION_PATH` boot warning now probes the owner
account's state: a fresh walled boot with no transport and no federated
sign-in is **silent** (the operator's own first-account creation arrives
verified — the case this closes), while an owner account that already
exists **unverified**, a populated store whose bootstrap window is spent,
and an unanswerable probe keep warning. A settled deployment whose owner is
verified stops re-warning on every boot.

`@objectstack/types` gains `isEmailVerifiedUserRow` — the [#11343]
fail-closed verified-representation allow-list, moved from
`plugin-security`'s private copy so the elevation gate and the boot
diagnostic read ONE resolution (`plugin-security` now consumes it; no
behaviour change there).
88 changes: 88 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@ import {
AUDIENCE_CONFIG_ERROR,
type ResolvedAudience,
} from './audience-posture.js';
import { shouldStampOwnerVerifiedAtCreation } from './walled-owner-operator-stamp.js';
import type { IDataEngine } from '@objectstack/core';
// [#10348] The ONE id-shaped platform-admin predicate (ADR-0068 D2).
// `auth-manager` used to re-derive that standing itself, in two spellings
Expand DownExpand Up@@ -3509,6 +3510,20 @@ export class AuthManager {

private static readonly SELF_REG_GRANT_STAGE_TTL_MS = 10 * 60 * 1000;

/**
* [#12751] Owner-verified stamps staged by the admission gate for the
* composed `user.create.before` hook — same shape and lifetime discipline
* as {@link pendingSelfRegistrationGrants} (keyed by lowercased email;
* better-auth lowercases the address on `createUser`, and an in-flight
* duplicate cannot create twice). One entry only ever exists for the
* declared platform owner's address on a walled deployment; TTL pruning
* keeps an admission whose creation never completed from leaking into an
* unrelated later creation of the same address.
*/
private pendingOwnerVerifiedStamps = new Map<string, { stagedAtMs: number }>();

private static readonly OWNER_STAMP_STAGE_TTL_MS = 10 * 60 * 1000;

/**
* Page size of the bootstrap population probe ({@link isBootstrapCreation}).
* Matches the bound the dev-admin seed reads with, so the two ask the same
Expand DownExpand Up@@ -3614,6 +3629,23 @@ export class AuthManager {
}
return { error: verdict.code, errorDescription: verdict.message };
}
// [#12751] Walled deployments: an ADMITTED creation of the declared
// platform owner through an operator provisioning path (operator class,
// or the bootstrap carve-out) is stamped email-verified at creation —
// maintainer ruling 2026-08-28, 「运营方创建即视为已验证」. The decision
// (and the per-path argument) lives in `walled-owner-operator-stamp.ts`;
// this seam only STAGES it, because the admission gate is the one place
// that holds the vendor's own `source.method` signal AND the bootstrap
// probe. Consumed once by the composed `user.create.before` hook, so the
// row is BORN verified; an email UPDATE can never traverse that seam,
// which is what keeps a later change-to-owner-address from inheriting
// the stamp.
if (
email &&
shouldStampOwnerVerifiedAtCreation({ email, creationClass, isBootstrap })
) {
this.stageOwnerVerifiedStamp(email);
}
if (verdict.grantPermissionSet) {
const setName = audience.selfRegistrationPermissionSet;
if (!setName) {
Expand DownExpand Up@@ -3818,6 +3850,32 @@ export class AuthManager {
}
}

/** [#12751] Stage the owner-verified stamp for the address being created. */
private stageOwnerVerifiedStamp(email: string): void {
this.prunePendingOwnerVerifiedStamps();
this.pendingOwnerVerifiedStamps.set(email.trim().toLowerCase(), { stagedAtMs: Date.now() });
}

/**
* [#12751] Consume the staged stamp for this address — one shot: the entry
* is deleted on read, so exactly one creation can be born verified per
* admission, and nothing survives for any later write to inherit.
*/
private takeOwnerVerifiedStamp(email: string): boolean {
this.prunePendingOwnerVerifiedStamps();
const key = email.trim().toLowerCase();
if (!this.pendingOwnerVerifiedStamps.has(key)) return false;
this.pendingOwnerVerifiedStamps.delete(key);
return true;
}

private prunePendingOwnerVerifiedStamps(): void {
const cutoff = Date.now() - AuthManager.OWNER_STAMP_STAGE_TTL_MS;
for (const [key, value] of this.pendingOwnerVerifiedStamps) {
if (value.stagedAtMs < cutoff) this.pendingOwnerVerifiedStamps.delete(key);
}
}

private stageSelfRegistrationGrant(email: string, setName: string): void {
this.prunePendingSelfRegistrationGrants();
this.pendingSelfRegistrationGrants.set(email.trim().toLowerCase(), {
Expand DownExpand Up@@ -5625,6 +5683,35 @@ export class AuthManager {
await membershipReconciler(user);
};

// [#12751] Walled owner-verified stamp, the CONSUMING half: the admission
// gate staged the decision (see `validateAudienceAdmission` and
// `walled-owner-operator-stamp.ts`); this before-hook lands it, so the
// declared owner's operator-provisioned row is BORN `emailVerified: true`
// — the same at-creation shape as a trusted-SSO insert, and the creation
// write then replays `bootstrapPlatformAdmin` (`shouldReplayBootstrapFor`,
// `create` arm), which elevates it with no further verification step.
// `user.create.before` is a seam only a CREATION traverses, so a later
// email UPDATE to the owner address structurally cannot inherit the
// stamp. Host hook chains FIRST and keeps its result shape, exactly as
// `sessionBefore` above does; a host `false` (refuse the creation) is
// honoured before the stamp is even consumed.
const hostUserBefore = (host as any)?.user?.create?.before;
const userBefore = async (user: any, ctx: any) => {
let draft = user;
if (hostUserBefore) {
const hostResult = await hostUserBefore(user, ctx);
if (hostResult === false) return false;
if (hostResult && typeof hostResult === 'object' && 'data' in hostResult) {
draft = { ...draft, ...(hostResult as any).data };
}
}
const email = typeof draft?.email === 'string' ? draft.email : '';
if (email && this.takeOwnerVerifiedStamp(email)) {
return { data: { ...draft, emailVerified: true } };
}
return draft === user ? undefined : { data: draft };
};

return {
...(host ?? {}),
account: {
Expand All@@ -5638,6 +5725,7 @@ export class AuthManager {
...((host as any)?.user ?? {}),
create: {
...((host as any)?.user?.create ?? {}),
before: userBefore,
after: userAfter,
},
},
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,14 @@
* one does, so each neighbouring shape — a transport wired, a federated
* sign-in wired, an unwalled posture, an undeclared owner, and the dev/harness
* boot that verifies its own seeded owner — is pinned SILENT.
*
* [#12751] (maintainer ruling 2026-08-28, 「运营方创建即视为已验证」): the
* operator-provisioning stamp is itself a verification path, so the firing
* now follows the OWNER ACCOUNT STATE the caller probes — a fresh walled
* boot with nothing wired is SILENT (its owner's first-account creation
* arrives verified), while an owner account already existing unverified, a
* populated store with no owner account, and an unanswerable probe keep
* warning. The `#12751` describe below is that two-sided contract's pin.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
Expand All@@ -23,14 +31,23 @@ import {
WALLED_OWNER_NO_VERIFICATION_PATH,
resolveWalledOwnerVerificationPathWarning,
warnIfWalledOwnerCannotVerify,
type WalledOwnerAccountState,
} from './walled-owner-verification-path';
import type { PluginContext } from '@objectstack/core';

const OWNER = 'operator@corp.example';
const DEV_SEED_ADMIN = 'admin@objectos.ai';

/** No transport, no federated sign-in — the shape the ruling is about. */
const NOTHING_WIRED = { hasEmailTransport: false, hasFederatedSignIn: false } as const;
/**
* No transport, no federated sign-in, with the caller-resolved owner account
* state. [#12751] The default state here is `owner-unverified` — the shape
* that stays a dead end after the operator-provisioning stamp — so every
* pre-existing "the dead-end shape warns" pin below keeps measuring a real
* dead end rather than the fresh boot the stamp now covers.
*/
const nothingWired = (ownerAccountState: WalledOwnerAccountState = 'owner-unverified') =>
({ hasEmailTransport: false, hasFederatedSignIn: false, ownerAccountState }) as const;
const NOTHING_WIRED = nothingWired();

const ENV_KEYS = [
'OS_TENANCY_POSTURE',
Expand DownExpand Up@@ -122,10 +139,12 @@ describe('#11640 — the dead-end shape warns, by name and with the remedy', ()
describe('#11640 — controls: every neighbouring shape stays SILENT', () => {
it('an email transport is wired ⇒ the verification link can be delivered ⇒ no warning', () => {
walledWithDeclaredOwner();
// Even against the worst account state: the transport IS the remedy.
expect(
resolveWalledOwnerVerificationPathWarning({
hasEmailTransport: true,
hasFederatedSignIn: false,
ownerAccountState: 'owner-unverified',
}),
).toBeNull();
});
Expand All@@ -136,6 +155,7 @@ describe('#11640 — controls: every neighbouring shape stays SILENT', () => {
resolveWalledOwnerVerificationPathWarning({
hasEmailTransport: false,
hasFederatedSignIn: true,
ownerAccountState: 'owner-unverified',
}),
).toBeNull();
});
Expand All@@ -156,30 +176,82 @@ describe('#11640 — controls: every neighbouring shape stays SILENT', () => {
it('a dev/harness boot that seeds THIS owner verifies it at startup ⇒ no warning', () => {
// The dev-admin seed provisions the declared owner and stamps it
// `email_verified` (#11343), which is a verification path even with no
// mailbox anywhere — the verify harness boots exactly this shape.
// mailbox anywhere — the verify harness boots exactly this shape. The
// seed acts on an empty store, and the harness boots that cannot probe
// one hand in 'unknown' — both stay silent.
process.env.NODE_ENV = 'development';
walledWithDeclaredOwner('isolated', DEV_SEED_ADMIN);
expect(resolveWalledOwnerVerificationPathWarning(NOTHING_WIRED)).toBeNull();
expect(resolveWalledOwnerVerificationPathWarning(nothingWired('no-human-users'))).toBeNull();
expect(resolveWalledOwnerVerificationPathWarning(nothingWired('unknown'))).toBeNull();

// …and it follows the seed's own address knob, not a hard-coded default.
process.env.OS_SEED_ADMIN_EMAIL = 'seeded-owner@corp.example';
process.env.OS_PLATFORM_OWNER_EMAIL = 'seeded-owner@corp.example';
expect(resolveWalledOwnerVerificationPathWarning(NOTHING_WIRED)).toBeNull();
expect(resolveWalledOwnerVerificationPathWarning(nothingWired('no-human-users'))).toBeNull();
});

it('…but a dev boot whose declared owner is NOT the seeded one is a real dead end', () => {
process.env.NODE_ENV = 'development';
walledWithDeclaredOwner('isolated', OWNER); // seed provisions admin@objectos.ai
expect(resolveWalledOwnerVerificationPathWarning(NOTHING_WIRED)).toContain(
WALLED_OWNER_NO_VERIFICATION_PATH,
);
const msg = resolveWalledOwnerVerificationPathWarning(nothingWired('no-human-users'));
expect(msg).toContain(WALLED_OWNER_NO_VERIFICATION_PATH);
// [#12751] …and the message says WHY the first-account stamp cannot help:
// the armed seed will spend the bootstrap carve-out on its own address.
expect(msg).toContain(DEV_SEED_ADMIN);
});

it('…and a dev boot with the seed switched OFF gets no free pass either', () => {
it('[#12751] …and the seed cannot rescue a store it will never touch — a populated dev boot still warns', () => {
// The seed acts only on an EMPTY store. An owner account that already
// exists unverified is past its reach, so even the address-matched dev
// shape is a real dead end there.
process.env.NODE_ENV = 'development';
process.env.OS_SEED_ADMIN = '0';
walledWithDeclaredOwner('isolated', DEV_SEED_ADMIN);
expect(resolveWalledOwnerVerificationPathWarning(NOTHING_WIRED)).toContain(
expect(resolveWalledOwnerVerificationPathWarning(nothingWired('owner-unverified'))).toContain(
WALLED_OWNER_NO_VERIFICATION_PATH,
);
});
});

// ---------------------------------------------------------------------------
// [#12751] 「运营方创建即视为已验证」 (maintainer, 2026-08-28): the operator
// provisioning stamp is itself a verification path, so the warning's firing
// now follows the OWNER ACCOUNT STATE — quiet where the stamp (or a finished
// verification) covers the deployment, loud where the store is past the
// stamp's reach.
// ---------------------------------------------------------------------------

describe('#12751 — the warning follows the owner account state', () => {
it('THE CASE THIS CARD CLOSES: a fresh production walled boot with nothing wired stays SILENT — the operator first-account creation arrives verified', () => {
walledWithDeclaredOwner();
// NODE_ENV is production-shaped here (the beforeEach cleared it), so the
// dev seed is NOT armed — pre-#12751 this exact shape warned on every
// fresh walled EE deployment following the shipped .env.example.
expect(resolveWalledOwnerVerificationPathWarning(nothingWired('no-human-users'))).toBeNull();
});

it('an owner account that exists VERIFIED needs nothing — silent (also on every later boot of a settled deployment)', () => {
walledWithDeclaredOwner();
expect(resolveWalledOwnerVerificationPathWarning(nothingWired('owner-verified'))).toBeNull();
});

it('an owner account that exists UNVERIFIED is the dead end — warns, and names the situation', () => {
walledWithDeclaredOwner();
const msg = resolveWalledOwnerVerificationPathWarning(nothingWired('owner-unverified'));
expect(msg).toContain(WALLED_OWNER_NO_VERIFICATION_PATH);
expect(msg).toContain('ALREADY EXISTS');
expect(msg).toContain('walled_owner_not_verified');
});

it('a populated store with NO owner account warns — the bootstrap window is spent and an invitee arrives unverified', () => {
walledWithDeclaredOwner();
const msg = resolveWalledOwnerVerificationPathWarning(nothingWired('owner-absent'));
expect(msg).toContain(WALLED_OWNER_NO_VERIFICATION_PATH);
expect(msg).toContain('UNVERIFIED');
});

it('an unanswerable probe warns — noisy over silent about a real dead end (the pre-#12751 posture)', () => {
walledWithDeclaredOwner();
expect(resolveWalledOwnerVerificationPathWarning(nothingWired('unknown'))).toContain(
WALLED_OWNER_NO_VERIFICATION_PATH,
);
});
Expand All@@ -202,7 +274,10 @@ describe('#11640 — the emitter logs once, on the channel `serve` replays', ()
walledWithDeclaredOwner();
const logger = { warn: vi.fn(), error: vi.fn(), info: vi.fn() };
expect(
warnIfWalledOwnerCannotVerify({ hasEmailTransport: true, hasFederatedSignIn: false }, logger),
warnIfWalledOwnerCannotVerify(
{ hasEmailTransport: true, hasFederatedSignIn: false, ownerAccountState: 'owner-unverified' },
logger,
),
).toBeNull();
expect(logger.warn).not.toHaveBeenCalled();
});
Expand Down
29 changes: 24 additions & 5 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,7 +66,9 @@ import { scheduleLegacySsoSecretMigration } from './sso-client-secret.js';
import {
devSeedAdminEmail,
isDevAdminSeedArmed,
probeWalledOwnerAccountState,
warnIfWalledOwnerCannotVerify,
type WalledOwnerAccountState,
} from './walled-owner-verification-path.js';
import { judgePlatformAdmin, isPlatformAdminUser, type PlatformAdminActor } from './platform-admin-gate.js';
import {
Expand DownExpand Up@@ -932,12 +934,29 @@ export class AuthPlugin implements Plugin {
// this hook's answer independent of hook registration order.
let pub: { socialProviders?: unknown[]; features?: { sso?: boolean } } | undefined;
try { pub = this.authManager?.getPublicConfig(); } catch { pub = undefined; }
const hasEmailTransport = !!emailSvc || !!this.authManager?.hasEmailTransport();
const hasFederatedSignIn =
(pub?.socialProviders?.length ?? 0) > 0 || pub?.features?.sso === true;
// [#12751] The third wiring fact: what the store says about the declared
// owner's account. Probed only when the answer can matter (walled +
// owner declared + neither transport nor federated sign-in wired), so
// every other boot pays nothing. This hook runs BEFORE the dev-seed
// hook below (registration order), so the probe reads the pre-seed
// store — the predicate's dev-seed clauses are written for exactly
// that reading.
let ownerAccountState: WalledOwnerAccountState = 'unknown';
if (
!hasEmailTransport &&
!hasFederatedSignIn &&
postureEnforcesWall(resolveTenancyPosture()) &&
resolvePlatformOwnerEmail()
) {
let ql: IDataEngine | undefined;
try { ql = ctx.getService<IDataEngine>('objectql'); } catch { ql = undefined; }
ownerAccountState = await probeWalledOwnerAccountState(ql);
}
warnIfWalledOwnerCannotVerify(
{
hasEmailTransport: !!emailSvc || !!this.authManager?.hasEmailTransport(),
hasFederatedSignIn:
(pub?.socialProviders?.length ?? 0) > 0 || pub?.features?.sso === true,
},
{ hasEmailTransport, hasFederatedSignIn, ownerAccountState },
ctx.logger,
);
});
Expand Down
Loading
Loading