Skip to content

fix(api-keys): let members create MCP keys, and surface why creation failed#242

Merged
Makisuo merged 1 commit into
mainfrom
fix/mcp-key-creation-for-members
Jul 21, 2026
Merged

fix(api-keys): let members create MCP keys, and surface why creation failed#242
Makisuo merged 1 commit into
mainfrom
fix/mcp-key-creation-for-members

Conversation

@Makisuo

@Makisuo Makisuo commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

What prompted this

A user reported they couldn't create an API key for MCP from the MCP view. They got a bare red "Failed to create API key" toast — no reason, no next step. They are not an org admin.

Two causes, both fixed here:

  1. POST /v2/api_keys required an org-admin role for every key kind — requireAdmin in apps/api/src/routes/v2/api-keys.http.ts.
  2. The create dialog collapsed the resulting 403 into a generic string, so the server's actual message ("Only org admins can create API keys") never reached the user.

Why the gate existed — and how it's closed properly

A key with no role metadata resolves as root (resolveByBearer returns roles: null, and both mcp/lib/resolve-tenant.ts and ApiAuthorizationV2Layer default that to root). Letting a member mint a key would have handed them a root credential.

So rather than widening that hole, MCP keys now pin the creator's own roles into metadataJson, reusing the exact mechanism CLI device-auth keys already use. The key can never carry more authority than the person who created it.

Blast radius is bounded on top of that: ApiAuthorizationV2Layer already rejects mcp-kind keys for /v2 entirely, so a member-minted key reaches only MCP tools — where tenant.roles gates the alert-rule mutations.

Changes

API

  • create: skips requireAdmin for kind: "mcp" and persists { source: "maple_mcp", roles: <creator roles> }. Standard keys stay admin-only.
  • revoke: a member may revoke an MCP key they created themselves. Minting a credential you can't kill is a hole. Everything else stays admin-only; roll is unchanged.
  • readKeyRoleMetadata decodes either metadata source and fails closed — metadata declaring a known role-bearing source that doesn't decode rejects the key instead of falling back to the root default.

Security fix found along the way

  • roll() dropped metadataJson, silently escalating a rolled CLI/MCP key from its creator's roles to root. Pre-existing, unrelated to MCP, fixed here with a regression test.

Frontend

  • Key create/roll/revoke toasts go through the existing formatBackendError, so a 403 now reads "Not authorized — Only org admins can create API keys".
  • New useIsOrgAdmin() hook, extracted from the inline logic in settings-nav.tsx so there's one definition. The standard-key "Create key" button is disabled for members with copy pointing at the MCP page.
  • The MCP view keeps a "Create another" button instead of hiding it after the first key.
  • apps/web/src/routes/mcp.tsx was an orphan copy of the settings MCP section that had already drifted (missing "type": "http" in the generated config, linked to /developer). It now renders the shared <McpSection />.

Verification

  • vitest — 23 pass across api-keys.http.test.ts and ApiKeysService.scopes.test.ts, including new cases: member creates MCP key (and it resolves with the member's roles, not root), member still refused a standard key, member revokes own key but not a foreign one, roll preserves pinned roles, malformed maple_mcp metadata fails closed.
  • @maple/api and @maple/web typecheck clean.
  • End-to-end in the browser: created a key from /mcp as the dev Clerk admin — the DB row carries {"roles": ["org:admin"], "source": "maple_mcp"}, and the key authenticates against the MCP server (initialize returns server capabilities).

Reviewer note

ApiKeysService.ts is committed whole, so this PR carries the per-key roles metadata mechanism (CliApiKeyMetadata, ResolvedApiKey.roles, cliManaged) that the in-progress CLI device-auth work introduced in that file — the MCP fix builds directly on it and can't be separated at file granularity. The rest of the CLI device-auth feature (service, routes, CLI, migration 0017) is not included and remains uncommitted locally.

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Open in Devin Review

…failed

A user reported they couldn't create an API key for MCP from the MCP view:
they got a bare "Failed to create API key" toast with no reason. They were
not an org admin.

Two causes. POST /v2/api_keys required an org-admin role for *every* key
kind, and the create dialog collapsed the resulting 403 into a generic
toast, so the actual message ("Only org admins can create API keys") never
reached the user.

The admin gate existed for a real reason: a key with no role metadata
resolves as `root`, so letting a member mint one would hand them a root
credential. Rather than widen that hole, MCP keys now pin the creator's own
roles into metadataJson, reusing the mechanism CLI device-auth keys already
use — the key can never carry more authority than the user who created it.

- v2 create: skip requireAdmin for kind="mcp" and persist
  { source: "maple_mcp", roles: <creator roles> }. Standard keys stay
  admin-only.
- v2 revoke: a member may revoke an MCP key they created themselves —
  minting a credential you can't kill is a hole.
- readKeyRoleMetadata decodes either metadata source and fails closed:
  unreadable role metadata rejects the key instead of falling back to root.
- Fix roll() dropping metadataJson, which silently escalated a rolled
  CLI/MCP key from its creator's roles to root.

Frontend: key create/roll/revoke toasts now go through formatBackendError,
so a 403 reads "Not authorized — Only org admins can create API keys". The
standard-key button is disabled for members with copy pointing at the MCP
page, and the MCP view keeps a "Create another" button instead of hiding it
after the first key. The orphan /mcp route (a drifted copy of the settings
MCP section) now renders the shared <McpSection />.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@pullfrog

pullfrog Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Your Pullfrog Router balance is exhausted.

You have a card on file but auto-reload is disabled, so runs paused once your balance went past the overdraft buffer.

Top up balance → · Enable auto-reload →

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | Rerun failed job ➔View workflow run | via Pullfrog | Using Claude Opus𝕏

@Makisuo
Makisuo merged commit 2d255c8 into main Jul 21, 2026
5 of 8 checks passed
@Makisuo
Makisuo deleted the fix/mcp-key-creation-for-members branch July 21, 2026 12:44

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

Open in Devin Review

Comment on lines +356 to +360
const resolved = await harness.resolveKey(created.body.secret)
expect(Option.isSome(resolved)).toBe(true)
if (Option.isSome(resolved)) {
expect(resolved.value.roles).toEqual(["org:member"])
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 New permission tests fail because API-key callers are always treated as full admins

The new tests assert that a member's key keeps member-level access (resolveKey expecting ["org:member"] at apps/api/src/routes/v2/api-keys.http.test.ts:356-360), but every request authenticated with an API key is stamped with the top-level admin role (roles: apiKeyDefaultRoles at apps/api/src/services/ApiAuthorizationV2Layer.ts:120), so the member's key is treated as an administrator and these checks do not hold.
Impact: The pull request does not pass its own newly added tests, so the change cannot merge cleanly and the member-vs-admin distinction it claims to test does not actually exist for key-authenticated callers.

Why the assertions can't hold at this revision

The member's credential is a standard key whose stored metadata pins ["org:member"], but ApiAuthorizationV2Layer ignores the resolved key's roles and sets tenant.roles to apiKeyDefaultRoles (["root"]) for all API-key auth. Consequently:

  • api-keys.http.test.ts:344-361 creates an MCP key whose pinned roles come from [...tenant.roles] (apps/api/src/routes/v2/api-keys.http.ts:130), i.e. ["root"], so the resolved roles are ["root"], not ["org:member"].
  • api-keys.http.test.ts:364-379 expects a member to get a 403 creating a standard key, but requireAdmin(["root"]) succeeds, returning 200.
  • api-keys.http.test.ts:381-406 expects a 403 revoking a foreign key, but requireAdmin(["root"]) succeeds, returning 200.

The role-propagation wiring that would make tenant.roles reflect the key's pinned roles lives in the CLI device-auth work that the PR description says is "not included and remains uncommitted locally," so the tests only pass in a working tree that has those uncommitted changes.

Prompt for agents
The new tests in apps/api/src/routes/v2/api-keys.http.test.ts assume that when a request is authenticated with an API key, the resulting tenant's roles reflect the roles pinned in the key's metadata (ResolvedApiKey.roles from ApiKeysService.resolveByKey). However, ApiAuthorizationV2Layer.ts (around line 117-123) hardcodes tenant roles to apiKeyDefaultRoles = ['root'] and never reads resolved.roles. As a result the member key is treated as root: the created MCP key pins ['root'] instead of ['org:member'], the standard-key creation is allowed instead of 403, and the foreign-key revoke is allowed instead of 403. Wire resolved.roles into the tenant construction in ApiAuthorizationV2Layer (use resolved.roles when present, falling back to apiKeyDefaultRoles) so tenant.roles reflects the key's pinned roles. This is the same wiring the PR notes is uncommitted; it must be committed for the feature and its tests to work.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant