Skip to content

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=admin is 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 as auditforge_session_token, sends as Authorization: Bearer <token> on subsequent requests
  • Admin token — legacy path; stores in localStorage as metis_admin_token, sends as x-admin-token header

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 helpers
  • app/auditforge_endpoints.py_resolve_caller + _require_admin (both-paths) + 7 new auth/user endpoints
  • frontend/src/api/auditforge.ts — login/logout/authMe/changePassword + session token storage
  • frontend/src/components/AuditForgeLanding.tsx — login form with mode toggle
  • frontend/src/components/AuditForge.tsx — Sign-out button when session active