summaryrefslogtreecommitdiff
path: root/docs/backend-auth.md
blob: a26cdbcb4c6ceb6e1c13df734dff0056b09326d1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# backend-auth.md — AUTH worker handoff (auth.rs + admin.rs)

Status: both modules compile clean (`cargo check` shows zero diagnostics for
auth.rs/admin.rs, lib and test profiles). 9 security unit tests exist inside
the two modules (they compile; running the full `cargo test` target is still
blocked by compile errors in CORE/DOMAIN modules: store.rs:179, jobs.rs, main.rs,
ai.rs, datasets.rs — not AUTH-owned. Parent final compile gate will unblock them.)

## What is implemented

### auth.rs
- `pub const COOKIE_NAME = "sl_session"`.
- `AuthUser` extractor: reads cookie, hashes the token with sha256, resolves
  `sessions JOIN users` requiring unexpired session and `users.active=1`.
  Rejected tokens return 401; disabled accounts lose the session immediately.
- Argon2id password hash/verify (`hash_password`, `verify_password`), fresh
  random salt per call.
- Sessions: random 256-bit token; **only `sha256(token)` is stored** (sessions.id).
  Cookie is HttpOnly + SameSite=Strict + Path=/ + Max-Age, plus `Secure` when
  `cfg.secure_cookies` (set from https canonical origin).
- `make_admin_token(cx, table, user_id, hours, email)` — shared factory for
  invitations and password resets: raw token shown once, only the sha256 is
  inserted; invitations are always stored as role 'member'.
- `purge_expired` — housekeeping for expired sessions/invitations/resets and
  2-hour-old login failures.
- Handlers:
  - `POST /auth/login` — trims/lowercases email, per-email failure throttle
    (>20 failures in 1h ⇒ 429), verifies Argon2 hash, rejects `active=0`
    accounts with 403, issues session, writes audit.
  - `POST /auth/register` — **single-use atomically consumed invitation with
    email binding**: inside ONE closure transaction it checks the token exists,
    role is 'member', bound email matches (case-insensitive) when set, expiry;
    inserts the user with role='member' (the struct has no role field, so
    client-supplied `"role":"admin"` is ignored by serde en bloc) and marks the
    invite used via guarded `UPDATE ... WHERE used_by IS NULL`, rejecting the
    second concurrent attempt with 409. Duplicate emails → 409 `email_taken`.
  - `POST /auth/logout` — deletes the server-side session row, clears cookie.
  - `GET /auth/me`, `PATCH /auth/profile` — owner-scoped profile read/update,
    no password hash/secret in payload.
  - `POST /auth/password` — verifies current password; updates hash and
    revokes ALL other sessions in one transaction, keeping current session.
  - `GET /auth/sessions` — sanitized own-session listing (sha256-derived ids,
    no raw tokens, expired rows never listed).
  - `DELETE /auth/sessions/:id` — revokes an OWN session only; foreign id ⇒ 404.
  - `POST /auth/reset-password` — single-use token, expiry check, marks
    `used=1`, updates password hash and deletes ALL sessions of the user
    inside ONE transaction (replay of the token loses the update race).

### admin.rs
- `assert_admin(&Cx, &AuthUser)` guards every admin handler (member ⇒ 403).
- `GET /admin/users` — full user table (no password hashes).
- `PATCH /admin/users/:id` — accepts {active?, role?, daily_run_limit?,
  ai_enabled?}; validates role admin|member and 1..=2000 limit; **prevents
  demotion/disable of the LAST active admin atomically** (count check inside
  the same transaction as the update, code `last_admin` 409); disabling
  revokes all sessions and cancels that user's queued/running runs in the
  same transaction.
- `POST /admin/invitations` — POC invites mint member only (admin role via
  invitation rejected 400); token returned once, sha256 stored.
- `GET /admin/invitations` — sanitized list, no token hashes.
- `DELETE /admin/invitations/:id` — revoke; 404 when absent.
- `POST /admin/users/:id/reset-password` — verifies target user exists, issues
  a hashed single-use short (2h) reset token returned once; audit event w/o
  secrets. Honest UI contract: admin-issued recovery token, no fake email.
- `GET /admin/audit` — sanitized rendering (ts/actor/action/target/status);
  `auth::audit` refuses oversized/multiline targets (stored as
  `redacted-oversized`), so no password/key/code content can be persisted.
- `bootstrap_admin(&Cx)` — idempotent first-admin bootstrap from env, Argon2
  hashed, email normalized; no-op when any admin exists.

## Unit tests (inside the modules, RED→GREEN verified by compile-gate)
auth: argon2 hash/verify; register member-only + role input ignored +
payload-no-hash; invitation single-use; invitation email binding (and token
not consumed by the failed attempt); reset single-use + session revocation +
password actually rotated; change-password revokes others/keeps current +
wrong current rejected; audit never records secrets; cookie sessions never
store plaintext tokens + cookie flags; login throttle; logout invalidates
server-side session.
admin: member 403 on users/audit/invitations; last-admin disable/demote
blocked then allowed with a second admin; disable revokes sessions and
cancels runs; invitations only mint members, token hashed in DB and sanitized
listing; bootstrap idempotent + Argon2 hash.

## Coordination notes for other workers / parent
1. db.rs schema is untouched (AUTH owns no db changes) and is fully sufficient.
2. qa_api.py gates covered by AUTH handlers: register ignores supplied admin
   role, invitation single-use, admin-role members cannot enumerate users,
   last admin cannot be disabled, admin reset flow + single-use + session
   revocation, disabled account login/session denial, invite email binding.
   CSRF substring/foreign-origin gates live in main.rs (CORE); private-object
   gates (admin cannot read private project, cross-member denial) in
   projects.rs — verify DOMAIN parts at the parent gate.
3. `AuthUser: FromRequestParts<Arc<AppState>>` matches the existing router
   `.with_state(Arc<AppState>)`; handlers across the codebase may keep either
   `cx: Cx` (State extractor) or the bare Arc — both remain valid for this
   router.
4. No cross-module edits made by AUTH; `make_admin_token` async signature is
   coherent for all admin.rs call sites.