External MCP Service-Key Policy

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):

  1. PATCH /api/v1/documents/{documentId} (updateDocument) is BonsaiUserInEntity → JWT-only, despite sibling documents routes accepting API keys.
  2. GET /api/v1/healthcheck (no auth at all) and GET /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.tslogging/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 createApiKey endpoint by BonsAI ops/engineering, not by an end-user org’s own admin.
  • Named per purpose. The name field passed to createApiKey identifies 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-mcp directly; it is not something a customer or their integration ever holds, unlike the org-facing API keys customers generate for their own automations via createApiKey/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_documents and download_document_file) succeed with a service key — verified at unit level: the policy gate lets the call through and custom-fetch.ts forwards the raw key on Tofu-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.