Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
6147265
docs(requirements): creating an MCP key requires a signed-in session
AndriiPasternak31 Sep 28, 2026
d687979
feat(auth): human-only principal primitives — assert_person, require_…
AndriiPasternak31 Sep 28, 2026
5a9c7eb
fix(mcp-keys): creating a key requires a signed-in session
AndriiPasternak31 Sep 28, 2026
f800b80
docs(mcp-keys): key creation flow, index row, learnings fragment, tes…
AndriiPasternak31 Sep 28, 2026
5773df4
style(auth): PEP 8 spacing around the human-only primitives block
AndriiPasternak31 Sep 28, 2026
890f830
docs(auth): human-only grant surfaces — the two principal rules and t…
AndriiPasternak31 Sep 28, 2026
89fc6e2
fix(autonomy): only a person can change an agent's autonomy (#2996)
AndriiPasternak31 Sep 28, 2026
f6bec5b
fix(agent-config): the other agent-config writes are person-only too …
AndriiPasternak31 Sep 28, 2026
bdb3b59
fix(users): the sign-in email and personal GitHub PAT change only in …
AndriiPasternak31 Sep 28, 2026
6dbcd14
test(auth): route census — every HTTP route chooses a policy class (#…
AndriiPasternak31 Sep 28, 2026
0f2caf9
test(schedules): an agent's own key still manages its schedules (#2996)
AndriiPasternak31 Sep 28, 2026
4ee74d6
test(registry): register the #2996 agent-config and self-service test…
AndriiPasternak31 Sep 28, 2026
de3a284
docs(flows): human-only grant surfaces in the affected flow docs (#2996)
AndriiPasternak31 Sep 28, 2026
925c5c9
test(agent-config): patch the container lookup on the modules the rou…
AndriiPasternak31 Sep 28, 2026
2ab1f90
docs(auth): describe the human-only rule, not the before-state (#2996)
AndriiPasternak31 Sep 28, 2026
25d0e69
docs(auth): point the census remainder at its tracking issue (#2996)
AndriiPasternak31 Sep 28, 2026
1b51710
test(auth): the runtime census imports main from its backend argument…
AndriiPasternak31 Sep 28, 2026
df3c7ac
fix(mcp-keys): listing keys requires a signed-in session
AndriiPasternak31 Sep 28, 2026
fe1205f
docs(mcp-keys): listing keys is signed-in-session only
AndriiPasternak31 Sep 28, 2026
68a879c
test(mcp-keys): the route rule refuses a connector principal itself
AndriiPasternak31 Sep 28, 2026
d7cd0aa
fix(mcp-keys): listing keys takes the same session rule as creating one
AndriiPasternak31 Sep 28, 2026
9abf7ea
Merge remote-tracking branch 'origin/dev' into AndriiPasternak31/issu…
AndriiPasternak31 Sep 29, 2026
9dfb3d3
test(auth): classify the skill-set and raise_my_ask routes in the cen…
AndriiPasternak31 Sep 29, 2026
b0e06ea
fix(schedules): say who can raise the agent timeout cap (#2996)
AndriiPasternak31 Sep 29, 2026
0e6ebbd
Merge remote-tracking branch 'origin/AndriiPasternak31/issue-2996' in…
AndriiPasternak31 Sep 29, 2026
7d73ac5
Merge remote-tracking branch 'origin/dev' into AndriiPasternak31/ent7…
AndriiPasternak31 Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/memory/feature-flows.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@
| Admin Login | [admin-login.md](feature-flows/admin-login.md) | Password-based admin auth |
| First-Time Setup | [first-time-setup.md](feature-flows/first-time-setup.md) | Admin password wizard |
| First-Run Overlay | [onboarding-wizard.md](feature-flows/onboarding-wizard.md) | The blocking post-login setup sequence — step registry, derived completion, re-run (ent#581; formerly the ent#52 wizard) |
| MCP API Keys | [mcp-api-keys.md](feature-flows/mcp-api-keys.md) | API key management — creating a key (`POST /keys`, `ensure-default`) requires a signed-in session; every creation is audited as `key_create` |
| MCP API Keys | [mcp-api-keys.md](feature-flows/mcp-api-keys.md) | API key management — creating (`POST /keys`, `ensure-default`) and listing keys require a signed-in session; every creation is audited as `key_create` |
| Execution Origin Tracking | [AUDIT-001-execution-origin-tracking.md](feature-flows/AUDIT-001-execution-origin-tracking.md) | Track who triggered executions |
| Agent-Server Authentication | [agent-server-authentication.md](feature-flows/agent-server-authentication.md) | Per-agent inbound auth for the in-container agent server (`:8000`) — derived `X-Trinity-Agent-Token` (HMAC over `AGENT_AUTH_SECRET`) enforced by a pure-ASGI middleware on every HTTP/WS route (#1159) |
| 4-Tier Role Model | [role-model.md](feature-flows/role-model.md) | user < operator < creator < admin role hierarchy (ROLE-001) |
Expand Down
15 changes: 11 additions & 4 deletions docs/memory/feature-flows/mcp-api-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ app.include_router(mcp_keys_router)
| Method | Endpoint | Handler | Line | Description |
|--------|----------|---------|------|-------------|
| `POST` | `/api/mcp/keys` | `create_mcp_api_key_endpoint()` | `mcp_keys.py:25` | Create new API key — **signed-in session only** (`Depends(require_interactive)`, every scope) |
| `GET` | `/api/mcp/keys` | `list_mcp_api_keys_endpoint()` | `mcp_keys.py:37-51` | List user's keys (admin sees all) |
| `GET` | `/api/mcp/keys` | `list_mcp_api_keys_endpoint()` | `mcp_keys.py:97` | List user's keys (admin sees all) — **signed-in session only** (`Depends(require_interactive)`) |
| `POST` | `/api/mcp/keys/ensure-default` | `ensure_default_mcp_api_key()` | `mcp_keys.py:114` | Auto-create default key — **signed-in session only**; audited as `key_create` |
| `GET` | `/api/mcp/keys/{key_id}` | `get_mcp_api_key_endpoint()` | `mcp_keys.py:99-111` | Get single key details |
| `POST` | `/api/mcp/keys/{key_id}/revoke` | `revoke_mcp_api_key_endpoint()` | `mcp_keys.py:114-126` | Revoke (deactivate) key |
Expand Down Expand Up @@ -275,12 +275,19 @@ strict as the principal it produces. The UI (`McpKeysTab.vue`) and the CLI
(`trinity login`) both create keys on the login JWT.

#### List API Keys (GET /api/mcp/keys)

Signed-in session only (trinity-enterprise#712): `Depends(require_interactive)`,
the same rule as creating a key, so every MCP key — the person's own `user` key
included — gets a 403. The inventory has no machine consumer; the Settings → MCP
Keys tab calls it with the JWT. A signed-in admin still gets every key with its
owner's `user_email` (the tab renders it per row); nothing returns the secret.

```python
# mcp_keys.py:37-51
# mcp_keys.py:97
@router.get("/keys", response_model=List[McpApiKey])
async def list_mcp_api_keys_endpoint(
request: Request,
current_user: User = Depends(get_current_user)
current_user: User = Depends(require_interactive) # JWT session only
):
if current_user.role == "admin":
keys = db.list_all_mcp_api_keys() # Admin sees all
Expand Down Expand Up @@ -677,7 +684,7 @@ class User(BaseModel):
curl -X POST http://localhost:8000/api/mcp/validate \
-H "Authorization: Bearer trinity_mcp_YOUR_KEY_HERE"

# List keys (requires JWT)
# List keys (requires a JWT; an MCP key gets 403)
curl http://localhost:8000/api/mcp/keys \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
```
Expand Down
1 change: 1 addition & 0 deletions docs/memory/requirements/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
| `PUT /api/users/me/email` (ent#711) | INTERACTIVE |
| `PUT` / `DELETE /api/users/me/github-pat` (a credential future agent creations inherit, ent#162) | INTERACTIVE |
| `POST /api/mcp/keys` (every scope), `POST /api/mcp/keys/ensure-default` | INTERACTIVE (security.md §20.10) |
| `GET /api/mcp/keys` (trinity-enterprise#712 — the key inventory; no machine consumer) | INTERACTIVE |

- **Refusal**: 403. PERSON routes return `HUMAN_ONLY_DETAIL` (`{"code": "person_required", ...}`, the same machine code as ent#611's ask endings, whose own detail is unchanged); INTERACTIVE routes return `reject_non_interactive_principal`'s existing detail. The refusal depends only on the principal, never on whether the addressed agent exists, so answering 403 before a path's 404 is not an enumeration oracle (#186).
- **Still working**: the owner's `user` key and ops tooling on a `user` key keep the PERSON routes; the UI and CLI use JWTs everywhere; heartbeat, the result callback, reports and notifications do not use these gates; `trinity-system` keeps `require_admin` (#2323) and has no caller of the routes above.
Expand Down
8 changes: 6 additions & 2 deletions src/backend/routers/mcp_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,13 @@ async def create_mcp_api_key_endpoint(
@router.get("/keys", response_model=List[McpApiKey])
async def list_mcp_api_keys_endpoint(
request: Request,
current_user: User = Depends(get_current_user)
current_user: User = Depends(require_interactive)
):
"""List all MCP API keys for the current user."""
"""List MCP API keys: the caller's own, or every key for an admin.

Signed-in session only, like creating one: the inventory has no machine
consumer, so every MCP key (the person's own `user` key included) gets a 403.
"""
try:
if current_user.role == "admin":
keys = db.list_all_mcp_api_keys()
Expand Down
12 changes: 12 additions & 0 deletions tests/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,18 @@
],
"description": "Creating an MCP key requires a signed-in session: POST /api/mcp/keys (every scope) and POST /api/mcp/keys/ensure-default through the REAL get_current_user with seeded agent/system/connector/user key rows -> 403 and mcp_api_keys row count unchanged, no key_create audit row; JWT -> 200 with the secret and one key_create audit row (ensure-default included, target = new key id); admin JWT still creates ops/portal_delegate; non-admin JWT portal_delegate still 403; a user-scoped key asking an admin scope 403; existing user key -> ensure-default returns null, creates and audits nothing; the created key validates as its owner."
},
{
"file": "unit/test_mcp_key_listing_requires_session.py",
"feature": "ent#712",
"added": "2026-09-28",
"categories": [
"backend",
"auth",
"security",
"mcp-keys"
],
"description": "GET /api/mcp/keys requires a signed-in session (INTERACTIVE, Depends(require_interactive)): agent/system/connector/user keys through the REAL get_current_user with seeded key rows, and connector/loopback-JWT/ops/portal_delegate/unknown-future-scope/missing-mcp_scope via an explicit principal dataclass -> 403. Signed-in admin -> every key with user_email (never the secret); signed-in user -> own keys only. Every route in routers/mcp_keys.py read off the router object and listed with its rule; a single-key read stays owner-scoped; /validate needs no session."
},
{
"file": "unit/test_ent600_telegram_group_context.py",
"feature": "ent#600",
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/_route_census.py
Original file line number Diff line number Diff line change
Expand Up @@ -638,7 +638,7 @@ def _resolve_endpoint(

# The exact size of the frozen baseline. Lower it in the same change that
# removes an entry; it never goes up.
FROZEN_BASELINE_COUNT = 361
FROZEN_BASELINE_COUNT = 360


def load_baseline(path: Path = BASELINE_PATH) -> Dict[str, str]:
Expand Down
1 change: 0 additions & 1 deletion tests/unit/fixtures/human_only_route_baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,6 @@
"routers/loops.py::stop_loop": "POST /api/loops/{loop_id}/stop",
"routers/mcp_keys.py::delete_mcp_api_key_endpoint": "DELETE /api/mcp/keys/{key_id}",
"routers/mcp_keys.py::get_mcp_api_key_endpoint": "GET /api/mcp/keys/{key_id}",
"routers/mcp_keys.py::list_mcp_api_keys_endpoint": "GET /api/mcp/keys",
"routers/mcp_keys.py::revoke_mcp_api_key_endpoint": "POST /api/mcp/keys/{key_id}/revoke",
"routers/messages.py::get_proactive_shares": "GET /api/agents/{agent_name}/shares/proactive",
"routers/messages.py::send_proactive_message": "POST /api/agents/{agent_name}/messages",
Expand Down
1 change: 1 addition & 0 deletions tests/unit/test_2996_human_only_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ class TestTheRoutesThisChangeGates:
"routers/users.py::clear_my_github_pat",
"routers/mcp_keys.py::create_mcp_api_key_endpoint",
"routers/mcp_keys.py::ensure_default_mcp_api_key",
"routers/mcp_keys.py::list_mcp_api_keys_endpoint",
]

@pytest.mark.parametrize("key", PERSON)
Expand Down
237 changes: 237 additions & 0 deletions tests/unit/test_mcp_key_listing_requires_session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
"""Listing MCP keys requires a signed-in session.

`GET /api/mcp/keys` is the key inventory the Settings → MCP Keys tab renders.
It has no machine consumer (the CLI and the MCP server never call it with a
key), so it takes the INTERACTIVE rule, `Depends(require_interactive)` like its
POST siblings — a JWT session only. Every MCP key, the person's
own `user` key included, gets a 403. A signed-in admin still sees the full
listing with each key's `user_email` (the tab renders it on every row); a
signed-in non-admin sees only their own keys.

Key-authenticated callers go through the REAL `get_current_user` with a key row
seeded in the real schema (`tests/db_harness.py`). Kinds the entry point fences
earlier, or that cannot be seeded as a row, go through a dependency override
with a stated principal — every `models.User` field set, `mcp_scope` included,
never a `MagicMock` — so the route's own gate is what refuses them.

Related: docs/memory/feature-flows/mcp-api-keys.md.
"""

from __future__ import annotations

import os
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Optional

import pytest

os.environ.setdefault("REDIS_URL", "redis://u:p@localhost:6379")
os.environ.setdefault("SECRET_KEY", "test-secret")

_BACKEND = Path(__file__).resolve().parents[2] / "src" / "backend"
if str(_BACKEND) not in sys.path:
sys.path.insert(0, str(_BACKEND))

from fastapi import FastAPI # noqa: E402
from fastapi.routing import APIRoute # noqa: E402
from fastapi.testclient import TestClient # noqa: E402

from db_harness import db_backend, run as _hrun # noqa: E402,F401

import dependencies # noqa: E402
import routers.mcp_keys as _KEYS # noqa: E402
from database import db # noqa: E402
from db_models import McpApiKeyCreate, UserCreate # noqa: E402

pytestmark = pytest.mark.unit

ADMIN = "keylist-admin"
PLAIN = "keylist-plain"
AGENT = "keylist-agent"
SYSTEM = "keylist-system"
ADMIN_EMAIL = "keylist-admin@example.com"
PLAIN_EMAIL = "keylist-plain@example.com"

LIST = "/api/mcp/keys"


@dataclass
class _Principal:
"""Every field `models.User` carries, stated — never a MagicMock."""

id: int
username: str
email: Optional[str]
role: str
agent_name: Optional[str] = None
connector_agent: Optional[str] = None
portal_delegate: bool = False
mcp_scope: Optional[str] = None
mcp_key_id: Optional[str] = None
mcp_key_name: Optional[str] = None
vouched_source_agent: Optional[str] = None


@dataclass
class _PrincipalWithoutScope:
"""Deliberately lacks `mcp_scope`: a stand-in that does not match the real
principal must fail closed, never read as the JWT value."""

id: int
username: str
email: Optional[str]
role: str
agent_name: Optional[str] = None
connector_agent: Optional[str] = None
portal_delegate: bool = False
vouched_source_agent: Optional[str] = None


@pytest.fixture
def world(db_backend, monkeypatch):
# JWT revocation is a Redis read; no Redis here.
monkeypatch.setattr(dependencies, "get_breaker_redis", lambda: None)
db.create_user(UserCreate(username=ADMIN, role="admin", email=ADMIN_EMAIL))
db.create_user(UserCreate(username=PLAIN, role="user", email=PLAIN_EMAIL))
db.register_agent_owner(AGENT, ADMIN)
# One key per account, so "full listing" and "own only" are distinguishable.
db.create_mcp_api_key(ADMIN, McpApiKeyCreate(name="admin laptop"))
db.create_mcp_api_key(PLAIN, McpApiKeyCreate(name="plain laptop"))
app = FastAPI()
app.include_router(_KEYS.router)
yield app, TestClient(app)
app.dependency_overrides.clear()


def _auth(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}


def _jwt(username: str) -> dict:
return _auth(dependencies.create_access_token({"sub": username}))


def _agent_key() -> dict:
return _auth(db.create_agent_mcp_api_key(AGENT, ADMIN).api_key)


def _rescoped_key(scope: str, agent_name: str) -> dict:
"""A key row carrying `scope`, created the way the platform creates the
system and connector keys: an agent key, then its scope column set."""
key = db.create_agent_mcp_api_key(agent_name, ADMIN)
_hrun("UPDATE mcp_api_keys SET scope = :s WHERE id = :i", s=scope, i=key.id)
return _auth(key.api_key)


def _admin_user_key() -> dict:
return _auth(
db.create_mcp_api_key(ADMIN, McpApiKeyCreate(name="admin own key")).api_key
)


REAL_KEY_KINDS = {
"agent-key": _agent_key,
"system-key": lambda: _rescoped_key("system", SYSTEM),
"connector-key": lambda: _rescoped_key("connector", AGENT),
"user-key": _admin_user_key,
}


def _admin_principal(**fields):
base = {"id": 1, "username": ADMIN, "email": ADMIN_EMAIL, "role": "admin"}
base.update(fields)
return _Principal(**base)


OVERRIDE_KINDS = {
# The real connector key is refused earlier, at the entry point; this one
# reaches the route, so the route's own rule is what refuses it.
"connector-principal": lambda: _admin_principal(
mcp_scope="connector", connector_agent=AGENT
),
# A JWT that the event loopback vouched for an agent is not a person's session.
"loopback-jwt": lambda: _admin_principal(mcp_scope=None, vouched_source_agent=AGENT),
"ops-key": lambda: _admin_principal(mcp_scope="ops"),
"portal-delegate-key": lambda: _admin_principal(
mcp_scope="portal_delegate", portal_delegate=True
),
"unknown-future-scope": lambda: _admin_principal(mcp_scope="a-scope-shipped-tomorrow"),
"missing-mcp-scope": lambda: _PrincipalWithoutScope(
id=1, username=ADMIN, email=ADMIN_EMAIL, role="admin"
),
}


class TestKeyListingRequiresSession:
@pytest.mark.parametrize("kind", sorted(REAL_KEY_KINDS))
def test_key_listing_requires_session(self, world, kind):
_, client = world
res = client.get(LIST, headers=REAL_KEY_KINDS[kind]())
assert res.status_code == 403, res.text

@pytest.mark.parametrize("kind", sorted(OVERRIDE_KINDS))
def test_refused_by_the_route_gate(self, world, kind):
app, client = world
principal = OVERRIDE_KINDS[kind]()
app.dependency_overrides[dependencies.get_current_user] = lambda: principal
res = client.get(LIST)
assert res.status_code == 403, res.text


class TestSessionListingUnchanged:
def test_a_signed_in_admin_sees_every_key_with_its_owner_email(self, world):
_, client = world
res = client.get(LIST, headers=_jwt(ADMIN))
assert res.status_code == 200, res.text
rows = {r["name"]: r for r in res.json()}
assert {"admin laptop", "plain laptop"} <= set(rows)
assert rows["plain laptop"]["user_email"] == PLAIN_EMAIL
assert "api_key" not in rows["plain laptop"] # never the secret

def test_a_signed_in_user_sees_only_their_own_keys(self, world):
_, client = world
res = client.get(LIST, headers=_jwt(PLAIN))
assert res.status_code == 200, res.text
assert [r["name"] for r in res.json()] == ["plain laptop"]


# Every route in routers/mcp_keys.py, read off the router object. A route added
# to the file fails here until it is listed with the rule it follows and a test
# that pins that rule.
MCP_KEY_ROUTES = {
("POST", "/api/mcp/keys"): "interactive — test_mcp_key_creation_requires_session.py",
("GET", "/api/mcp/keys"): "interactive — this file",
("POST", "/api/mcp/keys/ensure-default"): "interactive — test_mcp_key_creation_requires_session.py",
("GET", "/api/mcp/keys/{key_id}"): "owner-scoped read — this file",
("POST", "/api/mcp/keys/{key_id}/revoke"): "agent + connector refused — test_1854_agent_mcp_key.py",
("DELETE", "/api/mcp/keys/{key_id}"): "agent + connector refused — test_1854_agent_mcp_key.py",
("POST", "/api/mcp/validate"): "unauthenticated by design: validates the presented key itself",
}


def test_every_route_in_the_file_is_listed_with_its_rule():
live = {
(method, route.path)
for route in _KEYS.router.routes
if isinstance(route, APIRoute)
for method in route.methods
}
assert live == set(MCP_KEY_ROUTES), (
"routers/mcp_keys.py routes changed — list each with its rule: "
f"added {sorted(live - set(MCP_KEY_ROUTES))}, removed {sorted(set(MCP_KEY_ROUTES) - live)}"
)


def test_a_single_key_read_stays_owner_scoped(world):
_, client = world
other = db.create_mcp_api_key(ADMIN, McpApiKeyCreate(name="admin second"))
res = client.get(f"{LIST}/{other.id}", headers=_jwt(PLAIN))
assert res.status_code == 404, res.text


def test_validate_needs_no_session(world):
_, client = world
res = client.post("/api/mcp/validate")
assert res.status_code == 401 # the missing key, not a missing session
Loading