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.
91 lines
4.9 KiB
Markdown
91 lines
4.9 KiB
Markdown
# 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.
|