## Context

See `proposal.md` — Why. What follows is the state this design has to work against.

**Authorization today is one dimension.** `app/services/policy.py` holds a role→capability
matrix and `app/services/auth.py`'s `require(Action.X)` applies it. That answers "may this role
do this kind of thing" and nothing else. There is no second question, so every admin route
resolves records by id alone: `select(Event).where(Event.id == event_id)`. Adding a
who-may-reach-what dimension touches every one of the **42 admin endpoints** across
`admin_events`, `admin_guests`, `admin_messaging`, `admin_stats`, `admin_cards`, `admin_data`
and `admin_wedding`.

**`event` has no owner column.** Guests already belong to exactly one event (design D11 of
`add-event-invitation-card`), which is the one piece of luck here: guest scope can be derived
from event scope instead of being stored twice.

**`admin_user` carries one boolean.** `is_active` currently means "may sign in". The pending
state this change introduces is a third value, not a second one.

**`AdminRole` is a Postgres enum type** with three members. Postgres can add enum values
cheaply and cannot remove them at all, so collapsing to two is a type swap.

**The session is an `itsdangerous` signed cookie** holding only the email, 12-hour expiry, with
the role deliberately re-read from the database on every request (design D7). That last property
is the one worth keeping through everything below.

**No passwords exist anywhere.** `AdminUser` has no `password_hash` by explicit decision, and
`CLAUDE.md` states it. This change reverses that, and the reversal is the single largest source
of new attack surface in it: a Google assertion cannot be guessed and a password can.

**Google sign-in already works.** Verified against the running dev stack while planning: the API
fetches Google's JWKS and performs real verification (a malformed token produced
`MalformedError` → 401), the client id reaches the web container, and the GIS button renders
from `accounts.google.com/gsi/button` — which it would refuse to do if the origin were not
registered on the OAuth client. `last_login_at` is NULL on every `admin_user` row, so the path
is wired and has never been walked by a person.

**Two constraints from CLAUDE.md bind this change directly.** Authorization is decided by
FastAPI on its own authority and never inherited from anything Next.js sets — a `can()` in the
frontend hides buttons and enforces nothing. And server components map API responses to explicit
view models before handing them to client components, which applies to the new roster and profile
screens: an admin roster is a list of people's email addresses.

## Goals / Non-Goals

**Goals:**

- One place that answers "which events may this caller touch", consulted by every admin query.
- Scope applied where records are *selected*, so an out-of-scope record is never loaded.
- A test that fails when a new admin endpoint is added without scoping it.
- Password sign-in that does not weaken the revocation properties the cookie session already has.
- The dev username sign-in kept, specified, and provably absent from production.

**Non-Goals:**

- Per-event collaborators, or more than one host per event. One owner, one column.
- Row-level security in Postgres. The API is the only database client; a second enforcement
  layer with its own session-variable plumbing buys nothing here and can disagree with the first.
- Reworking guest-level permissions. A guest's reachability is derived from its event, never
  stored.
- Domain restriction or invite codes on self-registration. Anyone may reach the pending queue.
- **A self-service forgotten-password flow.** There is no channel for it (D14) — recovery is a
  super admin issuing a new temporary password.
- Two-factor authentication. PRD FR-4.1's TOTP stays out of scope; this change restores
  passwords, not the whole original auth story.
- Refresh tokens, token rotation, or a server-side session table. One two-day token (D12).
- Accounts that can sign in both ways. Kind is fixed at creation (D16).
- Touching the guest-facing surfaces (`/i/{token}`, `/e/{slug}`). Bearer tokens are unaffected.

## Decisions

### D1 — Ownership is one nullable-then-NOT-NULL column on `event`

`event.owner_admin_id` → `admin_user.id`, `ON DELETE RESTRICT`, indexed, `NOT NULL` after
backfill.

`RESTRICT` rather than `SET NULL` or `CASCADE` is the important part. `SET NULL` produces an
orphaned event that no host can see and no query will surface — data that exists and is
unreachable. `CASCADE` deletes a customer's wedding because an employee left. `RESTRICT` forces
the transfer-then-remove sequence the `admin-user-management` spec requires, and the database
enforces it even if the endpoint forgets.

*Alternative considered:* an `event_host` join table for many owners. Rejected for now — it turns
every scoped query into a join and the product has no multi-host requirement. The column can
become a join table later; the reverse is harder.

### D2 — Scope is a query fragment, not a post-filter

One helper, in a new `app/services/scope.py`:

```python
def visible_events(admin: CurrentAdmin) -> ColumnElement[bool]     # for WHERE on Event
def owns_event(admin: CurrentAdmin, event_id: UUID) -> ...          # guard for a known id
async def scoped_event(session, admin, event_id) -> Event          # load-or-404
```

Super Admin returns `true_()`, so the same code path serves both roles and there is no
`if role == SUPER_ADMIN` branch scattered through 42 endpoints to get wrong.

The rule is that scope goes into the `WHERE` clause. Loading the record and then comparing
`event.owner_admin_id` works for a single fetch and fails everywhere else: aggregates would count
rows the caller may not see before discarding them, exports would stream them, and any endpoint
returning a list would need the check repeated per row. Selecting under scope makes the leak
structurally impossible rather than conditionally absent.

*Alternative considered:* a FastAPI dependency that resolves and authorizes an `event_id` path
parameter. Adopted where it fits (`scoped_event`), but it cannot cover the endpoints that take no
event id — `/admin/stats`, `/admin/guests`, `/admin/export`, `/admin/messages` — which are exactly
the ones that leak most broadly. So the dependency is a convenience over the helper, not the
mechanism.

### D3 — Out of scope answers 404; forbidden capability answers 403

`require(Action.X)` keeps returning 403: the caller is authenticated and the answer says nothing
about any particular record. Scope failures return 404, identical to a fabricated id.

They have to be distinguishable in code and indistinguishable on the wire. A 403 on a real id and
a 404 on a fake one lets a host enumerate event ids and learn how many customers exist and when
their weddings are. This is the same reasoning that made the sign-in error generic, applied to
records instead of accounts.

### D4 — `is_active` becomes a three-valued `status`

`admin_user.status`: `pending | active | withdrawn`, replacing `is_active` rather than joining it.

Pending and withdrawn are both "cannot sign in" and must be told apart — a pending account gets
"awaiting approval" and appears in the approval queue, a withdrawn one gets "access withdrawn"
and does not. Keeping the boolean and adding a flag beside it creates a state where they
disagree, and then every reader has to decide which one wins.

`lookup_active_admin` becomes `status == ACTIVE`, which is one predicate change, and the
audit-visible transitions become explicit values instead of a boolean flip.

Migration: `is_active = true → active`, `false → withdrawn`. Nothing becomes pending
retroactively; pending is only ever reached by a first Google sign-in.

### D5 — Role enum swap: new type, swap, drop old

Postgres cannot remove an enum value. The migration creates `admin_role_v2` with
`('super_admin','host')`, adds a new column, maps `super_admin → super_admin`,
`co_host → host` (active), `viewer → host` (withdrawn), then swaps and drops. Downgrade
recreates the three-member type and maps every `host` back to `co_host` — lossy by nature, and
the docstring says so, because a `viewer` demoted to `host` cannot be told from a former
`co_host` afterwards.

`viewer → withdrawn` is the deliberate part. A viewer could read and export and nothing else;
`host` can send messages to guests and delete them. Silently granting that in a migration is a
privilege escalation performed by a deploy, and nobody reviews a deploy for that. They land in
the approval queue where a super admin makes the decision on purpose.

### D6 — Self-provisioning reuses the existing rate-limit table

A new bucket (`admin_signup`) in the `rate_limit` table already used for public endpoints
(design D10), keyed on the hashed source address. Provisioning happens **after** Google
verification succeeds, so an attacker needs a real verified Google account per record, and the
rate limit caps how fast a pool of them can fill the roster.

The pending record is created in its own committed transaction before the 401 is raised — the
same shape the existing rejected-sign-in audit row already needs, and for the same reason: the
thing being recorded is the failure, and the failure response rolls the request's session back.

Only Google sign-in provisions. Password accounts are created by a super admin and the dev
shortcut creates nothing, so there is exactly one door into the roster that is not a deliberate
administrative act.

### D7 — The generic sign-in error is retired for Google account state, kept for everything else

`GENERIC_SIGNIN_ERROR` stays for a malformed token, a failed signature, an unverified email, and
**every password failure** — the cases where the caller has proven nothing. Once Google has
verified the address, the response says which state the account is in.

The oracle that message protected is gone on the Google path: with self-registration, anyone can
discover that an address is not on the allowlist by signing in and being told they are pending.
Keeping it generic would no longer hide anything and would strand every legitimate new host at a
dead end that reads as a bug.

The password path is the opposite case and keeps the generic message, because there the caller has
proven nothing at all and the difference between "no such username" and "wrong password" is worth
real money to someone guessing.

### D8 — The bootstrap super admin is provisioned at startup, not only by `make seed`

`SEED_SUPER_ADMIN_EMAIL` is read on boot in every environment and upserted as an active Super
Admin of the **Google** kind, idempotently: insert if absent, and if present leave role, status
and any password alone.  It joins `assert_production_ready()`'s required set.

`make seed` is a dev-only target that also writes a demo wedding and templates, so it cannot be
the production path. Without provisioning at boot, a fresh production database has an empty
roster, the first Google sign-in creates a pending record, and there is nobody with
`MANAGE_ADMINS` to approve it — the system locks itself out on first deploy. Not overwriting an
existing row matters just as much: a super admin who deliberately demoted that account must not
have it restored by the next restart.

### D9 — The dev sign-in is unchanged; only its status changes

`/auth/dev` already requires `auth_dev_bypass` **and** `not is_prod`, and
`assert_production_ready()` already refuses to boot production with the flag set. The frontend
already gates the form on `authDevBypassEnabled()` read at runtime through `public-config.ts`.
That is three independent gates and they are correct.

This change adds tests that pin them, and leaves the gating alone. Two behaviours are stated
explicitly because they were only implicit: the dev sign-in **never provisions** — an unknown
email there is refused, not queued — and it does not lift the temporary-password confinement.
Now that a real password form sits on the same page, the dev control is labelled as a development
shortcut so the two are not confused.

### D10 — Migration order

Additive first, destructive last, so a failure mid-way leaves a working system:

1. Add `status`, backfill from `is_active`, keep `is_active` in place.
2. Add the credential columns (D13), all nullable — no existing row has a password.
3. Add `owner_admin_id` nullable.
4. Provision/ensure the bootstrap super admin row.
5. Backfill every existing event's owner to it (2 events in the current dev database).
6. `owner_admin_id` → `NOT NULL`.
7. Role type swap.
8. Drop `is_active`.

### D11 — A test that enumerates the routes

`test_admin_route_guards.py` gains a scope axis and a confinement axis, driven by walking
`app.routes` rather than a hand-written list: every route under `/admin` must appear in a coverage
map declaring how it is scoped and whether it is reachable under a temporary password, and an
unlisted route fails the test.

A hand-maintained list of 42 endpoints is a list that goes stale the first time someone adds an
endpoint in a hurry, and the failure mode of a missed one is a silent cross-customer read. The
test makes adding an unscoped admin route impossible to do quietly.

### D12 — The JWT replaces the cookie's contents, not the cookie

The session becomes a `pyjwt` HS256 token, two-day expiry, set in the **same httpOnly, secure,
sameSite cookie** that carries the `itsdangerous` value today. No `Authorization: Bearer` header,
no `localStorage`, no `sessionStorage`.

"JWT-based" describes the token format; it does not require handing the token to page JavaScript,
and doing so would be a straight downgrade. The current session is unreadable from JavaScript, so
an XSS on an admin screen cannot exfiltrate it. Put the same token in `localStorage` and it can,
and it is then valid for two days from any machine — a longer-lived and more portable credential
than the 12-hour cookie it replaced. Keeping it in the cookie means the browser attaches it
automatically, `sameSite=lax` keeps its CSRF properties, and nothing about the frontend's
server-side fetching (design D8) has to change.

*Alternative considered:* a bearer header with the token in memory only, refreshed from a
refresh-token cookie. That is the standard shape when an API serves third-party clients. This API
serves one first-party frontend behind the same origin, so it would add a refresh endpoint, a
rotation story and a silent-renewal race for no gain.

Claims: `sub` (account id), `iat`, `exp`, `jti`. Deliberately **not** the role, and not the scope.

### D13 — Argon2 hashes, and the credential columns

New dependency `argon2-cffi`; `pyjwt` for D12. On `admin_user`:

| Column | Why |
|---|---|
| `auth_method` (`google` \| `password`) | D16 — fixed at creation |
| `username` | unique, case-insensitive, password accounts only |
| `password_hash` | Argon2id, nullable — Google accounts have none |
| `must_change_password` | the confinement flag (D14) |
| `password_expires_at` | bounds an unused temporary password (D14) |
| `password_set_at` | reporting, and "is this still the temporary one" |
| `failed_login_count`, `locked_until` | per-account lockout (D15) |
| `sessions_valid_from` | token invalidation (D17) |

Argon2id over bcrypt: memory-hard, no 72-byte truncation surprise, and `argon2-cffi` is a single
maintained library rather than passlib's larger surface. Uniqueness on `username` is enforced by a
functional unique index on `lower(username)`, in the database — an application-level check loses
the race between two concurrent creates, and this one is cheap to get right.

### D14 — Confinement is a dependency, not a redirect

`must_change_password` is checked by a new dependency that wraps `get_current_admin` and is
applied to **every** admin route except `/auth/me`, the password-change endpoint, and sign-out. It
returns 403 with a machine-readable marker the frontend uses to route to the change screen.

Enforcing it in the frontend alone would make the confinement a suggestion: the API would happily
serve a full guest list to a session holding a password an administrator chose and may have sent
over WhatsApp. The dependency is applied by default and opted out of by three named routes, rather
than opted in to, so a new endpoint is confined unless someone deliberately says otherwise.

The temporary password expires (`password_expires_at`) because an unused one is a standing
credential known to at least two people and sitting in whatever channel it was sent through.
Setting an own password clears the flag, the expiry and the temporary mark in one transaction.

There is no forgotten-password flow. The system's only outbound email is guest-facing — the
messaging pipeline, its suppression lists and its quiet hours all assume a guest — and routing
admin credential mail through it would put reset links behind guest unsubscribe state. Recovery is
a super admin issuing a new temporary password, which is also the only recovery an operator of
this size actually needs.

### D15 — Lockout per account, rate limit per address

Two independent controls, because they stop different attacks. `failed_login_count` +
`locked_until` on the account stops guessing at one password. A `password_signin` bucket in the
existing `rate_limit` table, keyed on hashed IP, stops one attacker spreading a few guesses across
many accounts to stay under every per-account threshold.

A locked account answers exactly as an unknown username does, so the lockout is not itself a way
to enumerate which usernames exist.

### D16 — Kind is fixed at creation, and the two paths never cross

`auth_method` is set when the account is created and cannot be edited. A password account's
address presented as a Google assertion does not sign in, and a Google account's address is not
accepted by the password form.

The alternative — one account reachable by either means — sounds friendlier and quietly makes the
weaker credential authoritative: an account with a password is only ever as strong as that
password, no matter how well its Google account is protected. Converting between kinds has the
same problem in a single step, and a conversion is exactly the operation an attacker who has
compromised a super admin session would reach for. Moving someone between kinds is
create-new-and-withdraw-old, which leaves two audit entries and no ambiguity about which
credential was live when.

### D17 — Revocation still comes from the database, never from the token

The token says who; the database says whether, what role, and what scope, on every single request.
`get_current_admin` already re-reads the row and this design keeps that unchanged — it is what
makes D12's two-day lifetime safe, and it is why role is not a claim.

`sessions_valid_from` handles the one case the row lookup cannot: a token that is still valid for
an account that is still active, issued before a password change. Any token whose `iat` precedes
that moment is refused. It moves on a self-service password change, on a super admin issuing a
temporary password, and on withdrawal.

The trade-off in going 12 hours → 2 days is accepted deliberately: it is four times the window in
which a stolen cookie is useful. What makes it tolerable is that the window is the *only* thing
that lengthened — demotion, withdrawal and password change all still take effect on the next
request, so the token cannot outlive the authority behind it.

## Risks / Trade-offs

**A missed endpoint leaks another customer's guest list** → D11's route-enumerating test is the
primary control; D2's select-time scoping means the miss has to be a whole endpoint rather than a
forgotten branch. The two-role matrix keeps the blast radius to hosts, who are trusted people
with the wrong scope, not anonymous callers.

**Passwords are guessable and Google assertions are not** → This change adds the first credential
in the system that can be attacked from the open internet with nothing but a wordlist. D15's two
controls, D13's Argon2 parameters and the password quality rules are the whole defence; there is
no second factor behind them (a stated non-goal). An operator who wants stronger assurance should
keep their super admins on Google accounts and hand out password accounts only where necessary.

**A temporary password travels over whatever channel the admin chooses** → Shown once, expiring,
single-use in effect since the first sign-in forces a change, and never logged. It is still the
weakest link in the flow, and it is weak in a way the system cannot fix.

**Two-day tokens widen the theft window** → Mitigated by D17 and by the cookie being unreadable
from JavaScript (D12). Not mitigated for a stolen device with an unlocked browser.

**The enum swap runs against a live table** → Both a type swap and a `NOT NULL` add take an
`ACCESS EXCLUSIVE` lock. The tables are tiny (2 admins, 2 events) and this is a single-tenant
deployment with an accepted maintenance window, so a plain migration is right; the alternative
(add-column, dual-write, backfill, cut over) is machinery for a problem this size does not have.
Run it with the app stopped.

**Downgrade is lossy** → A `host` cannot be resolved back to whether it was a `co_host` or a
`viewer`. Documented in the migration; rollback below is restore-from-backup, not downgrade.

**Everyone is signed out on deploy** → The cookie changes format, so every existing session is
refused. Harmless here (`last_login_at` is NULL on every row — nobody has ever signed in), but it
is a real effect and belongs in the deploy notes rather than in a support conversation.

**Self-registration fills the roster** → Rate-limited (D6), gated behind a verified Google
account, and confined to `pending`, which grants nothing. A pending record is an inert row.

**A host loses access to their own event by transfer** → Deliberate and the point of the feature,
but it is a foot-gun: a mis-clicked transfer silently removes someone's entire working set. The
transfer control confirms, names both parties, and writes an audit entry.

**Existing data is not clean** → The dev database holds `card-7dbbac@example.com` as an active
`super_admin`, left by a card test. Under this change it becomes an active Super Admin with sight
of every event. Auditing the roster before deploying is a task, not an afterthought.

**Frontend and API can disagree about scope** → The frontend hides what it believes the caller
cannot reach; the API refuses independently. A stale frontend is then a cosmetic bug. This is only
true while nothing reads scope from a header Next.js sets — the boundary CLAUDE.md protects.

## Migration Plan

1. Stop the app (`make down`), take a backup with `make backup` — database **and** `media/`.
2. `make migrate` runs the sequence in D10.
3. Set the JWT signing secret in `.env.prod`. Production refuses to start without it, and without
   `SEED_SUPER_ADMIN_EMAIL`.
4. Start the app. Startup provisions the bootstrap super admin idempotently.
5. Sign in as the bootstrap super admin and audit the roster: confirm every account's kind, role
   and status, remove or withdraw leftovers such as `card-7dbbac@example.com`, and assign each
   event's owner away from the bootstrap account where a real host should hold it.
6. Confirm the Google path: sign in with a Google account not on the roster, see the pending
   state, approve it as a Host, confirm it sees only what it owns.
7. Confirm the password path: create a password Host, note the temporary password, sign in as it,
   confirm every admin screen is refused until the password is changed, change it, confirm the
   Host then sees only its own events.

**Rollback:** restore from the step-1 backup. The alembic downgrade exists and runs, but it maps
every `host` back to `co_host` and drops password columns, so it recovers the schema and not the
intent. For anything past a smoke test, restore.

## Open Questions

- Whether a Host should be able to hand an event to another Host without a Super Admin. Deferred:
  the specs put transfer under Super Admin only, and relaxing it later adds a permission without
  changing any model or query.
- Whether the pending queue should notify super admins by email. Deferred, and now with a second
  reason: D14 establishes that admin-facing mail has no channel, so this would need one built
  either way.
- Exact Argon2 cost parameters. Deferred: a tuning question answered by measuring on the
  deployment host, not a design question — the library's defaults are a safe starting point and
  changing them later rehashes on next sign-in.
