Skip to content

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}/intake
  • POST /auditforge/engagement/{id}/run
  • GET /auditforge/engagement/{id}/stream
  • GET /auditforge/engagement/{id}/findings
  • GET /auditforge/engagement/{id}/deliverable
  • POST /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}/edit
  • POST /auditforge/finding/{id}/investigate-further

Plus list-style endpoints scope at the query level:

  • GET /auditforge/engagement — non-admin sessions filter to caller's firm
  • GET /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

curl -H "x-admin-token: <ADMIN_TOKEN>" /auditforge/engagement/eng-firm-b-id
# → 200 OK

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_access helper + 12 per-engagement endpoint integrations + _update_finding_status accepts caller parameter

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)