feat: create the initial platform owner from an operator command
A fresh production installation deadlocked. ADR 0034 gave Ekklesia a login
page and ADR 0019 gave it a single-use platform-owner bootstrap, but that
bootstrap grants ownership to a user id that already exists and has no way
to bring one into being. Public signup is disabled by default and
production cannot enable it at all, so a newly deployed instance had a
login page, no account, and no supported way to create one — only hand-
written SQL or briefly opening public registration.
pnpm identity:create-initial-owner -- --email [email protected]
creates the identity and grants it ownership, in the same shape as the
other operator commands: trusted local tooling, reachable only by someone
who already holds the database credentials, serving no HTTP route. No
/signup, /register, or /setup route is added, no bootstrap endpoint, and
no setup token; public signup stays disabled in every environment and a
test asserts a freshly bootstrapped deployment still refuses
/api/auth/sign-up/email.
The identity is created through Better Auth's own sign-up path, so
hashing, salting, normalization and the 8-128 bound are the identity
system's rather than a second implementation that could drift. Because the
server's instance refuses account creation, the command builds its own
in-process instance; nothing in apps/api imports it and the running
server's posture is untouched.
The sign-up result is verified rather than trusted. Better Auth returns a
fabricated success for an already-registered address — a user object whose
id was never persisted, database unchanged. That is correct enumeration
defence on a public endpoint and a trap here, so the identity is always
read back from the database and that read, not the response, is what the
grant uses.
An existing usable identity is adopted: ownership is granted and its
password left alone, reported as an adoption rather than a creation. This
is the recovery path for the one partial failure possible, since sign-up
commits on Better Auth's connection and the grant is a separate
transaction. A credential-less row is refused instead — making it an owner
would consume the single use on an identity nobody can authenticate as. A
password is never reset and an account is never taken over.
Whole invocations are serialized by an advisory lock on their own key, so
a race cannot leave a stray signable-in account behind; the grant's own
locked single-use transaction is unchanged. There is no --force or reset.
The password is prompted for invisibly or read from stdin, never from
argv (--password is refused with an explanation) and never from an
environment variable, so nothing about it becomes permanent deployment
configuration. The command needs DATABASE_URL and nothing else — verified
that sign-up needs no secret, base URL, or trusted origins, and that the
resulting account signs in through a separately configured server — so the
first owner can be created before TLS or DNS exist. It never migrates.
Proven end to end rather than at the database: a fresh installation is
bootstrapped, signed in through the real browser login transport with
signup disabled, its principal read, and used to open an owner-protected
resource that stays closed to anonymous callers.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c131543abf
commit
c8fa13dd6a
@@ -0,0 +1,278 @@
|
||||
import type { EkklesiaUserId } from "@ekklesia/contracts";
|
||||
import { safeParseEkklesiaUserId } from "@ekklesia/contracts";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import type { BootstrapIdentityCreator } from "../identity/bootstrap-identity.js";
|
||||
import { BootstrapIdentityError } from "../identity/bootstrap-identity.js";
|
||||
import type { IdentityDrizzleDatabase } from "../identity/database.js";
|
||||
import { ekklesiaCoreAccount, ekklesiaCoreUser } from "../identity/schema.js";
|
||||
import { bootstrapPlatformOwner, PlatformOwnerBootstrapError } from "./bootstrap.js";
|
||||
import type { AuthorizationDrizzleDatabase } from "./database.js";
|
||||
import { ekklesiaCorePlatformOwnerBootstrap } from "./schema.js";
|
||||
|
||||
export type InitialPlatformOwnerErrorCode =
|
||||
| "INITIAL_OWNER_ALREADY_COMPLETED"
|
||||
| "INITIAL_OWNER_EMAIL_INVALID"
|
||||
| "INITIAL_OWNER_IDENTITY_NOT_CREATED"
|
||||
| "INITIAL_OWNER_IDENTITY_UNUSABLE"
|
||||
| "INITIAL_OWNER_OWNERSHIP_GRANT_FAILED"
|
||||
| "INITIAL_OWNER_PASSWORD_TOO_LONG"
|
||||
| "INITIAL_OWNER_PASSWORD_TOO_SHORT"
|
||||
| "INITIAL_OWNER_SCHEMA_UNAVAILABLE";
|
||||
|
||||
const initialOwnerMessages: Readonly<Record<InitialPlatformOwnerErrorCode, string>> = Object.freeze(
|
||||
{
|
||||
INITIAL_OWNER_ALREADY_COMPLETED:
|
||||
"This installation already has a platform owner. Initial-owner bootstrap runs once and cannot run again.",
|
||||
INITIAL_OWNER_EMAIL_INVALID: "The email address is not a valid address.",
|
||||
INITIAL_OWNER_IDENTITY_NOT_CREATED:
|
||||
"The identity system reported success but no account exists for that address. Nothing was changed; the bootstrap is still available.",
|
||||
INITIAL_OWNER_IDENTITY_UNUSABLE:
|
||||
"An account already exists for that address but has no password and could never sign in. Choose a different address, or remove that account before retrying.",
|
||||
INITIAL_OWNER_OWNERSHIP_GRANT_FAILED:
|
||||
"The identity exists but platform ownership was not granted. Bootstrap is still available: run this command again with the same email address to finish it.",
|
||||
INITIAL_OWNER_PASSWORD_TOO_LONG: "The password is longer than the identity system allows.",
|
||||
INITIAL_OWNER_PASSWORD_TOO_SHORT: "The password is shorter than the identity system allows.",
|
||||
INITIAL_OWNER_SCHEMA_UNAVAILABLE:
|
||||
"The database is unreachable or has not been migrated. Apply migrations before creating the initial owner.",
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Never carries the password, a hash, a connection string, SQL, a driver error, or a cause. The
|
||||
* messages are written to be read by an operator at a terminal, so each one states what happened
|
||||
* *and* what is still true about the installation — in particular whether bootstrap remains
|
||||
* available, which is the only fact that determines what they should do next.
|
||||
*/
|
||||
export class InitialPlatformOwnerError extends Error {
|
||||
readonly code: InitialPlatformOwnerErrorCode;
|
||||
|
||||
constructor(code: InitialPlatformOwnerErrorCode) {
|
||||
super(initialOwnerMessages[code]);
|
||||
this.name = "InitialPlatformOwnerError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
// A CLI-level advisory key distinct from `bootstrap.ts`'s: taking the same one would deadlock this
|
||||
// operation against the grant it performs. This serializes whole invocations; that one serializes
|
||||
// the grant transaction itself, and both remain in force.
|
||||
const initialOwnerLockNamespace = 1_500_000_003;
|
||||
const initialOwnerLockKey = 1_500_000_004;
|
||||
|
||||
export interface CreateInitialPlatformOwnerDependencies {
|
||||
readonly authorizationDb: AuthorizationDrizzleDatabase;
|
||||
readonly identityDb: IdentityDrizzleDatabase;
|
||||
readonly createIdentity: BootstrapIdentityCreator;
|
||||
/** Injected so a partial failure — identity created, ownership not granted — can be exercised.
|
||||
* Production composition leaves it unset and the real single-use bootstrap is used. */
|
||||
readonly grantOwnership?: (userId: EkklesiaUserId) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface CreateInitialPlatformOwnerInput {
|
||||
readonly email: string;
|
||||
readonly password: string;
|
||||
/** The display name the identity model requires. */
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
export interface InitialPlatformOwnerResult {
|
||||
readonly email: string;
|
||||
readonly userId: EkklesiaUserId;
|
||||
/** `created` when this call made the identity; `adopted` when one already existed for that
|
||||
* address and only ownership was granted. Reported so an operator is never told something was
|
||||
* created when it was not. */
|
||||
readonly identity: "adopted" | "created";
|
||||
}
|
||||
|
||||
interface ExistingIdentity {
|
||||
readonly userId: string;
|
||||
readonly hasCredential: boolean;
|
||||
/** As the identity system actually stored it, which is not necessarily as the operator typed it. */
|
||||
readonly storedEmail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an identity by email, case-insensitively.
|
||||
*
|
||||
* Deliberately not an exact match on the caller's spelling: Better Auth lowercases the address it
|
||||
* stores (verified against the pinned release — `[email protected]` persists as
|
||||
* `[email protected]`), so an exact comparison would fail to find the very account this operation
|
||||
* had just created and would report it missing.
|
||||
*/
|
||||
async function findIdentityByEmail(
|
||||
db: IdentityDrizzleDatabase,
|
||||
email: string,
|
||||
): Promise<ExistingIdentity | undefined> {
|
||||
const rows = await db
|
||||
.select({ email: ekklesiaCoreUser.email, id: ekklesiaCoreUser.id })
|
||||
.from(ekklesiaCoreUser)
|
||||
.where(sql`lower(${ekklesiaCoreUser.email}) = ${email.trim().toLowerCase()}`)
|
||||
.limit(1);
|
||||
const found = rows[0];
|
||||
if (!found) {
|
||||
return undefined;
|
||||
}
|
||||
// A row in `user` alone is not an account anyone can sign in as — the credential lives in
|
||||
// `account`. The distinction matters: granting ownership to a passwordless row would consume the
|
||||
// one-time bootstrap on an identity nobody can authenticate as, leaving the installation with an
|
||||
// owner that cannot log in and no second chance.
|
||||
const credentials = await db
|
||||
.select({ id: ekklesiaCoreAccount.id })
|
||||
.from(ekklesiaCoreAccount)
|
||||
.where(
|
||||
and(
|
||||
eq(ekklesiaCoreAccount.userId, found.id),
|
||||
eq(ekklesiaCoreAccount.providerId, "credential"),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return { hasCredential: credentials.length > 0, storedEmail: found.email, userId: found.id };
|
||||
}
|
||||
|
||||
function translateIdentityCreationError(error: unknown): InitialPlatformOwnerError {
|
||||
if (error instanceof BootstrapIdentityError) {
|
||||
if (error.code === "BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT") {
|
||||
return new InitialPlatformOwnerError("INITIAL_OWNER_PASSWORD_TOO_SHORT");
|
||||
}
|
||||
if (error.code === "BOOTSTRAP_IDENTITY_PASSWORD_TOO_LONG") {
|
||||
return new InitialPlatformOwnerError("INITIAL_OWNER_PASSWORD_TOO_LONG");
|
||||
}
|
||||
if (error.code === "BOOTSTRAP_IDENTITY_EMAIL_INVALID") {
|
||||
return new InitialPlatformOwnerError("INITIAL_OWNER_EMAIL_INVALID");
|
||||
}
|
||||
}
|
||||
return new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_CREATED");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the first platform owner of a fresh installation: one identity, then the single-use
|
||||
* ownership grant.
|
||||
*
|
||||
* ## Why the identity is verified rather than trusted
|
||||
*
|
||||
* Better Auth's sign-up returns a **fabricated success** when the address is already registered —
|
||||
* a user object carrying an id that was never persisted, with the database left unchanged (verified
|
||||
* against the pinned release). On a public endpoint that is correct: it refuses to tell an
|
||||
* anonymous caller which addresses exist. Here it is a trap, because a caller that believed the
|
||||
* response would report an owner it never created and then grant ownership to an id that does not
|
||||
* exist. So the identity is always read back from the database, and that read — not the sign-up
|
||||
* response — is what the grant uses.
|
||||
*
|
||||
* ## Why an existing identity is adopted rather than refused or overwritten
|
||||
*
|
||||
* If the address already has a usable account and bootstrap is still open, ownership is granted to
|
||||
* it and **its password is left completely alone**. This is precisely the recovery path for the one
|
||||
* partial failure this operation can have: sign-up commits through Better Auth's own connection and
|
||||
* the grant runs in a separate transaction, so a crash between them leaves an identity with no
|
||||
* ownership. Refusing would strand a fresh installation with no way forward but manual SQL; a
|
||||
* password reset would be a silent takeover of an account the operator may not own. Adoption
|
||||
* changes no credential, and it is reported as `adopted` so nobody is told an account was created
|
||||
* when it was not.
|
||||
*
|
||||
* That is safe here specifically because production has **no** other way to create an identity —
|
||||
* public signup cannot be enabled at all — so an account present on a fresh installation was put
|
||||
* there by someone who already had database access.
|
||||
*
|
||||
* ## Ordering
|
||||
*
|
||||
* The bootstrap sentinel is checked before an identity is created, so a closed bootstrap never
|
||||
* leaves a stray account behind. That check is an early exit, not the authority: `bootstrapPlatformOwner`
|
||||
* re-checks it inside its own locked transaction, which is what actually makes the grant single-use.
|
||||
*/
|
||||
export async function createInitialPlatformOwner(
|
||||
dependencies: CreateInitialPlatformOwnerDependencies,
|
||||
input: CreateInitialPlatformOwnerInput,
|
||||
): Promise<InitialPlatformOwnerResult> {
|
||||
const email = input.email.trim();
|
||||
if (email.length === 0) {
|
||||
throw new InitialPlatformOwnerError("INITIAL_OWNER_EMAIL_INVALID");
|
||||
}
|
||||
|
||||
// Serializes entire invocations against each other. Without it two operators racing on a fresh
|
||||
// installation would both pass the sentinel check and both create an identity, and only then
|
||||
// would one lose the grant — leaving a stray account that can sign in and owns nothing. The grant
|
||||
// alone was already safe; this makes the whole command safe.
|
||||
return dependencies.authorizationDb.transaction(async (lockTx) => {
|
||||
await lockTx.execute(
|
||||
sql`select pg_advisory_xact_lock(${initialOwnerLockNamespace}, ${initialOwnerLockKey})`,
|
||||
);
|
||||
|
||||
let alreadyConsumed: boolean;
|
||||
try {
|
||||
const consumed = await lockTx
|
||||
.select({ id: ekklesiaCorePlatformOwnerBootstrap.id })
|
||||
.from(ekklesiaCorePlatformOwnerBootstrap)
|
||||
.limit(1);
|
||||
alreadyConsumed = consumed.length > 0;
|
||||
} catch {
|
||||
// An unreachable database and a database without the authorization tables are the same
|
||||
// situation to an operator running this on a fresh server: the deployment step before this
|
||||
// one has not happened. Never migrated implicitly — see ADR 0035.
|
||||
throw new InitialPlatformOwnerError("INITIAL_OWNER_SCHEMA_UNAVAILABLE");
|
||||
}
|
||||
if (alreadyConsumed) {
|
||||
throw new InitialPlatformOwnerError("INITIAL_OWNER_ALREADY_COMPLETED");
|
||||
}
|
||||
|
||||
let existing: ExistingIdentity | undefined;
|
||||
try {
|
||||
existing = await findIdentityByEmail(dependencies.identityDb, email);
|
||||
} catch {
|
||||
throw new InitialPlatformOwnerError("INITIAL_OWNER_SCHEMA_UNAVAILABLE");
|
||||
}
|
||||
if (existing && !existing.hasCredential) {
|
||||
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_UNUSABLE");
|
||||
}
|
||||
|
||||
// Captured before the read-back below, which cannot distinguish the two: an identity this call
|
||||
// just created and one it adopted both come back with a credential.
|
||||
const identity: "adopted" | "created" = existing ? "adopted" : "created";
|
||||
|
||||
if (!existing) {
|
||||
try {
|
||||
await dependencies.createIdentity({
|
||||
email,
|
||||
name: input.name,
|
||||
password: input.password,
|
||||
});
|
||||
} catch (error) {
|
||||
throw translateIdentityCreationError(error);
|
||||
}
|
||||
existing = await findIdentityByEmail(dependencies.identityDb, email).catch(() => undefined);
|
||||
if (!existing) {
|
||||
// Reached when sign-up reported success without persisting anything.
|
||||
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_CREATED");
|
||||
}
|
||||
}
|
||||
|
||||
const parsedUserId = safeParseEkklesiaUserId(existing.userId);
|
||||
if (!parsedUserId.success) {
|
||||
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_CREATED");
|
||||
}
|
||||
|
||||
try {
|
||||
const grant =
|
||||
dependencies.grantOwnership ??
|
||||
((userId: EkklesiaUserId) =>
|
||||
bootstrapPlatformOwner(dependencies.authorizationDb, { userId }));
|
||||
await grant(parsedUserId.data);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof PlatformOwnerBootstrapError &&
|
||||
error.code === "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED"
|
||||
) {
|
||||
throw new InitialPlatformOwnerError("INITIAL_OWNER_ALREADY_COMPLETED");
|
||||
}
|
||||
// The identity is real and remains usable; the sentinel rolled back with the grant, so
|
||||
// re-running this command with the same address adopts it and finishes.
|
||||
throw new InitialPlatformOwnerError("INITIAL_OWNER_OWNERSHIP_GRANT_FAILED");
|
||||
}
|
||||
|
||||
// The stored address, never the operator's spelling: the identity system lowercases what it
|
||||
// persists, and telling someone to sign in as `[email protected]` when the account is
|
||||
// `[email protected]` invites them to conclude the command failed.
|
||||
return Object.freeze({ email: existing.storedEmail, identity, userId: parsedUserId.data });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user