Skip to content

Fine-grained roles (Phase 15)

What

Distinguish the three user roles AuditForge has always had defined but treated as equivalent (Phase 8 only checked is_admin):

Role Scope Read Mutate User/firm CRUD
admin Platform-wide
partner Their firm
associate Their firm

Associates get read-only access. They can browse engagements, view findings, export deliverables and audit logs — but they cannot accept/reject/refine/edit findings, create engagements, kick off audit runs, or trigger LLM-cost operations like portfolio cluster recompute and AI-assisted intake.

Why

Audit firms typically split work between senior partners (judgment, sign-off) and associates (document review prep, meeting notes). Today AuditForge couldn't enforce that split — every partner-firm user with credentials could mutate findings before partner review.

With fine-grained roles, an associate can: - Help the partner triage by browsing the audit's findings - Pull the audit log for procurement review - Export the deliverable for the partner to review

But the act of marking a finding accepted/rejected/refined — the partner-billable judgment — stays with the partner.

Implementation

A new _require_mutate(caller) helper:

def _require_mutate(caller: _Caller) -> None:
    if caller.is_admin or caller.user_id is None:
        return  # admin token OR admin-role user
    user = get_user_store().get(caller.user_id)
    if user is None:
        raise HTTPException(403, "User no longer exists.")
    if user.role == "associate":
        raise HTTPException(
            403,
            "Associates have read-only access. "
            "Ask a partner to perform this action.",
        )

Inserted at the top of every mutating endpoint after the auth check.

Where it gates

Endpoint Gated
POST /engagement (create)
DELETE /engagement/{id}
POST /engagement/{id}/intake
POST /engagement/{id}/run
POST /finding/{id}/accept
POST /finding/{id}/reject
POST /finding/{id}/refine
POST /finding/{id}/edit
POST /finding/{id}/investigate-further ✅ (LLM cost)
POST /findings/portfolio-clusters/recompute ✅ (LLM cost)
POST /intake/extract ✅ (LLM cost)
GET * (list / detail / search / export) — read OK for associates
/auth/* (self-ops: login / logout / change-password / TOTP) — self-ops always allowed
/firm/*, /user/* admin-only via existing _require_admin + explicit caller.is_admin checks added in Phase 15

Role transitions

POST /auditforge/user/{user_id}/role
body: { "role": "partner" }   # admin | partner | associate

Admin-only. Applies the new role and revokes all active sessions for the user so the change takes effect on next login (no stale-permission window).

Default role

POST /user uses role: "partner" as the default if the caller doesn't specify. Most firms onboard partners first; associates are added later as needed.

Edge cases

  • Admin-token caller: caller.user_id is None → bypass _require_mutate. The admin token is unrestricted (it's the platform-admin recovery path).
  • User no longer exists: 403 (rather than 401) — the caller's session is technically valid but their account was deleted. Forces a re-login.
  • Role string invalid: POST /user/{id}/role with role not in {admin, partner, associate} → 422.
  • Associate self-mutates own MFA: allowed. Self-ops are exempt from _require_mutate.

Frontend implications

Today the UI doesn't disable buttons based on role — an associate can click "Accept" but the API returns 403. Future polish (deferred):

  • Hide / disable mutating buttons for associates
  • Display the user's role badge in the header
  • Show "ask a partner to perform this action" inline instead of as an error toast

The current behavior is correct (the server enforces the boundary); frontend polish is purely UX.

Code

  • app/auditforge_endpoints.py_require_mutate(caller) helper; inserted at 11 mutating endpoint sites; POST /user/{id}/role admin-only role transition; caller.is_admin check added to delete_user (was implicit; now explicit so admin-token + admin-role both pass)

Open follow-ups

  • Hide mutating UI affordances for associates — purely cosmetic; server enforcement is correct
  • /auth/me returns role — frontend can read it and adapt
  • Per-firm "associate-only" engagements — partner can mark an engagement's review as fully associate-driven (no partner sign-off required) for routine work; today every engagement is partner-reviewed
  • Read-only flag at firm level — pause a firm's mutations during external audit cooperation period