Files

208 lines
8.3 KiB
Markdown
Raw Permalink Normal View History

# Staging auth rehearsal (no SMTP)
Operator checklist to prove **promote company admin → re-issue set-password → one login** on staging Postgres **without** real SMTP. Delivery path = **copy invite URL** from migrator stdout / `artifacts/password_invites.json`.
Companion scripts (print SQL + optionally run invite re-issue):
- `scripts/staging-auth-rehearsal.ps1` (Windows / Laragon)
- `scripts/staging-auth-rehearsal.sh` (bash)
Full cutover remains [cutover.md](cutover.md). Status/blockers: [migration-readiness.md](migration-readiness.md).
## Scope
| In | Out |
|---|---|
| Promote one company membership `member``admin` | Production DNS / Clerk decommission |
| Re-issue set-password invites (`-issue-set-password-invites`) | `cmd/mailhooks` / SMTP send |
| Copy one printed URL → `/accept-invite` → set password → `/login` | Live `.env` files (do not commit or paste secrets into docs) |
| Confirm `must_set_password = false` | Bulk email blast |
**ASSUMPTION:** Staging already has a migrated Postgres (see [migration-run-log.md](migration-run-log.md)). This rehearsal does **not** re-run MySQL ETL.
## Preconditions
- [ ] Staging API + web reachable (defaults: API `:8080`, web `http://localhost:5174`)
- [ ] Operator-supplied `DATABASE_URL` in the shell only — **do not** load or edit committed secrets; **do not** use production `.env`
- [ ] At least one smoke user has a **real** email (not `*@legacy.local`) — `prepareSetPasswordHooks` skips synthetics via `IsSyntheticLegacyEmail`
- [ ] You know which membership to promote (email + company), or will pick from the listing SQL below
- [ ] `artifacts/` exists or will be created; gitignored — never commit invite tokens
Local default DSN (matches `docker-compose.yml` / cutover quick reference):
```text
postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable
```
Optional: set `WEB_ORIGIN` so printed links match the web you will open. Default when unset: `http://localhost:5174` (API `config.Load`, migrator `webOrigin`, Vite `apps/web/vite.config.ts`, `.env.example`).
## 0. Health (optional)
```bash
curl -sS http://127.0.0.1:8080/healthz
curl -sS http://127.0.0.1:8080/readyz
```
Both should return ready-ish JSON; `/readyz` fails if Postgres is down.
## 1. Promote company admin
Import defaults all memberships to `role=member`. Company-admin APIs require session role `admin` (`CompanyAdminAllowed`).
**Preferred:** migrator Postgres-only tooling (dry-run first; live needs `-confirm`). See also [cutover.md](cutover.md).
```bash
cd apps/api
export DATABASE_URL="postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable"
# List active role=member rows (read-only)
go run ./cmd/migrator -list-member-memberships -postgres "$DATABASE_URL"
# Preview promote for one email or company (no writes; A1 rows always skipped as a1_cohort)
go run ./cmd/migrator -promote-company-admins -email '<real-email>' -dry-run -postgres "$DATABASE_URL"
# or: -company-id '<non-a1-uuid>' -dry-run
# Apply only after reviewing dry-run output (never promotes a1=true)
go run ./cmd/migrator -promote-company-admins -email '<real-email>' -confirm -postgres "$DATABASE_URL"
```
Optional narrow: `-user-id <uuid>` and/or `-company-id <uuid>` (`-company-id` alone is enough to scope). Unscoped promote is refused. **Never** promote A1 (`a1=true`); local Demo is already admin via `seed-demo` — see [demo-user.md](demo-user.md).
### 1a. List memberships (SQL alternate)
```sql
SELECT u.id AS user_id,
u.email,
u.must_set_password,
u.is_platform_admin,
m.company_id,
c.name AS company_name,
m.role,
m.status
FROM memberships m
JOIN users u ON u.id = m.user_id
JOIN companies c ON c.id = m.company_id
WHERE m.status = 'active'
ORDER BY c.name, u.email;
```
### 1b. Promote one row (SQL alternate; operator fills UUIDs)
```sql
UPDATE memberships
SET role = 'admin'
WHERE user_id = '<user-uuid>'
AND company_id = '<company-uuid>'
AND status = 'active'
RETURNING user_id, company_id, role;
```
Verify:
```sql
SELECT role FROM memberships
WHERE user_id = '<user-uuid>' AND company_id = '<company-uuid>';
-- expect: admin
```
### 1c. Platform admin (separate)
Legacy `admin_users``users.is_platform_admin` is applied during migrator load (`applyPlatformAdmins`), not by the SQL above. For `/admin` routes, confirm:
```sql
SELECT email, is_platform_admin FROM users WHERE is_platform_admin = true;
```
If the smoke user must be a platform admin and is not, set only on staging with an explicit operator decision:
```sql
UPDATE users SET is_platform_admin = true WHERE email = lower('<real-email>');
```
### 1d. Alternate (after someone can already log in)
Settings Team → Make admin, or `PATCH /api/team/{userID}` (requires an existing company admin session). Prefer SQL for first staging rehearsal when nobody can log in yet.
## 2. Re-issue set-password (link copy — no SMTP)
Staging load used `-skip-post-import`, so invites were not created automatically. Re-issue is Postgres-only.
```bash
cd apps/api
export DATABASE_URL="postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable"
# Optional: export WEB_ORIGIN="http://localhost:5174"
go run ./cmd/migrator -issue-set-password-invites \
-postgres "$DATABASE_URL" \
-maps-dir ../../artifacts
```
PowerShell:
```powershell
cd apps/api
$env:DATABASE_URL = "postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable"
# Optional: $env:WEB_ORIGIN = "http://localhost:5174"
go run ./cmd/migrator -issue-set-password-invites `
-postgres $env:DATABASE_URL `
-maps-dir ../../artifacts
```
Or: `.\scripts\staging-auth-rehearsal.ps1 -IssueInvites` (requires `DATABASE_URL` in the environment).
### What success looks like
- Stdout: `=== Set-password invite URLs ===` then `email<TAB>url` lines (`writeSetPasswordArtifacts`)
- Files (gitignored): `artifacts/password_invites.json`, `artifacts/set-password-hooks.json`
- Counts: `set_password_hooks: N`; synthetic emails appear as skips, not URLs
- Dry-run only: add `-dry-run` — prints `mode: dry-run (no invites written)` and writes nothing
**Do not** run `go run ./cmd/mailhooks …` for this rehearsal. SMTP proof is a separate gate ([ops-runtime.md](ops-runtime.md), [cutover.md](cutover.md) §4).
Invite URLs are `/accept-invite?token=…` (DB invite row → `POST /api/auth/accept-invite`). That differs from admin HMAC links (`?mode=set-password` via `POST /api/admin/emails/set-password`), which need a configured mailer.
## 3. One login smoke
1. Pick **one** row from stdout (or `password_invites.json``url`) for the promoted / real-email user.
2. Open the URL in a browser against your staging web origin.
3. Submit a password (≥8 characters).
4. Confirm you land in-app (invite accept may establish a session) **or** are sent to `/login`.
5. Sign in at `/login` with the same email + new password.
6. Confirm in Postgres:
```sql
SELECT email, must_set_password, password_hash IS NOT NULL AS has_hash
FROM users
WHERE lower(email) = lower('<smoke-email>');
-- expect: must_set_password = false, has_hash = true
```
7. If you promoted company admin: open a company-admin surface (e.g. Settings team) and confirm it is allowed.
8. If platform admin: open `/admin`.
### Fail closed
| Symptom | Likely cause |
|---|---|
| No URLs printed | All candidates `@legacy.local`, inactive, or no active membership |
| Accept-invite error | Expired invite, wrong `WEB_ORIGIN`, or token already used — re-issue |
| Login rejected | Password not set / still `must_set_password` / wrong email |
| Company admin 403 | Membership still `member` |
### Escape hatch (not this rehearsal)
`go run ./cmd/migrator -set-password "email:pass" -postgres "$DATABASE_URL"` bypasses the invite link path — local bootstrap only, not proof of cutover invite flow.
## Pass / fail
**Pass** when: one company admin membership is `admin`, one set-password invite was issued and opened without SMTP, and that user can log in once with `must_set_password = false`.
**Fail / stop** if you need SMTP, production credentials, or a live `.env` committed to the repo. Document the blocker in [migration-readiness.md](migration-readiness.md) instead of flipping DNS.
## Related
- [cutover.md](cutover.md) §4 Set passwords after import
- [migration-readiness.md](migration-readiness.md) After a live import
- [go-live-checklist.md](go-live-checklist.md) Staging login rehearsal
- [ops-runtime.md](ops-runtime.md) SMTP (deferred)