NextMyDay/docs/specs/spec-007-roles-chain-recovery.md
Claus Lohmar 04c2394c34 docs: record the M0 review fixes and correct the README
REVIEW-001 triage updated (H1, H2, M1c, M1s, L3s, L1c/L2s, L8c, M2s, M3s,
M2c, L1s, L5s, L4s, L7c marked FIXED; L3c, L4c, L5c, L6c stay open with the
reason), and each fix is tied to its code and test.

- SPEC-003: the door pipeline (XFF right→left, the per-request setup gate,
  the isolated hardening headers, the pinned setup module), §6 the `__Host-`
  cookie and the startup-generated signing keys, §7 loopback enforcement
  per request, new AC-10…AC-12, D-2 amended (session_key removed), and a new
  §11 that maps every REVIEW-001 finding to its fix.
- SPEC-005 / SPEC-006 / SPEC-007: the same three points where they belong
  (the wizard's loopback + module pin, the nudge's configured-origin signal,
  the async /access notification ruled in).
- PROJECT_PLAN §10 register: A13 re-stated as enforced (per request + pin);
  A2/A5/A8 re-stated as enforced where the review verified them; A3/A6/A9/A15
  now enforced by this pass; A11 left open (L4c). §14 change log entries.
- README: the status said "planning / pre-alpha · nothing is built" — it is
  stale. State M0+M0.1, note that /setup PERSISTS config.yaml and a restart
  adopts the generated secrets, and state the per-request loopback rule.
2026-10-14 11:30:17 +00:00

4.9 KiB

SPEC-007 — roles, the chain & recovery

Field Value
Status Implemented (2026-10-13) — see §7; build/vet/test/-race green · static binary · live-smoked
Created 2026-10-13
Author OpenArchitect
Target milestone M0
Governing decisions D-007 · D-009 · D-019 · D-020 · D-033 · D-034 · PROJECT_PLAN.md §6.4/§7
Related SPEC-004 (identity) · SPEC-006 (login)

1. Goal

The role ladder, the manager chain, and the two recovery paths. Onboarding is top-down (D-019): a manager always exists before their reports.

2. Roles (D-034) — simple tiers, no ACL, one per user

admin  >  director  >  manager  >  team
  • The assigned role ripples down — a director may do all a manager/team member can.
  • One role per user. The chain (manager_user_id) is separate: it answers who approves you, not what you may do.

3. The chain & creation authority (D-019)

  • Creation authority = a role strictly above the new user's; the new user's manager defaults to the creator. admin is the root (manager_user_id = NULL).
  • Top-down: the admin creates the first directors; directors create managers; managers create team.

4. Recovery — non-admin (manager escalation)

GET  /access                       → enter the email on record
POST /access?do=auth&action=recover → ALWAYS generic ("if a record exists, we've emailed you")
   └─ match → recovery_request(pending) · notify the approver chain + the employee
Approver (**an ancestor in the chain** — the manager upward — or the **admin**) → approve / deny   (approver ≠ requester · both audited)
   └─ on approval: revoke the OLD identity → mint a NEW GUID → email the enrolment link
Employee re-enrols Passkey + TOTP under the NEW GUID → /{old GUID} permanently dead
  • Pending requests expire (~72h); a capped number per user.
  • Email only initiates; a human in the chain authorises (D-009). Identity rotation (D-007) means a compromised inbox cannot take over alone.

5. Recovery — the admin (config-gated)

operator sets admin_recovery: true in config.yaml → restart
   → rotate user 1's identity (pending_enrollment)
   → EMAIL the enrolment link to user 1's ADDRESS ON RECORD
   → audited · the flag is cleared

Without the flag, nothing happens (dual control, D-033).

6. Acceptance criteria

# Criterion
AC-1 Creating a user requires a role above theirs; the creator is the default manager
AC-2 A user cannot approve their own recovery request
AC-3 Approval revokes the old identity + all its sessions and mints a new GUID
AC-4 /access never reveals whether the email matched (generic response)
AC-5 admin_recovery: false ⇒ no rotation, no email
AC-6 Every request, approval, denial, rotation and enable is audited (A11)

7. Implementation notes (accepted rulings)

Built 2026-10-13. build · vet · test · -race green; static binary; live-smoked — /access returns a byte-identical generic page for known and unknown email; admin flag on → rotate + email + flag cleared; flag off → nothing. Step 0 landed (R-6 secrets generated · F-2 no-store).

# Builder choice Ruling
D-1 Approve/deny at POST /{GUID}?do=recovery&action=approve|deny&request={id} (session-gated) Accepted — the request action lives under the approver's own GUID.
R-7 Approver rule = "a role strictly above the requester, never self". Change — the authority is the chain: the requester's manager upward (an ancestor) or the admin. §4 amended. Landed with SPEC-008 (Step 0): users.CanApprove(approver, requester, approverIsAncestor) + recovery.isAncestor; TestCanApproveChainRule and TestSpec007AC2_R7_ChainAuthority.
D-2 Only the immediate manager + the employee are notified Accepted for M0; wider chain fan-out deferred.
D-3 Claim-then-rotate; on a rotation failure the request stays approved + audited (never double-rotates) Accepted — safe.
D-4 A live pending request is reused, so the cap (3) is a hard bound only under concurrency Accepted.
D-5 Admin-recovery flag clearing rewrites config.yaml; unwritable → logged, continue Accepted (D-035).
D-6 The /access notification runs off the request path (a Dispatch seam; go fn() by default, context detached with context.WithoutCancel, bounded by a 30s timeout) Accepted 2026-10-14 (REVIEW-001 M2s): a hit must not be timed against a miss — a synchronous send was an enumeration oracle (A3). Deps.Dispatch lets tests run it synchronously for determinism.

Follow-up (deferred): notify the wider chain, not only the direct manager.