Skip to content

fix(mcp-keys): creating an MCP key requires a signed-in session - #3043

Merged
vybe merged 5 commits into
devfrom
AndriiPasternak31/mcp-key-mint-session
Sep 29, 2026
Merged

vybe merged 5 commits into
devfrom
AndriiPasternak31/mcp-key-mint-session

Conversation

@AndriiPasternak31

Copy link
Copy Markdown
Contributor

Refs trinity-enterprise#718

Summary

Creating an MCP key now requires a signed-in (JWT) session. POST /api/mcp/keys (every scope) and POST /api/mcp/keys/ensure-default take Depends(require_interactive): every MCP key gets a 403, the person's own user key included. A credential creator has to be at least as strict as the credential it produces. ensure-default now writes the same key_create audit row as POST /api/mcp/keys.

The PR also adds the human-only primitives the next PR builds on, in dependencies.py: assert_person / require_person (the PERSON rule: a JWT or the person's own user key) and require_interactive (JWT only). Both are allowlists over mcp_scope and fail closed on a principal without one. Nothing existing changes: is_person_principal, reject_non_interactive_principal and the ent#611 detail stay byte-identical.

Merge order: this PR first (M → A → B)

This is PR M of three. PR A (#2996) is stacked on it: it gives a person's own user key the new PERSON rule, which is only sound once keys can't create a user key. PR B (ent#712) follows A. See the PR comment for why these are three PRs.

Journey Impact: none: key creation from the UI and trinity login both use a JWT and are unchanged

Type of Change

  • Bug fix

Testing

  • tests/unit/test_mcp_key_creation_requires_session.py and tests/unit/test_human_only_principals.py, through the real get_current_user with seeded key rows.
  • Mutation: with the create gate removed, 6/243 tests go red; ensure-default's gate, 5/243; the audit row, 1/243; the missing-scope sentinel read as None, 1–2/243.
  • /review and /cso --diff both ran.
  • Callers checked: McpKeysTab.vue and the CLI (trinity login → ensure-default) use a JWT. Nothing in src/mcp-server, config/ or the system agent calls either route with a key.

After merge

trinity-enterprise#718 is cross-repo, so close it by hand at the release cut.

🤖 Generated with Claude Code

Records the rule in security.md §20.10 ahead of the change: POST
/api/mcp/keys and POST /api/mcp/keys/ensure-default take a signed-in
(JWT) session, and ensure-default writes the same key_create audit row
as the create route.
…person, require_interactive

Two allowlist rules over mcp_scope, both fail-closed on a principal
without one: PERSON (a JWT session or the person's own user-scoped key,
via is_person_principal) and INTERACTIVE (a JWT session only). The
Depends forms make a route's rule a one-line signature change an AST
guard can see. Both also refuse a principal carrying
vouched_source_agent. The ent#611 ask-endings detail and predicate are
unchanged, and pinned so.
POST /api/mcp/keys (every scope) and POST /api/mcp/keys/ensure-default
now take Depends(require_interactive): a credential minter is at least
as strict as the principal it produces. The existing per-scope checks
stay as they were.

ensure-default also writes the same key_create audit row as the create
route, so every created key is attributable.

Tests go through the real get_current_user with seeded key rows and
assert the mcp_api_keys row count is unchanged on every refusal.
…t registry

mcp-api-keys.md: POST /keys and ensure-default are signed-in-session
only; ensure-default is audited as key_create. Adds the feature-flows
index row, a learnings fragment and the two new test files to
tests/registry.json.
@AndriiPasternak31

Copy link
Copy Markdown
Contributor Author

Why three PRs, and the merge order: #3043 → #3044 → #3045

Why not one PR: #3045 is blocked on the operator step with no date, and #3043/#3044 are P1 fixes that #2984's autonomy "hard off" depends on. Squash-merge each; don't merge a later one first.

@dolho dolho left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving. The auth boundary is correct and nothing here blocks.

Verification. I ran the new suites plus the related auth suites (#163, #2323, #293, #1310) in a worktree: 165 passed.

What I checked

  • require_interactive → reject_non_interactive_principal lets only a JWT through (mcp_scope is None), so a key of any scope is refused, including scopes added later. It also refuses the loopback JWT (vouched_source_agent). This is what makes PR A's PERSON rule sound: a user key can no longer mint another user key.
  • Legitimate callers all use a signed-in session:
    • McpKeysTab.vue, for create and for ensure-default on mount.
    • CLI trinity init / login, which pass the fresh JWT in both the email and --admin paths.
    • The live tests, which use the admin JWT.
    • Nothing in src/mcp-server, docker/, scripts/ or config/ calls either route.
  • The new ensure-default audit row can't turn a key that was created into a 500, because platform_audit_service.log swallows its own errors.
  • The tests go through the real get_current_user with seeded key rows, and each refusal also checks that the row count didn't change. Nice.

Findings (all minor or nits)

  1. Minor: docs/user-docs/api-reference/authentication.md:103,107. The scope table doesn't say that creating a key now requires a signed-in session. A script that mints keys with a user key will now get a 403, so this needs a line in the docs and the release notes.
  2. Minor: routers/mcp_keys.py, revoke/delete routes. These stay on get_current_user, so a user key can still revoke or delete its owner's keys. That's a denial of service, not escalation, and it's out of scope here. Worth a follow-up to decide whether revoke should also require a session.
  3. Nit: dependencies.py:1187-1206. assert_person / require_person have no callers yet. That's expected for the M→A→B stack, but please land A soon.
  4. Nit: routers/mcp_keys.py:~55. The reject_non_interactive_principal in the ops branch is now redundant with the route-level gate. Either drop it or add a comment saying it's kept as a second layer.
  5. Nit: routers/mcp_keys.py:154-169. The ensure-default audit block duplicates the one in create. A small _audit_key_create() helper would stop the two drifting apart.
  6. Nit: routers/agent_mcp_key.py:72,88,108. These routes use plain reject_non_interactive_principal, which lacks the loopback refusal. It's harmless, because the loopback JWT is blocked where it enters, but it leaves two spellings of the "interactive" rule. Consider moving them to require_interactive later.

vybe pushed a commit that referenced this pull request Sep 29, 2026

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

merge-train: batch validated on train/20260929-0815 (#3070, all checks green).

@vybe
vybe merged commit ff4e9de into dev Sep 29, 2026
25 checks passed
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.

3 participants