summaryrefslogtreecommitdiff
path: root/docs/backend-auth.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/backend-auth.md')
-rw-r--r--docs/backend-auth.md98
1 files changed, 98 insertions, 0 deletions
diff --git a/docs/backend-auth.md b/docs/backend-auth.md
new file mode 100644
index 0000000..a26cdbc
--- /dev/null
+++ b/docs/backend-auth.md
@@ -0,0 +1,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.