Per-user authentication (Phase 8)¶
What¶
Email + password user accounts with argon2id-hashed passwords and opaque session tokens. Replaces the shared admin token as the primary auth mechanism for partner-firm users while keeping the admin token as a backward-compat platform admin path.
Closes the "shared admin token" entry on the SOC 2 Type 2 hardening list.
Architecture¶
Two auth paths, both supported on every existing endpoint:
| Path | Header | Auth context | Use for |
|---|---|---|---|
| Admin token | x-admin-token: <token> |
Platform admin, firm_id=None (sees all firms), is_admin=True |
Pilot onboarding, smoke tests, platform support, CD smoke checks |
| User session | Authorization: Bearer <session_token> |
Per-user, scoped to their firm via caller.firm_id |
Day-to-day partner workflows |
The frontend sends both headers when both are present. The backend checks admin-token first; falls back to bearer-token; raises 401 if neither validates.
Data model¶
User¶
class User:
id: str # u-<hex>
email: str # unique (case-insensitive)
firm_id: str # FK to Firm
role: str # "admin" | "partner" | "associate"
password_hash: str # argon2id
display_name: str
created_at: str
updated_at: str
last_login_at: str | None
must_change_password: bool # forced rotation on first login
Persisted at s3://{bucket}/auditforge/users.json (lazy-load + best-effort upload).
Session¶
class Session:
token: str # 32 random bytes, urlsafe base64
user_id: str
firm_id: str
created_at: str
expires_at: str # 12 hours from creation by default
last_used_at: str
Persisted at s3://{bucket}/auditforge/sessions.json. Expired sessions GC'd on every load.
Endpoints¶
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /auth/login |
none | Email + password → session token |
| POST | /auth/logout |
bearer | Revoke the bearer-token session |
| GET | /auth/me |
either | Resolve current caller context |
| POST | /auth/change-password |
bearer | User changes own password (revokes other sessions) |
| POST | /user |
admin-only | Create a user account |
| GET | /user |
admin-only | List users (filter by firm_id) |
| DELETE | /user/{id} |
admin-only | Delete a user, revoke their sessions |
Roles¶
- admin — platform-admin equivalent. Can do firm/user CRUD, sees all engagements regardless of firm. A user with
role=adminis functionally equivalent to an admin-token caller. - partner — typical role. Scoped to their firm; can do all engagement workflows (create, run, review, accept/reject/refine/edit, investigate-further, export) within their firm.
- associate — same scope as partner today. Reserved for future read-only / drafting permissions.
Role enforcement is currently coarse — caller.is_admin distinguishes admin from partner; partner and associate are equivalent. Fine-grained role checks are an open follow-up.
Per-firm scoping¶
GET /auditforge/engagement is scoped to the caller's firm when authed via session token. The firm_id query parameter is ignored for non-admin session callers — they always see only their firm's engagements. Admin-token callers and admin-role users see all firms (with optional firm_id filter).
Phase 9 update: per-engagement scoping is now enforced at every detail endpoint. Cross-firm access by a non-admin session caller returns 404 (not 403) to prevent ID enumeration — the caller can't tell whether the engagement exists in another firm or doesn't exist at all. See 22-per-engagement-firm-scoping.md.
Frontend¶
The AuditForgeLanding token gate now offers two modes via toggle:
- Email + password (default) — calls
/auth/login, stores returned token in localStorage asauditforge_session_token, sends asAuthorization: Bearer <token>on subsequent requests - Admin token — legacy path; stores in localStorage as
metis_admin_token, sends asx-admin-tokenheader
The "Sign out" button in the header calls /auth/logout (revokes server-side session) and clears localStorage. When the session token is set, the button label is "Sign out"; when only an admin token is set, it's "Clear token".
Bootstrapping users¶
There's no public sign-up. Creating the first per-user account requires the admin token:
curl -X POST https://metis-demo.base2ml.com/auditforge/user \
-H "Content-Type: application/json" \
-H "x-admin-token: <ADMIN_TOKEN>" \
-d '{
"email": "partner@acme-audit.com",
"firm_id": "firm-acme",
"role": "partner",
"password": "<initial-password>",
"display_name": "Pat Partner",
"must_change_password": true
}'
The user signs in with the initial password, the UI prompts them to change it, the change-password endpoint rotates the credential and revokes the initial session. Admin token holder never sees the partner's chosen password.
Security properties¶
- argon2id for password hashing (memory-hard, parallel-resistant; defaults to library-recommended parameters)
- Random session tokens (32 bytes from
secrets.token_urlsafe) — opaque, server-side validated - TTL of 12 hours — partner re-auths each working day
- Server-side revocation — logout removes the session from S3; no JWT-style replay window
- Constant-ish-time login — invalid email and invalid password return the same 401 message; prevents email enumeration
- No password in logs — the auth/login handler never logs the body
- must_change_password gate — admin-issued initial credentials force rotation
Open follow-ups¶
- Per-engagement firm scoping (today: only list endpoint scopes)
- Fine-grained role checks (admin / partner / associate distinction beyond
is_admin) - Password reset flow (email-based; needs SES wiring)
- MFA / TOTP (nice-to-have for SOC 2 Type 2)
- SAML/SSO via Auth0 / AWS Cognito (Enterprise tier)
- Brute-force lockout (rate-limit at the load balancer first; per-account lockout in app layer)
- Audit log entries attribute mutations to user_id (today: ✎ Edit notes record
[EDITED]but don't store user_id)
Code¶
app/auditforge/users.py— User + Session dataclasses, UserStore, SessionStore, password hashing helpersapp/auditforge_endpoints.py—_resolve_caller+_require_admin(both-paths) + 7 new auth/user endpointsfrontend/src/api/auditforge.ts— login/logout/authMe/changePassword + session token storagefrontend/src/components/AuditForgeLanding.tsx— login form with mode togglefrontend/src/components/AuditForge.tsx— Sign-out button when session active