MCP per-user authorization — how it works & how to use it¶
docgen is a shared service called by many apps and agents. When it gathers data from the MCP source servers (HubSpot, Google Drive, Fellow), the end user's identity flows through docgen to those servers so a user only sees data they're allowed to — Google Drive enforces each user's real file ACLs. This page is the operator's guide: the model, how to turn it on, and how to test it locally.
The model in one picture¶
caller app/agent
│ Authorization: Bearer <caller's Entra token> (+ X-Org-Slug)
▼
docgen ── AuthMiddleware verifies the token → request.state.user (the caller's email)
│ ── stashes that identity request-scoped (ContextVar; safe across gather's threads)
▼
gather → MCPTool (per-user mode)
│ X-AltaForge-User-Identity: {"user_email": "<caller>"} ← the verified caller
▼
MCP server (hubspot / gdrive / fellow)
── Casbin authorizes the AGENT (is DealDesk allowed to call this tool?) [coarse]
── gdrive impersonates the VERIFIED USER (delegated_user = caller email) [fine]
▼
Google / Fellow apply that user's own ACLs ← real per-user data protection
Two layers, deliberately split:
- Agent authorization (Casbin): "is this caller service allowed to invoke this tool at all?" — coarse, static policy in the accelerator repo (configs/prod/casbin-policy.csv), keyed on the agent principal.
- User data scoping (impersonation): "which files can this user see?" — fine-grained, enforced by Google itself when the gdrive server impersonates the verified user. No per-user Casbin policy — users are dynamic; Google's ACLs are the source of truth.
The user identity is the existing altaforge permission.User (user_email), transported in the X-AltaForge-User-Identity header (mirrors the existing X-AltaForge-Agent-Identity header).
Turning it on¶
Per-user forwarding is off by default — docgen behaves exactly as before until you set:
| Env var | Where | Meaning |
|---|---|---|
DOCGEN_MCP_PER_USER=1 |
docgen runtime (uvicorn docgen.server) |
Register the MCP source tools in per-user mode: forward the caller's identity per call, fail closed if there's no caller identity. |
MCP_HUBSPOT_URL / MCP_GDRIVE_URL / MCP_FELLOW_URL (+ _TOKEN) |
docgen runtime | The MCP server endpoints (set in apps/web/.env.local or the process env). Unset → that source is skipped. |
AF_PERMISSIONS_ENABLED=true |
each MCP server | Enables Casbin agent authorization on the server. Off in dev → the server skips authz entirely (no policy setup needed locally). |
When DOCGEN_MCP_PER_USER is on and a request has no verified caller identity, the MCP call raises AuthError (fail-closed) — docgen never silently falls back to a service identity.
Testing it locally (no ADO, no Google service account, no Casbin)¶
The fake DealDesk MCP server (docgen.mcp_servers.dealdesk_fake) lets you exercise the whole path with made-up data. It reflects the caller's identity back as _served_for_user, so you can see the identity flow.
# one command: fake MCP server + uvicorn + Next, in per-user mode
cd apps/web && DOCGEN_MCP_PER_USER=1 pnpm dev:fake
pnpm dev:fake starts the fake server on :8465, points MCP_HUBSPOT_URL/GDRIVE/FELLOW at it, and (with DOCGEN_MCP_PER_USER=1) runs docgen in per-user mode. Generate a DealDesk proposal in the UI — the gather lanes forward your logged-in identity, and the fake server logs called for user=<you> and returns _served_for_user.
Fake token, real plumbing. Dev uses a real, locally-signed JWT (the same JwtAuthBackend verification as production, just a local issuer) — there is no separate "dev bypass." Mint one for any user and drive the path directly:
# terminal 1
DEALDESK_FAKE_PORT=8466 uv run python -m docgen.mcp_servers.dealdesk_fake
# terminal 2
uv run python -c "
from docgen import auth_dev, identity
from docgen.tools.mcp import MCPTool
from docgen.errors import AuthError
auth_dev.configure_env()
t = MCPTool(server_url='http://127.0.0.1:8466/mcp', streamable_http=True, per_user=True)
try: t.invoke({'tool_name':'deals.get_full_context','arguments':{'deal_id':'hs-12345'}})
except AuthError: print('no identity -> fail-closed (correct)')
identity.set_current_user_email('jane@altaml.com')
out = t.invoke({'tool_name':'deals.get_full_context','arguments':{'deal_id':'hs-12345'}})
print('served_for_user =', out['result']['_served_for_user']) # jane@altaml.com
"
Change the email → the server reflects a different user. That's the whole dev loop: flip the env, pick an email.
Production path¶
- The caller (app/agent) presents the end user's Entra token to docgen.
AuthMiddleware(JwtAuthBackend) verifies it →request.state.user(the verified email). - docgen (
DOCGEN_MCP_PER_USER=1) sendsX-AltaForge-User-Identity(apermission.User) on each MCP call. T2 token model: services share an audience, so the call also carries the bearer for transport — see the security note below. - The MCP server (
AF_PERMISSIONS_ENABLED=true) Casbin-authorizes the agent (fromX-AltaForge-Agent-Identity), and gdrive impersonates the verified user (current_user().user_email) so Google enforces that user's Drive ACLs. A client-supplieddelegated_useris ignored — callers can't impersonate someone else.
Security properties¶
- Server is authoritative: each MCP server enforces; docgen only forwards its own verified caller identity (it cannot assert an arbitrary user).
- No impersonation spoofing: gdrive derives the impersonation target from the verified
X-AltaForge-User-Identity, never from a client argument. - Fail-closed: per-user mode with no caller identity →
AuthError, never a service-identity fallback. - Deny-by-default Casbin for agents; off by default end to end (toggle).
- T2 caveat: a token valid at docgen is valid at every MCP server (shared audience). Mitigate: HTTPS only; never log the token (scrub from Arize spans); short token TTL.
Reference¶
- docgen send-side:
src/docgen/identity.py(request-scoped caller identity),src/docgen/tools/mcp.py(MCPToolper-user mode),src/docgen/tools/mcp_sources.py(registration). - Framework:
altaforge.mcp.server.base—X_ALTAFORGE_USER_IDENTITY_HEADER,current_user(). - Servers:
altaml/altaforge-acceleratorsmcp/gdrive(impersonation),configs/prod/casbin-policy.csv(agent policies). - Design:
docs/design/specs/2026-06-18-mcp-per-user-authz-option1-revised.md.