Skip to content
Merged
9 changes: 7 additions & 2 deletions docs/runbooks/break-glass-account-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,16 @@ docker compose -f deploy/docker-compose.yml exec app \
### Expected output (exit code `0`)

```
Break-glass reset complete for owner@thefarm.example (account 0000000a-...). All existing sessions were revoked.
Break-glass reset complete for owner@thefarm.example on farm <slug> (account 0000000a-...). All existing sessions were revoked.
Temporary password: <20-char one-time password>
Log in with this now and change it immediately (Account → change password).
```

The **farm code** before the account GUID is the required #532 sign-in input, so
copy it from stdout along with the password. If its output is ever lost, the
read-only `list-accounts` verb prints every farm's code and is the supported way
to recover it.

The temporary password is written to **stdout only** (never the logger/OTLP), so
it does not persist in structured logs. Capture it from the terminal, hand it to
the user over a trusted channel, and have them rotate it on first login.
Expand Down Expand Up @@ -169,7 +174,7 @@ Nothing is changed on a failure.

## Post-recovery verification (drill this on staging before you need it)

1. The user logs in with the temporary password → **succeeds**.
1. The user logs in with the temporary password **and the printed farm code** → **succeeds**.
2. The **old** password → **rejected**.
3. Any previously open session (a still-loaded browser tab) can no longer refresh → signed out.
4. `SELECT action, actor_email, reason, occurred_at_utc FROM audit_events WHERE action = 'User.BreakGlassReset' ORDER BY occurred_at_utc DESC LIMIT 1;` shows the row with your reason.
Expand Down
15 changes: 10 additions & 5 deletions docs/runbooks/first-admin-provisioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,16 @@ Base data — the default account, roles, default egg grades, packed-unit
conversions — ships inside the EF migrations, so it is already there. **No
credential is ever baked into the repo**, so the admin user is not.

All three forms below share the same three properties:
All three forms below share the same properties:

- the generated one-time password goes to **stdout only** — never the application
logger or the OTLP pipeline. A host's stdout collector (docker logs, journald, a
platform log pipeline) may still capture it, so treat that output as sensitive
while the password is valid;
- a first-run provisioning also prints the **farm code** (`on farm <slug>`) — #532
made it a required sign-in input, so copy it from stdout along with the password.
A re-run (the no-op bullet below) deliberately does **not** reprint it; to recover
a code whose output was lost, run the read-only `list-accounts` verb.
- first sign-in shows a **Set your password** screen and refuses everything else
until you pick your own;
- a re-run against an already-provisioned account is a safe no-op.
Expand Down Expand Up @@ -93,8 +97,8 @@ against a plaintext local Postgres (the #261/#262 TLS floor).

## Verify

1. Sign in with the printed password. The SPA must show **Set your password** and
refuse every other screen.
1. Sign in with the printed password **and the printed farm code**. The SPA must
show **Set your password** and refuse every other screen.
2. Set a password. The app releases the rest of the UI.
3. Re-run the same command. Expected: a no-op — no new user, no new password.

Expand All @@ -113,7 +117,8 @@ Safe on a scratch database only.

1. `docker compose -f deploy/docker-compose.dev.yml down -v && … up -d` — a
database with no Owner.
2. Run form 3. Expected: a password on stdout, exit `0`.
3. Sign in; expect **Set your password**; set one.
2. Run form 3. Expected: a password and the farm code on stdout, exit `0`.
3. Sign in with the printed password **and farm code**; expect **Set your
password**; set one.
4. Run form 3 again. Expected: no-op, exit `0`, no second password.
5. Update **Last drilled** above.
6 changes: 4 additions & 2 deletions specs/product/GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -986,7 +986,8 @@ data**: they ship inside the EF migrations themselves (no runtime seeder, no
**not**: an operator runs the offline `bootstrap-admin --email <address>`
command once, which generates a random password, creates the Owner with it,
and writes the password to **stdout only** — never the application logger or
the OTLP pipeline. A host's stdout collector (docker logs, journald, a
the OTLP pipeline — and prints the **farm code** alongside it, which #532 made
a required sign-in input. A host's stdout collector (docker logs, journald, a
platform log pipeline) may still capture it, so that output must be treated
as sensitive while the password is valid. Re-running the command against an
already-provisioned account is a safe no-op (no second Owner, no password
Expand Down Expand Up @@ -1041,7 +1042,8 @@ reset, self-service needs the current password, and an Owner cannot reset their
own account — so without this the only recourse is direct database surgery. An
operator with server access runs the offline `recover-admin` command, which
resets the password to a freshly generated temporary one, **clears any lockout**,
rotates the security stamp, and revokes every session. It is audited as
rotates the security stamp, and revokes every session, printing the temporary
password and the farm code to stdout. It is audited as
`User.BreakGlassReset` (distinct from `User.PasswordSet`, and recording the
reason + the host it ran from) so the reset is conspicuous in the audit log. The
temporary password must be changed on first login.
Expand Down
14 changes: 13 additions & 1 deletion src/Cluckwork.Api/Cli/BootstrapAdminCliCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,20 @@ await Console.Out.WriteLineAsync(
// journald, a platform log pipeline) may still capture it — treat
// the value as sensitive, same as the recover-admin runbook
// instructs (#265 review).
// #589 — the FARM CODE is named explicitly because it is the one of the
// three login inputs the operator has no other source for: the email and
// password are the operator's own, while the farm code is furnished by a
// command. Printing it here removes a recovery step rather than being the
// only route — the read-only `list-accounts` verb also prints it, and the
// SPA can supply it from a remembered code or a /login?farm= link once
// either exists (on a first run, neither does). The account GUID is
// diagnostic only. Without this line the command still ends with "log in
// with this now" while withholding one of the three things that takes:
// #532 changed the login contract (farm code became a required input) and
// this caller was missed.
await Console.Out.WriteLineAsync(
$"First-run admin provisioned: {outcome.Email} (account {outcome.AccountId}).");
$"First-run admin provisioned: {outcome.Email} on farm {outcome.Slug} "
+ $"(account {outcome.AccountId}).");
Comment on lines 79 to +81

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Document the farm code in Help and glossary

This changes operator-visible behavior, but the required documentation surfaces remain stale: web/src/i18n/en.ts's signingInFirstRun says the setup command prints only a temporary password, its first-run/break-glass glossary text omits the printed code, and specs/product/GLOSSARY.md likewise describes these commands without this output. Update the SPA Help, in-app glossary/localized packs, and product glossary to cover the farm code emitted by both provisioning and recovery.

AGENTS.md reference: AGENTS.md:L198-L198

Useful? React with 👍 / 👎.

await Console.Out.WriteLineAsync($"Temporary password: {outcome.TemporaryPassword}");
await Console.Out.WriteLineAsync(
"Log in with this now — the app requires a new password before anything else works.");
Expand Down
4 changes: 2 additions & 2 deletions src/Cluckwork.Api/Cli/RecoverAdminCliCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ await Console.Error.WriteLineAsync(
// rotate it on first login, as the runbook instructs (#265 review).
var outcome = recovered.Value;
await Console.Out.WriteLineAsync(
$"Break-glass reset complete for {outcome.Email} (account {outcome.AccountId}). " +
"All existing sessions were revoked.");
$"Break-glass reset complete for {outcome.Email} on farm {outcome.Slug} " +
$"(account {outcome.AccountId}). All existing sessions were revoked.");
await Console.Out.WriteLineAsync($"Temporary password: {outcome.TemporaryPassword}");
await Console.Out.WriteLineAsync(
"Log in with this now and change it immediately (Account → change password).");
Expand Down
11 changes: 9 additions & 2 deletions src/Cluckwork.Infrastructure/Identity/AdminRecoveryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,16 @@ public async Task<Result<AdminRecoveryResult>> RecoverAsync(
return Result.Failure<AdminRecoveryResult>(reset.Error);

await transaction.CommitAsync(token);
return Result.Success(new AdminRecoveryResult(target.Email!, target.AccountId, password));
return Result.Success(new AdminRecoveryResult(target.Email!, target.AccountId, lockedAccount.Slug, password));
}, ct);
}
}

public sealed record AdminRecoveryResult(string Email, Guid AccountId, string TemporaryPassword);
// #589 — Slug is a plain NON-NULLABLE string, unlike FirstRunAdminOutcome's
// nullable one. That record has a no-op path (AlreadyProvisioned returns a
// value with nothing populated) so every field must be nullable there;
// AdminRecoveryResult has no no-op path — recovery always ran — so every field
// is populated and none are nullable. The slug is read off `lockedAccount`
// (already loaded FOR UPDATE in the transaction, no new query) and printed by
// recover-admin because #532 made the farm code a required login input.
public sealed record AdminRecoveryResult(string Email, Guid AccountId, string Slug, string TemporaryPassword);
50 changes: 40 additions & 10 deletions src/Cluckwork.Infrastructure/Identity/FirstRunAdminService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,29 @@ public async Task<Result<FirstRunAdminOutcome>> ProvisionAsync(
// the schema is current (MigrateAsync above already ran) — this is
// defense-in-depth against a hand-rolled/partially-restored schema,
// not the expected path. Read-only, so it stays outside the lock.
var accountExists = await db.Accounts
// #589 — select the SLUG instead of testing existence. The operator cannot
// sign in without the farm code (#532 made it a required login input), and
// this query already runs on this path, so reading the value here costs no
// extra round trip. A MISSING ROW returns null and takes the
// Bootstrap.AccountMissing failure path below. A row whose slug is NULL —
// only possible on a hand-rolled or partially-restored schema, since
// AccountConfiguration marks Slug .IsRequired() — throws
// InvalidCastException ("Column 'Slug' is null.") before SingleOrDefaultAsync
// returns; the CLI's catch-all turns that into "Bootstrap failed: …" and exit
// 1. That is still fail-closed, but via the catch-all, NOT via this guard, so
// the operator sees an EF error rather than the friendly message. Recorded
// as a known limitation: an earlier attempt to make the null-slug case reach
// the friendly path by projecting `(string?)a.Slug` changed nothing — the
// cast is an identity conversion whose nullability comes from the model
// (a migrated database never has a NULL slug), not from the C# annotation.
// Deliberately NOT a "default-farm" literal: that value lives in
// 20260818235944_AddAccountSlug.cs and a second copy would drift.
var accountSlug = await db.Accounts
.IgnoreQueryFilters()
.AnyAsync(a => a.Id == accountId, ct);
if (!accountExists)
.Where(a => a.Id == accountId)
.Select(a => a.Slug)
.SingleOrDefaultAsync(ct);
if (accountSlug is null)
return Result.Failure<FirstRunAdminOutcome>(Error.Validation(
"Bootstrap.AccountMissing",
"The default account does not exist. This should never happen against a schema this " +
Expand Down Expand Up @@ -149,7 +168,7 @@ await db.Database.ExecuteSqlInterpolatedAsync(
$"SELECT pg_advisory_lock({AdvisoryLockClassId}, {AdvisoryLockObjectId})", ct);
try
{
return await ProvisionUnderLockAsync(accountId, email.Trim(), ct);
return await ProvisionUnderLockAsync(accountId, accountSlug, email.Trim(), ct);
}
finally
{
Expand Down Expand Up @@ -211,7 +230,7 @@ private async Task TryCleanupAsync(Func<Task> cleanup, string what)
}

private async Task<Result<FirstRunAdminOutcome>> ProvisionUnderLockAsync(
Guid accountId, string email, CancellationToken ct)
Guid accountId, string accountSlug, string email, CancellationToken ct)
{
// Idempotency: an Owner already existing in the default account means
// first-run provisioning already happened. Re-checked HERE (not just
Expand Down Expand Up @@ -332,7 +351,8 @@ private async Task<Result<FirstRunAdminOutcome>> ProvisionUnderLockAsync(
}

await transaction.CommitAsync(token);
return Result.Success(FirstRunAdminOutcome.Provisioned(email, accountId, password));
return Result.Success(
FirstRunAdminOutcome.Provisioned(email, accountId, accountSlug, password));
}, ct);
}

Expand Down Expand Up @@ -366,11 +386,21 @@ SELECT 1 FROM pg_locks
""").SingleAsync(ct);
}

// #589 — Slug is NULLABLE and sits beside the other nullable fields deliberately.
// This record is not a mirror of AccountProvisionOutcome: that one has no no-op
// path, so all its fields are non-null, whereas AlreadyProvisioned() here returns
// a value with nothing populated. Copying its non-nullable `string Slug` across
// would make the idempotent branch unrepresentable.
public sealed record FirstRunAdminOutcome(
bool WasAlreadyProvisioned, string? Email, Guid? AccountId, string? TemporaryPassword)
bool WasAlreadyProvisioned,
string? Email,
Guid? AccountId,
string? Slug,
string? TemporaryPassword)
{
public static FirstRunAdminOutcome AlreadyProvisioned() => new(true, null, null, null);
public static FirstRunAdminOutcome AlreadyProvisioned() => new(true, null, null, null, null);

public static FirstRunAdminOutcome Provisioned(string email, Guid accountId, string password) =>
new(false, email, accountId, password);
public static FirstRunAdminOutcome Provisioned(
string email, Guid accountId, string slug, string password) =>
new(false, email, accountId, slug, password);
}
Loading
Loading