Per-engagement firm scoping (Phase 9)¶
What¶
Every per-engagement endpoint now enforces firm scoping for non-admin session callers. A partner authenticated via session token can only access engagements where engagement.firm_id == user.firm_id. Cross-firm access returns 404 Not Found (not 403 Forbidden) to prevent ID enumeration — a malicious user can't tell whether an engagement exists in another firm or doesn't exist at all.
This closes the last ID-guessing risk in the SOC 2 Type 2 hardening punch list.
Why 404 not 403¶
Returning 403 on cross-firm access leaks information: the caller learns that the engagement ID exists, just in a firm they don't have access to. With enough probing, they could enumerate all engagement IDs across the platform.
Returning 404 (the same response as a non-existent ID) means the caller can't distinguish "exists but I can't see it" from "doesn't exist." The information leak is closed.
This is the standard pattern in security-conscious APIs (GitHub, GitLab, AWS) — though it does mean the caller has to triage 404s differently in their own logic.
Scoping rules¶
| Caller type | Access pattern |
|---|---|
Admin token (legacy x-admin-token) |
Full access — all firms, all engagements |
Admin-role user (role=admin, session) |
Full access — all firms, all engagements |
Partner / associate user (session, caller.firm_id set) |
Scoped — only engagement.firm_id == caller.firm_id |
Non-admin user with firm_id=None (malformed record) |
403 — refuses access entirely |
Where it applies¶
The _require_engagement_access(eng, caller) helper is called immediately after every engagement load in:
GET /auditforge/engagement/{id}DELETE /auditforge/engagement/{id}POST /auditforge/engagement/{id}/intakePOST /auditforge/engagement/{id}/runGET /auditforge/engagement/{id}/streamGET /auditforge/engagement/{id}/findingsGET /auditforge/engagement/{id}/deliverablePOST /auditforge/finding/{id}/accept(via_update_finding_status)POST /auditforge/finding/{id}/reject(via_update_finding_status)POST /auditforge/finding/{id}/refine(via_update_finding_status)POST /auditforge/finding/{id}/editPOST /auditforge/finding/{id}/investigate-further
Plus list-style endpoints scope at the query level:
GET /auditforge/engagement— non-admin sessions filter to caller's firmGET /auditforge/findings/search— non-admin sessions search only their firm's findings
Implementation¶
def _require_engagement_access(eng, caller: _Caller) -> None:
"""Phase 9 — per-engagement firm scoping.
Admin-token callers and admin-role users have full access.
Non-admin session callers can only access engagements in their
own firm; cross-firm access returns 404 (not 403) to prevent
ID enumeration."""
if caller.is_admin or caller.user_id is None:
return # platform admin OR admin-role user — full access
if caller.firm_id is None:
raise HTTPException(
status_code=403,
detail="User has no firm assignment; cannot access engagements.",
)
if eng.firm_id != caller.firm_id:
raise HTTPException(status_code=404, detail="Engagement not found.")
The _update_finding_status helper takes caller: _Caller as a keyword parameter so accept/reject/refine all enforce the check transparently.
Testing the scoping¶
Cross-firm access by partner returns 404¶
# Partner of firm-A signs in
PARTNER_TOKEN=$(curl -sX POST -d '{"email":"partner@firm-a.com","password":"..."}' \
-H 'Content-Type: application/json' /auditforge/auth/login | jq -r .token)
# Tries to access an engagement in firm-B
curl -H "Authorization: Bearer $PARTNER_TOKEN" /auditforge/engagement/eng-firm-b-id
# → 404 Not Found (looks identical to a truly-missing ID)
Admin-role user keeps full access¶
ADMIN_USER_TOKEN=$(curl -sX POST -d '{"email":"admin@firm-a.com","password":"..."}' \
-H 'Content-Type: application/json' /auditforge/auth/login | jq -r .token)
curl -H "Authorization: Bearer $ADMIN_USER_TOKEN" /auditforge/engagement/eng-firm-b-id
# → 200 OK with full engagement payload
Legacy admin-token still works¶
Open follow-ups¶
- Fine-grained roles — today partner and associate are equivalent; a role-aware permission layer (e.g., associate can view but not delete) is on the roadmap
- Engagement transfer — moving an engagement between firms would require explicit re-assignment logic; today firm_id is set at create-time and never changes
- Audit log — every access denial should land in the audit log so the firm's compliance team can review attempted cross-firm access (today: only logged at app-level, not engagement-level)
Code¶
app/auditforge_endpoints.py—_require_engagement_accesshelper + 12 per-engagement endpoint integrations +_update_finding_statusacceptscallerparameter
SOC 2 Type 2 status¶
With Phase 9 shipped, the major hardening items are complete:
- ✅ Per-user authentication (Phase 8)
- ✅ Per-engagement firm scoping (Phase 9)
- ✅ Per-engagement S3 bucket isolation (Phase 7, behind feature flag)
- ✅ Encrypted at rest (S3 default, AES-256)
- ✅ Encrypted in transit (ALB HTTPS, ACM cert)
- ✅ Audit log per engagement
- ⏳ External audit kickoff (next quarter)