Skip to content

Commit dc4f490

Browse files
authored
feat(auth): portal_delegate key scope + single-route fence (ent#163) (#1869)
* feat(auth): portal_delegate key scope + single-route fence (ent#163) The OSS half of delegated portal identity: the scope, its containment, and the admin-only mint. The endpoint that decides *whether* an email may be delegated stays in the entitled module — core owns the enforcement primitive only, the same split as `PORTAL_SESSION_SCOPE` (#78) and `users.suspended_at` (#995). Why a new scope rather than reusing `scope='user'`: the capability lets its holder act as another person and read their conversations. Riding it on an ordinary user key would silently turn EVERY user key into a fleet-wide impersonation key. It is admin-issued, listed and revocable like any other key, and revoking it stops delegation on the next request. The fence is the load-bearing part. A delegate key resolves to the KEY OWNER, exactly like every other MCP key, so unfenced it would simply BE an admin's credential handed to a third party. `get_current_user` confines it to one (method, path) — the exchange route — at the single auth entry point, mirroring the connector fence (ent#46): centrally, not in the portal router, because the many endpoints doing inline access checks resolve this principal to the owner and would otherwise treat it as that human. Deliberately ONE route, not a prefix: the minted portal session — not this key — drives the portal surface afterwards, so the key never needs breadth, and a prefix would silently grant every portal endpoint added in future. Minting is admin AND human-only. `assert_admin` alone is insufficient: it rejects connector principals but not agent-scoped ones, and an agent key resolves to its owner carrying the owner's role, which on a default admin-owned install passes a bare role check (trinity-ops-agent#232). The db layer independently refuses any scope outside `{user, portal_delegate}` so agent/connector/system keys — which carry an agent binding and are minted by their own paths — can never be forged through this endpoint. Incidental fix in the same function: `create_mcp_api_key` now sets `is_active=1` explicitly instead of relying on a column default that exists in only one of the two schema sources — `schema.py` declares `is_active INTEGER DEFAULT 1` but `db/tables.py` (the Core/Alembic source) declares a bare `Column(...)`. A table built from the metadata yields NULL, and `validate_mcp_api_key` treats falsy `is_active` as revoked, so every minted key would be born invalid. Harmless on both live schema paths; found because it broke a test built from the metadata. Tests: `test_163_portal_delegate_scope.py` — structural (the fence is one route, the model defaults to False, the db creatable-set excludes agent scopes) plus behavioural, driving the real `get_current_user`: a delegate key is accepted on the exchange route and 403s on the fleet, the user list, key minting, the portal surface itself, and the right path with the wrong method. Verified the behavioural half fails (5 tests) with the fence disabled. 103 passed across the auth-surface suites; 692 passed over the wider key/auth/portal selection. Enterprise-docs guard clean. Related to trinity-enterprise#163 * feat(ui): surface portal_delegate keys in Settings → MCP Keys (ent#163) The issue's AC asks for the scope to be "admin-issued, listed and revocable in Settings → MCP Keys". Listing and revoking already worked — but a `portal_delegate` key rendered with NO badge, falling through the `agent`/`system` v-if chain and looking exactly like an ordinary user key. For a credential that can act as any end user with portal access, that is misleading in precisely the place an admin audits keys. And there was no way to create one from the UI at all, so "admin-issued" meant "curl only". Two small additions: * an amber **Portal Delegate** badge, deliberately distinct from the purple Agent / red System badges, with a title explaining what the key can do * an admin-only checkbox on the create modal (`v-if="isAdmin"`), opt-in rather than a default — minting a key that acts as other people should be a conscious choice, never a stray click. The scope is omitted entirely (not sent as null) for an ordinary key, so nothing changes for existing callers. Verified live: badge renders on both delegate keys; the checkbox appears for an admin and mints a working `scope=portal_delegate` key. The end-to-end flow was driven against local dev — mint → fenced (403 on /api/agents, /api/users, /api/mcp/keys) → exchange for two different end users from ONE key → disjoint rosters (bob→[evt-boss], carol→[evt-worker]) → unauthorized email 403 → revoke → 401. Related to trinity-enterprise#163
1 parent 9fdb782 commit dc4f490

8 files changed

Lines changed: 347 additions & 8 deletions

File tree

docs/memory/architecture.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1280,7 +1280,7 @@ CREATE TABLE mcp_api_keys (
12801280
is_active INTEGER DEFAULT 1,
12811281
user_id INTEGER NOT NULL,
12821282
agent_name TEXT, -- non-null for agent-scoped keys
1283-
scope TEXT DEFAULT 'user', -- user | agent | system
1283+
scope TEXT DEFAULT 'user', -- user | agent | system | connector | portal_delegate
12841284
FOREIGN KEY (user_id) REFERENCES users(id)
12851285
);
12861286
```
@@ -1982,6 +1982,7 @@ Enforced at the **MCP server layer** (`src/mcp-server/src/tools/`), not the back
19821982
| `user` | Owner/admin/shared checks | Owner/admin/shared checks |
19831983
| `agent` | Explicit permission list (`agent_permissions`) | Resolves to owner user; ownership/sharing checks only |
19841984
| `system` | **Bypasses all checks** | Resolves to owner user (system agent owner) |
1985+
| `portal_delegate` | n/a (not an MCP tool principal) | **Fenced to a single route** — may only exchange an asserted end-user email for a portal session; every other path 403s (ent#163) |
19851986

19861987
### 7. External Credentials (Agent → External Services)
19871988

src/backend/db/mcp_keys.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,27 @@ def _row_to_mcp_api_key(row) -> McpApiKey:
9090
scope=scope
9191
)
9292

93+
# ent#163: the scopes this endpoint may mint. `agent`, `connector` and
94+
# `system` are deliberately absent — they are bound to an agent and are
95+
# minted by their own code paths; accepting them here would let a caller
96+
# forge an agent principal with no agent behind it.
97+
_USER_CREATABLE_SCOPES = ("user", "portal_delegate")
98+
9399
def create_mcp_api_key(self, username: str, key_data: McpApiKeyCreate) -> Optional[McpApiKeyWithSecret]:
94-
"""Create a new MCP API key for a user (scope: user)."""
100+
"""Create a new MCP API key for a user.
101+
102+
Scope defaults to `user`. `portal_delegate` (ent#163) is admin-gated at
103+
the router; this layer only refuses anything outside the creatable set
104+
so a bad value can never reach the column.
105+
"""
95106
user = self._user_ops.get_user_by_username(username)
96107
if not user:
97108
return None
98109

110+
scope = getattr(key_data, "scope", None) or "user"
111+
if scope not in self._USER_CREATABLE_SCOPES:
112+
return None
113+
99114
key_id = self._generate_id()
100115
api_key = self._generate_mcp_api_key()
101116
key_hash = self._hash_api_key(api_key)
@@ -112,7 +127,17 @@ def create_mcp_api_key(self, username: str, key_data: McpApiKeyCreate) -> Option
112127
created_at=now,
113128
user_id=user["id"],
114129
agent_name=None,
115-
scope="user",
130+
scope=scope,
131+
# Set explicitly rather than leaning on the column default:
132+
# `schema.py` declares `is_active INTEGER DEFAULT 1` but
133+
# `db/tables.py` (the Core/Alembic source) declares a bare
134+
# `Column("is_active", Integer)` with no default, so a table
135+
# built from the metadata yields NULL here — and
136+
# `validate_mcp_api_key` treats a falsy is_active as revoked,
137+
# i.e. every minted key would be born invalid. Harmless today
138+
# (both live schema paths carry the DDL default) but a real
139+
# trap for anything built off the metadata.
140+
is_active=1,
116141
)
117142
)
118143

@@ -129,7 +154,7 @@ def create_mcp_api_key(self, username: str, key_data: McpApiKeyCreate) -> Option
129154
username=username,
130155
user_email=user.get("email"),
131156
agent_name=None,
132-
scope="user",
157+
scope=scope,
133158
api_key=api_key
134159
)
135160

src/backend/db_models.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ class AgentShareRequest(BaseModel):
8787
class McpApiKeyCreate(BaseModel):
8888
name: str
8989
description: Optional[str] = None
90+
# ent#163: normally omitted → a plain `user` key. The only other value a
91+
# caller may request is `portal_delegate`, and the router gates that on
92+
# admin. Agent/connector/system keys are minted by their own code paths,
93+
# never by this endpoint, so they are not accepted here.
94+
scope: Optional[str] = None
9095

9196

9297
class McpApiKey(BaseModel):

src/backend/dependencies.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,38 @@ def decode_mfa_challenge(token: str) -> Optional[dict]:
183183
PORTAL_SESSION_SCOPE = "portal_session"
184184
PORTAL_SESSION_EXPIRE_HOURS = 12
185185

186+
# --- Delegated portal identity (ent#163) ---------------------------------
187+
#
188+
# A `portal_delegate` MCP key lets a TRUSTED backend assert which of *its* end
189+
# users a request is for, and exchange that assertion for a portal session
190+
# token. It is how a licensee runs their own customer portal against Trinity
191+
# while keeping their own IdP: they already authenticated bob@example.com and
192+
# want Trinity to act as bob, not as the key owner.
193+
#
194+
# Why a dedicated scope rather than reusing `scope='user'`: this capability
195+
# reads another person's chat history. Riding it on an ordinary user key would
196+
# silently turn EVERY user key into a fleet-wide impersonation key. It is
197+
# admin-issued, and revoking the key stops delegation immediately.
198+
#
199+
# Why a mint rather than a per-request `X-On-Behalf-Of` header: a header puts
200+
# impersonation in the auth path of every current *and future* portal endpoint —
201+
# an ambient capability each new route silently inherits. A mint is one
202+
# auditable event, and everything downstream keeps using the portal session
203+
# token it already understands.
204+
#
205+
# Edition-agnostic, exactly like PORTAL_SESSION_SCOPE above: OSS owns the scope,
206+
# the containment fence, and the mint primitive; the entitled module owns the
207+
# endpoint that decides *whether* this email may be delegated. In an OSS-only
208+
# build the fenced path is not registered, so such a key can reach nothing.
209+
PORTAL_DELEGATE_SCOPE = "portal_delegate"
210+
211+
# The ONLY (method, path) a portal_delegate key may reach. Deliberately a single
212+
# exchange route, not a prefix: the minted portal session — not this key — is
213+
# what drives the portal surface afterwards, so this key never needs breadth.
214+
PORTAL_DELEGATE_ALLOWED_ROUTES = {
215+
("POST", "/api/enterprise/client-portal/auth/exchange"),
216+
}
217+
186218

187219
def create_portal_session_token(email: str, mode: str = "prod") -> str:
188220
"""Mint a Client Portal session token for a verified email. Carries no
@@ -330,6 +362,24 @@ async def get_current_user(request: Request, token: str = Depends(oauth2_scheme)
330362
# agent (see _enforce_connector_scope). The key is minted by an
331363
# entitled module; core only recognizes + enforces the scope.
332364
connector_agent = mcp_key_info.get("agent_name") if scope == "connector" else None
365+
# ent#163: a delegated-portal key is fenced to the single exchange
366+
# route. Enforced HERE at the one auth entry point — not only in the
367+
# portal router — for the same reason as the connector fence above:
368+
# this principal resolves to the key OWNER, so any endpoint doing an
369+
# inline access check would otherwise treat it as that human. The
370+
# key's whole job is to mint a portal session; it never needs to
371+
# reach anything else, including the portal endpoints themselves.
372+
portal_delegate = scope == PORTAL_DELEGATE_SCOPE
373+
if portal_delegate and (
374+
(request.method.upper(), request.url.path) not in PORTAL_DELEGATE_ALLOWED_ROUTES
375+
):
376+
raise HTTPException(
377+
status_code=status.HTTP_403_FORBIDDEN,
378+
detail=(
379+
"Portal delegate keys may only exchange an end-user email "
380+
"for a portal session"
381+
),
382+
)
333383
if connector_agent:
334384
# Central containment (ent#46): a connector key may reach ONLY
335385
# its bound agent's chat + connector playbook list. Enforced here
@@ -365,6 +415,7 @@ async def get_current_user(request: Request, token: str = Depends(oauth2_scheme)
365415
role=user["role"],
366416
agent_name=agent_name,
367417
connector_agent=connector_agent,
418+
portal_delegate=portal_delegate,
368419
)
369420

370421
# Both JWT and MCP key failed

src/backend/models.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,13 @@ class User(BaseModel):
207207
# key itself is minted by an entitled module (core-primitive + enterprise-
208208
# knob, same shape as users.suspended_at #995).
209209
connector_agent: Optional[str] = None
210+
# ent#163: True for a `portal_delegate` MCP key — a trusted issuer that may
211+
# exchange an asserted end-user email for a portal session, and NOTHING else
212+
# (fenced centrally in `dependencies.get_current_user`). Like every MCP key
213+
# it resolves to the key OWNER, so a consumer must branch on this flag
214+
# rather than on the resolved user: the whole point of the request is that
215+
# it concerns somebody other than the owner.
216+
portal_delegate: bool = False
210217

211218

212219
class Token(BaseModel):

src/backend/routers/mcp_keys.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@
66

77
from models import User
88
from database import db, McpApiKeyCreate, McpApiKey, McpApiKeyWithSecret
9-
from dependencies import get_current_user
9+
from dependencies import (
10+
PORTAL_DELEGATE_SCOPE,
11+
assert_admin,
12+
get_current_user,
13+
reject_agent_principal,
14+
)
1015
from services.platform_audit_service import platform_audit_service, AuditEventType
1116

1217
router = APIRouter(prefix="/api/mcp", tags=["mcp"])
@@ -21,7 +26,27 @@ async def create_mcp_api_key_endpoint(
2126
"""
2227
Create a new MCP API key for the current user.
2328
The full API key is only returned once during creation - store it securely.
29+
30+
Scope is `user` unless explicitly requested. `portal_delegate` (ent#163) is
31+
admin-only: it lets the holder act as any end user who has portal access, so
32+
it must never be self-issuable by an ordinary account.
2433
"""
34+
requested_scope = (getattr(key_data, "scope", None) or "user").strip()
35+
if requested_scope != "user":
36+
if requested_scope != PORTAL_DELEGATE_SCOPE:
37+
raise HTTPException(
38+
status_code=400,
39+
detail=f"Unsupported key scope '{requested_scope}'",
40+
)
41+
# Human-only AND admin. `assert_admin` alone is not enough: it rejects
42+
# connector principals but not agent-scoped ones, and an agent key
43+
# resolves to its OWNER carrying the owner's role — on a default
44+
# admin-owned install that passes a bare role check outright
45+
# (trinity-ops-agent#232). Minting an impersonation key is a human
46+
# decision, so the agent guard runs first.
47+
reject_agent_principal(current_user)
48+
assert_admin(current_user)
49+
2550
try:
2651
api_key = db.create_mcp_api_key(current_user.username, key_data)
2752

src/frontend/src/components/settings/McpKeysTab.vue

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,14 @@
8686
<span v-else-if="key.scope === 'system'" class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-status-urgent-100 dark:bg-status-urgent-900/50 text-status-urgent-800 dark:text-status-urgent-300">
8787
System
8888
</span>
89+
<!-- ent#163: this key can act as ANY end user with portal
90+
access, so it must never render like an ordinary user
91+
key on the page where an admin audits keys. -->
92+
<span v-else-if="key.scope === 'portal_delegate'"
93+
class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-status-warning-100 dark:bg-status-warning-900/50 text-status-warning-800 dark:text-status-warning-300"
94+
title="Can exchange an end-user email for a portal session — treat as a delegated-identity credential">
95+
Portal Delegate
96+
</span>
8997
</div>
9098
<p v-if="key.description" class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ key.description }}</p>
9199
<div class="mt-1 flex items-center text-xs text-gray-400 dark:text-gray-500 space-x-4">
@@ -158,6 +166,23 @@
158166
placeholder="Used for..."
159167
></textarea>
160168
</div>
169+
170+
<!-- ent#163. Admin-only, and deliberately opt-in rather than a
171+
default: a delegate key acts as other people, so creating one
172+
should be a conscious choice, never a stray click. -->
173+
<div v-if="isAdmin">
174+
<label class="flex items-start gap-2 cursor-pointer">
175+
<input type="checkbox" v-model="newKey.portalDelegate" class="mt-0.5 rounded text-action-primary-600 focus:ring-action-primary-500" />
176+
<span class="text-sm text-gray-700 dark:text-gray-300">
177+
Portal delegate key
178+
<span class="block text-xs text-gray-500 dark:text-gray-400">
179+
Lets a trusted backend exchange one of your client emails for a
180+
portal session — it acts as that person. It can do nothing else:
181+
every other endpoint is refused. Revoke it to stop delegation.
182+
</span>
183+
</span>
184+
</label>
185+
</div>
161186
</div>
162187
</div>
163188

@@ -326,7 +351,8 @@ const copiedConfig = ref(false)
326351
327352
const newKey = ref({
328353
name: '',
329-
description: ''
354+
description: '',
355+
portalDelegate: false
330356
})
331357
332358
const confirmDialog = reactive({
@@ -432,7 +458,10 @@ const createKey = async () => {
432458
},
433459
body: JSON.stringify({
434460
name: newKey.value.name,
435-
description: newKey.value.description || null
461+
description: newKey.value.description || null,
462+
// Omitted (not `null`) for an ordinary key so the backend
463+
// default stands and nothing changes for existing callers.
464+
...(newKey.value.portalDelegate ? { scope: 'portal_delegate' } : {})
436465
})
437466
})
438467
@@ -441,7 +470,7 @@ const createKey = async () => {
441470
createdApiKey.value = data.api_key
442471
showCreateModal.value = false
443472
showKeyModal.value = true
444-
newKey.value = { name: '', description: '' }
473+
newKey.value = { name: '', description: '', portalDelegate: false }
445474
await fetchApiKeys()
446475
} else {
447476
const error = await response.json()

0 commit comments

Comments
 (0)