External MCP Service-Key Policy (ENG-6796, P2.5) #
Classification: Restricted (describes source code, infrastructure, and access control).
Design for the Tofu-API-Key (service-key) authentication path in tofu-external-mcp, alongside the existing Clerk OAuth path. No key has been issued yet — this is the policy that governs the first one, whenever a headless consumer actually shows up. Parent: ENG-6758.
What a service key is #
A bonsapi ApiKey (the same row/HMAC-digest mechanism the customer-facing “API keys” org settings page already creates via createApiKey), presented on the Tofu-API-Key header instead of a Clerk-issued Authorization: Bearer token. bonsapi’s BonsaiUserOrApiKey / BonsaiUserInEntityOrApiKey extractors (apps/bonsapi/src/model/app/bonsai_user.rs) accept either credential; a plain BonsaiUser / BonsaiUserInEntity extractor accepts only the Clerk header. Which is which, per operation, is the credential map below.
An API-key-authenticated request gets permissions = all_access() and skips entity-scoping entirely on bonsapi’s side — a key acts org-wide, never per-user. There is no narrower scope to request; this is bonsapi’s existing behavior, not something introduced by this design.
Credential map (source of truth: ENG-6768, verified 2026-07-22 against main)
#
168/168 external.yaml operations mapped. Exactly 8 are API-key-compatible; every other mapped operation is JWT-only. Implemented in src/service-key-policy.ts, enforced per-tool in tools/with-audit.ts via each tool’s bonsapiOperationId option — a tool with no declared bonsapiOperationId rejects every service-key call by default.
| Endpoint | Method | operationId | MCP tool today |
|---|---|---|---|
/api/v1/documents |
POST | createDocument |
none yet |
/api/v1/documents |
GET | getDocuments |
list_documents |
/api/v1/documents/stale |
GET | getStaleDocuments |
none yet |
/api/v1/documents/{documentId} |
GET | getDocument |
get_document |
/api/v1/documents/{documentId}/download |
GET | downloadDocumentFile |
download_document_file |
/api/v1/documents/{documentId}/metadata |
GET | getDocumentMetadata |
none yet |
/api/v1/entities |
GET | getEntities |
none yet |
/api/v1/entities/{entityId} |
GET | getEntity |
none yet |
Two traps (still true, carried over from ENG-6768 — do not re-derive this table by resource-group name):
PATCH /api/v1/documents/{documentId}(updateDocument) isBonsaiUserInEntity→ JWT-only, despite siblingdocumentsroutes accepting API keys.GET /api/v1/healthcheck(no auth at all) andGET /api/v1/updates(no backing handler) are excluded entirely — neither classifies as JWT-only or API-key-compatible.
whoami (wraps getEntityRoles) is JWT-only — not in the table above — so a service key calling it gets the rejection message, not a bonsapi round-trip.
Rejection behavior (implemented) #
A service-key call to a tool not backed by one of the 8 operations above returns an MCP tool error (isError: true) naming the tool and the bonsapi operation it wraps, pointing back to this document (serviceKeyRejectionMessage() in src/service-key-policy.ts, invoked from src/tools/with-audit.ts’s service-key policy gate). The call never reaches bonsapi — no forwarded request, no chance of bonsapi’s all_access() org-wide bypass producing a misleadingly-successful result for an operation this server never intended to expose to a service key. The rejection is still audit-logged (tools/with-audit.ts → logging/audit-log.ts), tagged auth_mode: "api_key", same as any other tool call.
A service-key call to one of the 8 compatible tools is forwarded with the raw key on the Tofu-API-Key header (bonsapi/custom-fetch.ts) exactly as received — this server holds no key material and does not itself validate the key’s format or existence; that’s bonsapi’s HMAC-digest lookup.
Issuance policy (design only — no key issued) #
- Super-user issued. Created via bonsapi’s existing
createApiKeyendpoint by BonsAI ops/engineering, not by an end-user org’s own admin. - Named per purpose. The
namefield passed tocreateApiKeyidentifies the consumer (e.g. a specific headless integration), not a person — so a compromised or retired key is identifiable and revocable without guessing which automation used it. - Stored and rotated in Doppler, under the
TOFU_EXTERNAL_MCP_*prefix — same secrets-management path as this app’s other credentials (TOFU_EXTERNAL_MCP_CLERK_*), not a new mechanism. - Never handed to end users. This key authenticates a headless backend consumer talking to
tofu-external-mcpdirectly; it is not something a customer or their integration ever holds, unlike the org-facing API keys customers generate for their own automations viacreateApiKey/Settings. - First key is issued only when a headless consumer actually appears. Until then this path exists in code (so the rejection/acceptance behavior above is real and tested) but has zero live traffic.
Acceptance criteria #
- A JWT-only tool (
whoami) called with a service key returns the clear rejection message — verified at unit level (src/tools/__tests__/with-audit.test.ts). - API-key-compatible tools (
get_document, and — as of ENG-6970 —list_documentsanddownload_document_file) succeed with a service key — verified at unit level: the policy gate lets the call through andcustom-fetch.tsforwards the raw key onTofu-API-Key. No live bonsapi round-trip attempted, since no real key exists yet (by design — see Issuance policy above and Verification below). - Service-key policy is written down (this document).
Verification #
Unit-level: src/__tests__/service-key-policy.test.ts, src/tools/__tests__/with-audit.test.ts, and src/middleware/__tests__/auth.test.ts cover the credential map, the rejection/acceptance gate, and the middleware’s handling of the Tofu-API-Key header (accepted standalone, Authorization takes precedence when both are present, raw key available downstream via AsyncLocalStorage).
No live end-to-end proof against a real bonsapi-issued service key — no key exists yet (by design; see Issuance policy above). When a real headless consumer appears and a first key is issued, add a dated run log as a new section at the bottom of this page (a real call against one of the 8 compatible tools, and against whoami) rather than a separate doc — this project’s manual-verification convention moved from a standalone docs/verification.md running file to dated sections appended directly to the relevant Hugo page.