Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
153 lines
8.9 KiB
Markdown
153 lines
8.9 KiB
Markdown
---
|
|
phase: 12-initial-setup-wizard
|
|
plan: 03
|
|
type: tdd
|
|
wave: 2
|
|
depends_on: ["12-01"]
|
|
files_modified:
|
|
- apps/api/src/auth/user.ts
|
|
- apps/api/tests/auth/user.test.ts
|
|
autonomous: true
|
|
requirements: [SETUP-01]
|
|
must_haves:
|
|
truths:
|
|
- "The first OIDC login AFTER app_config.setup_complete='true' claims the single unclaimed local user (oidc_iss IS NULL AND claimed=false), populating oidc_iss/oidc_sub and setting claimed=true"
|
|
- "The claimed user keeps its is_admin and credential — no new admin row is created"
|
|
- "The claim NEVER keys on email — match is by oidc_iss IS NULL AND claimed=false only (D-10)"
|
|
- "Existing OIDC users (claimed=true from the Plan-01 backfill) are matched by identity as before and never re-claimed"
|
|
- "When setup_complete is not yet true (or no unclaimed user exists), upsertUser falls through to the normal new-user insert path"
|
|
artifacts:
|
|
- path: "apps/api/src/auth/user.ts"
|
|
provides: "upsertUser with the first-login-claims branch (repurposed first-login-wins)"
|
|
contains: "claimed"
|
|
- path: "apps/api/tests/auth/user.test.ts"
|
|
provides: "D-08 first-login-claims tests (claim, no-email-key, no-double-claim, fallthrough)"
|
|
contains: "claimed"
|
|
key_links:
|
|
- from: "apps/api/src/auth/user.ts"
|
|
to: "app_config.setup_complete"
|
|
via: "read before the claim branch"
|
|
pattern: "setup_complete"
|
|
- from: "apps/api/src/auth/user.ts"
|
|
to: "users (oidc_iss IS NULL AND claimed=false)"
|
|
via: "claim query"
|
|
pattern: "isNull\\(users.oidcIss\\)"
|
|
---
|
|
|
|
<objective>
|
|
Rework `upsertUser` in `apps/api/src/auth/user.ts` to implement first-login-claims (D-08): the first
|
|
OIDC login after `app_config.setup_complete='true'` claims the single unclaimed pre-OIDC local user
|
|
(provisioned by the wizard in Plan 02) instead of minting a fresh admin. This repurposes the Phase 10
|
|
first-login-wins bootstrap — the WR-01 rework the code comment at user.ts l.114 explicitly defers to
|
|
Phase 12. TDD plan: claim behavior tests are written before/with the logic change.
|
|
|
|
Purpose: Without this, the wizard-created local user (oidc_iss NULL, is_admin=true, holding the
|
|
validated credential) would be orphaned and the first OIDC login would create a second admin. SETUP-01's
|
|
"first run → guided bootstrap" only closes the loop once the operator's OIDC identity adopts that local user.
|
|
Output: A claim-aware upsertUser that preserves the identity model (no email keying) and the credential + admin status.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
|
@$HOME/.claude/gsd-core/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/12-initial-setup-wizard/12-CONTEXT.md
|
|
@.planning/phases/12-initial-setup-wizard/12-RESEARCH.md
|
|
@.planning/phases/12-initial-setup-wizard/12-PATTERNS.md
|
|
@apps/api/src/auth/user.ts
|
|
</context>
|
|
|
|
## Artifacts this phase produces (Plan 03 portion)
|
|
|
|
- `upsertUser` first-login-claims branch in `apps/api/src/auth/user.ts`:
|
|
- reads `app_config.setup_complete`
|
|
- when true, claims the unclaimed local user (`WHERE oidc_iss IS NULL AND claimed=false LIMIT 1`), sets `oidc_iss`/`oidc_sub`/`claimed=true`, preserves `is_admin` + credential
|
|
- `shouldBeAdmin` for the normal insert path becomes `setup_complete !== 'true' && admin count === 0`
|
|
- `apps/api/tests/auth/user.test.ts` — D-08 claim test cases (turning the Plan-01 scaffolds green)
|
|
|
|
<tasks>
|
|
|
|
<task type="tdd" tdd="true">
|
|
<name>Task 1: First-login-claims branch in upsertUser (D-08)</name>
|
|
<files>apps/api/src/auth/user.ts, apps/api/tests/auth/user.test.ts</files>
|
|
<read_first>
|
|
- apps/api/src/auth/user.ts (the file being modified — identity lookup l.76-97, first-login-wins block l.112-123, insert path l.125-141)
|
|
- apps/api/tests/auth/user.test.ts (existing upsertUser tests + the Plan-01 D-08 scaffold)
|
|
- .planning/phases/12-initial-setup-wizard/12-PATTERNS.md §auth/user.ts (the exact replacement pattern, import additions, claim query)
|
|
- .planning/phases/12-initial-setup-wizard/12-RESEARCH.md §Pattern 4 + Pitfall 4 (no email keying) + §Migration backfill (claimed=true for existing OIDC users)
|
|
</read_first>
|
|
<behavior>
|
|
- Existing identity match (oidc_iss+oidc_sub present) → returns/updates that row as today (unchanged); never re-claims
|
|
- setup_complete='true' AND an unclaimed user exists (oidc_iss IS NULL AND claimed=false) → claim it: set oidc_iss, oidc_sub, claimed=true, keep is_admin; return the claimed row
|
|
- setup_complete='true' AND no unclaimed user → normal insert path, NOT auto-admin (an admin already exists from the claim model)
|
|
- setup_complete !== 'true' → existing first-login-wins behavior preserved (shouldBeAdmin = admin count === 0)
|
|
- Claim query uses isNull(users.oidcIss) AND eq(users.claimed,false) — asserts NO claims.email / no email column lookup
|
|
</behavior>
|
|
<action>
|
|
Per PATTERNS.md §auth/user.ts: add `isNull` to the drizzle-orm import and `appConfig` to the
|
|
schema import. After the existing identity lookup (step 1, l.76-97) and before the insert (step 4),
|
|
read `app_config.setup_complete`. If its value === 'true', select the single unclaimed user
|
|
`WHERE isNull(users.oidcIss) AND eq(users.claimed, false) LIMIT 1`; if found, `db.update(users).set({
|
|
oidcIss, oidcSub, claimed: true, displayName: displayName ?? unclaimed.displayName }).where(eq(
|
|
users.id, unclaimed.id))` and return `{ ...unclaimed, oidcIss, oidcSub, claimed: true }` (is_admin
|
|
preserved — not overwritten). Replace the `shouldBeAdmin = Number(count) === 0` line with
|
|
`shouldBeAdmin = flagRow?.value !== 'true' && Number(count) === 0` so the normal insert path no
|
|
longer self-promotes once setup is complete. MUST NOT introduce any email-keyed matching (D-10 /
|
|
Pitfall 4). Turn the Plan-01 D-08 scaffolds GREEN and add: claim success (fields + is_admin
|
|
preserved), no-double-claim (a claimed user is not re-claimed), no-email-key (assert the query path
|
|
references no email), and the setup_complete-false fallthrough.
|
|
</action>
|
|
<acceptance_criteria>
|
|
- source: `grep -c "isNull(users.oidcIss)" apps/api/src/auth/user.ts` returns >= 1
|
|
- source: claim path reads setup_complete (`grep -c "setup_complete" apps/api/src/auth/user.ts` >= 1)
|
|
- source: NO email keying in the claim — `grep -Ec "claims\.email|users\.email|eq\(.*email" apps/api/src/auth/user.ts` returns 0
|
|
- source: shouldBeAdmin gated on setup_complete (`grep -Ec "value !== 'true'.*count|flagRow.*shouldBeAdmin|shouldBeAdmin =.*!= 'true'" apps/api/src/auth/user.ts` >= 1)
|
|
- source: the claim sets claimed=true (`grep -c "claimed: true" apps/api/src/auth/user.ts` >= 1)
|
|
- test: user.test.ts D-08 cases pass (claim success/admin-preserved, no-double-claim, fallthrough)
|
|
</acceptance_criteria>
|
|
<verify>
|
|
<automated>cd apps/api && pnpm test -- user && pnpm typecheck</automated>
|
|
</verify>
|
|
<done>upsertUser claims the unclaimed local user after setup_complete, preserves is_admin, never keys on email, and falls through correctly when setup is incomplete; user.test.ts green.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<threat_model>
|
|
## Trust Boundaries
|
|
|
|
| Boundary | Description |
|
|
|----------|-------------|
|
|
| Authelia OIDC callback → upsertUser | claims supplied by the IdP drive the claim/merge of a pre-existing local user |
|
|
|
|
## STRIDE Threat Register
|
|
|
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
|
|-----------|----------|-----------|-------------|-----------------|
|
|
| T-12-10 | Spoofing | first-login-claims claiming the wrong user | accept | Claim query is `oidc_iss IS NULL AND claimed=false LIMIT 1`; exactly one pending user exists in a 2-person household; OIDC reach requires Authelia household membership (documented claim-window assumption, D-08) |
|
|
| T-12-11 | Elevation of Privilege | unexpected auto-admin after setup | mitigate | shouldBeAdmin gated to `setup_complete !== 'true'` — once setup completes, new logins do not self-promote; admin comes only from the claimed local user |
|
|
| T-12-12 | Tampering | email-keyed identity coupling | mitigate | Acceptance gate forbids claims.email/users.email lookups (D-10 / Pitfall 4); match is identity-null + claimed-false only |
|
|
| T-12-SC | Tampering | npm/pip/cargo installs | accept | Zero new packages this plan — no legitimacy checkpoint needed |
|
|
</threat_model>
|
|
|
|
<verification>
|
|
- `pnpm --filter @familysync/api test -- user` green (claim, no-double-claim, no-email-key, fallthrough)
|
|
- `cd apps/api && pnpm typecheck` green
|
|
- Source greps: isNull(users.oidcIss) present; no email keying; shouldBeAdmin gated on setup_complete
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- D-08 first-login-claims: first OIDC login after setup_complete claims the unclaimed local user, preserving is_admin + credential
|
|
- No email coupling; existing OIDC users (backfilled claimed=true) never re-claimed
|
|
- Normal insert path no longer auto-promotes admin once setup is complete
|
|
</success_criteria>
|
|
|
|
<output>
|
|
Create `.planning/phases/12-initial-setup-wizard/12-03-SUMMARY.md` when done
|
|
</output>
|