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¶
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}/rolewith 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}/roleadmin-only role transition;caller.is_admincheck added todelete_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/mereturns 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
Related¶
- 21-per-user-auth.md — base user/role model
- 09-per-engagement-firm-scoping.md — firm-level scoping that this layers on top of