From 6fd62df2a87372990e61c90c67a8bc7b46477333 Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Sun, 3 May 2026 15:42:07 +0000 Subject: [PATCH] auth: cut over community machine access to agent keys --- TASKS.md | 55 +----- apps/admin-api/src/app.rs | 158 +++++++++------ apps/admin-api/src/routes/access.rs | 59 +----- apps/admin-api/src/routes/agents.rs | 72 ++++++- apps/admin-api/src/service.rs | 85 +++++--- apps/mcp-server/src/app.rs | 6 +- apps/mcp-server/src/main.rs | 185 +++++++++++++++--- apps/ui/html/api-keys.html | 26 ++- apps/ui/js/api-keys.js | 109 ++++++++++- apps/ui/js/api.js | 16 +- apps/ui/js/i18n.js | 46 +++-- apps/ui/tests/e2e/api-keys.spec.js | 17 +- apps/ui/tests/e2e/helpers.js | 12 +- crates/crank-core/src/access.rs | 7 +- crates/crank-registry/src/migrations.rs | 4 + crates/crank-registry/src/postgres/api_key.rs | 170 +++++++++++++--- crates/crank-registry/src/postgres/mod.rs | 27 ++- docs/admin-api.md | 16 +- 18 files changed, 768 insertions(+), 302 deletions(-) diff --git a/TASKS.md b/TASKS.md index d569fe6..70d70ff 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,55 +2,9 @@ ## Current -### `feat/community-agent-key-cutover` - -Status: in_progress - -Goal: -- довести открытую редакцию до продуктово завершенного машинного доступа через ключ AI-агента. - -Main code areas: -- `crates/crank-core/src/agent.rs` -- `crates/crank-core/src/auth.rs` -- `crates/crank-registry/src/postgres/agent.rs` -- `crates/crank-registry/src/postgres/api_key.rs` -- `apps/admin-api/src/service.rs` -- `apps/admin-api/src/app.rs` -- `apps/mcp-server/src/app.rs` -- `apps/ui/js/agents.js` -- `apps/ui/js/api-keys.js` -- `apps/ui/html/agents.html` -- `apps/ui/html/api-keys.html` -- `apps/ui/js/i18n.js` - -Implementation slices: -1. Закрыть remaining workspace/platform-key assumptions в `admin-api` и `mcp-server`. -2. Нормализовать list/create/revoke/delete flow для agent keys. -3. Привести UI страницы `Agents` и `API Keys` к одной модели agent-scoped machine access. -4. Явно ограничить `Community` на `security_level = standard`. - -Progress: -- done: `mcp-server` принимает ключ только по `(workspace_slug, agent_slug, secret_hash)`; -- done: `admin-api` уже отдает и принимает agent-scoped key routes; -- done: UI `API Keys` переведен на agent-scoped machine access; -- done: removed legacy workspace-key routes, demo/bootstrap assumptions, and per-agent key count bug. - -DoD: -- ключ создается и принадлежит конкретному AI-агенту; -- UI объясняет назначение ключа без ссылок на старую workspace-key модель; -- `mcp-server` принимает Community machine access только по agent key; -- Community flow не требует short-lived token service. - -Verification: -- backend unit and integration tests for key lifecycle; -- MCP tool-call tests through agent endpoint; -- e2e smoke for agent key create/reveal/revoke. - -## Next - ### `feat/edition-capability-model` -Status: ready +Status: in_progress Goal: - ввести capability model по редакциям и перестать смешивать Community и premium surface. @@ -74,6 +28,11 @@ Implementation slices: 3. Привести UI к capability gating вместо скрытых продуктовых допущений. 4. Зафиксировать Community defaults в docs и runtime config. +Progress: +- pending: define shared capability payload in `crank-core` and `admin-api`; +- pending: expose Community capability defaults to UI; +- pending: gate premium protocols and security levels in wizard and agents flows. + DoD: - UI знает, какие функции входят в текущую редакцию; - Community не показывает рабочие controls для premium-only features; @@ -84,6 +43,8 @@ Verification: - frontend smoke for hidden/locked states; - manual verification on Community build. +## Next + ### `feat/private-token-service-seam` Status: ready diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index 08a1f28..59d6f2a 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -9,13 +9,14 @@ use crate::{ request_context::apply_request_context, routes::{ access::{ - create_invitation, create_platform_api_key, delete_invitation, delete_membership, - delete_platform_api_key, delete_workspace, export_workspace, list_invitations, - list_memberships, list_platform_api_keys, revoke_platform_api_key, update_membership, + create_invitation, delete_invitation, delete_membership, delete_workspace, + export_workspace, list_invitations, list_memberships, update_membership, }, agents::{ - archive_agent, create_agent, delete_agent, get_agent, get_agent_version, list_agents, - publish_agent, save_agent_bindings, unpublish_agent, update_agent, + archive_agent, create_agent, create_agent_platform_api_key, delete_agent, + delete_agent_platform_api_key, get_agent, get_agent_version, + list_agent_platform_api_keys, list_agents, publish_agent, + revoke_agent_platform_api_key, save_agent_bindings, unpublish_agent, update_agent, }, auth::{ change_password, get_profile, get_session, login, logout, update_current_workspace, @@ -114,6 +115,18 @@ pub fn build_app(state: AppState) -> Router { .route("/agents/{agent_id}/publish", post(publish_agent)) .route("/agents/{agent_id}/unpublish", post(unpublish_agent)) .route("/agents/{agent_id}/archive", post(archive_agent)) + .route( + "/agents/{agent_id}/platform-api-keys", + get(list_agent_platform_api_keys).post(create_agent_platform_api_key), + ) + .route( + "/agents/{agent_id}/platform-api-keys/{key_id}/revoke", + post(revoke_agent_platform_api_key), + ) + .route( + "/agents/{agent_id}/platform-api-keys/{key_id}", + delete(delete_agent_platform_api_key), + ) .route( "/auth-profiles", get(list_auth_profiles).post(create_auth_profile), @@ -136,18 +149,6 @@ pub fn build_app(state: AppState) -> Router { ) .route("/invitations/{invitation_id}", delete(delete_invitation)) .route("/export", get(export_workspace)) - .route( - "/platform-api-keys", - get(list_platform_api_keys).post(create_platform_api_key), - ) - .route( - "/platform-api-keys/{key_id}/revoke", - post(revoke_platform_api_key), - ) - .route( - "/platform-api-keys/{key_id}", - delete(delete_platform_api_key), - ) .route("/logs", get(list_logs)) .route("/logs/{log_id}", get(get_log)) .route("/usage", get(get_usage)) @@ -891,48 +892,12 @@ mod tests { .json::() .await .unwrap(); - let created_key = client - .post(format!("{base_url}/platform-api-keys")) - .json(&json!({ - "name": "workspace-operator", - "scopes": ["read", "write"] - })) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - let key_id = created_key["api_key"]["api_key"]["id"] - .as_str() - .unwrap() - .to_owned(); - let listed_keys = client - .get(format!("{base_url}/platform-api-keys")) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - let revoke_status = client - .post(format!("{base_url}/platform-api-keys/{key_id}/revoke")) - .send() - .await - .unwrap() - .status(); let delete_invitation_status = client .delete(format!("{base_url}/invitations/{invitation_id}")) .send() .await .unwrap() .status(); - let delete_key_status = client - .delete(format!("{base_url}/platform-api-keys/{key_id}")) - .send() - .await - .unwrap() - .status(); assert_eq!(members["items"][0]["role"], "owner"); assert_eq!( @@ -949,14 +914,89 @@ mod tests { invitations["items"][0]["invitation"]["email"], "operator@example.com" ); + assert_eq!(delete_invitation_status, reqwest::StatusCode::NO_CONTENT); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn manages_agent_platform_api_keys() { + let registry = test_registry().await; + let storage_root = test_storage_root("agent_platform_keys"); + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + + let created_agent = assert_success_json( + client + .post(format!("{base_url}/agents")) + .json(&json!({ + "slug": "sales-routing", + "display_name": "Sales Routing", + "description": "Routing agent", + "instructions": {}, + "tool_selection_policy": {} + })) + .send() + .await + .unwrap(), + ) + .await; + let agent_id = created_agent["agent_id"].as_str().unwrap().to_owned(); + + let created_key = assert_success_json( + client + .post(format!("{base_url}/agents/{agent_id}/platform-api-keys")) + .json(&json!({ + "name": "sales-routing-primary", + "scopes": ["read", "write"] + })) + .send() + .await + .unwrap(), + ) + .await; + let key_id = created_key["api_key"]["api_key"]["id"] + .as_str() + .unwrap() + .to_owned(); + + let listed_keys = assert_success_json( + client + .get(format!("{base_url}/agents/{agent_id}/platform-api-keys")) + .send() + .await + .unwrap(), + ) + .await; + + let revoke_status = client + .post(format!( + "{base_url}/agents/{agent_id}/platform-api-keys/{key_id}/revoke" + )) + .send() + .await + .unwrap() + .status(); + let delete_status = client + .delete(format!( + "{base_url}/agents/{agent_id}/platform-api-keys/{key_id}" + )) + .send() + .await + .unwrap() + .status(); + + assert_eq!(created_key["api_key"]["api_key"]["agent_id"], agent_id); + assert_eq!( + listed_keys["items"][0]["api_key"]["agent_id"], + json!(agent_id) + ); assert_eq!( listed_keys["items"][0]["api_key"]["name"], - "workspace-operator" + "sales-routing-primary" ); assert!(created_key["secret"].as_str().unwrap().starts_with("crk_")); assert_eq!(revoke_status, reqwest::StatusCode::NO_CONTENT); - assert_eq!(delete_invitation_status, reqwest::StatusCode::NO_CONTENT); - assert_eq!(delete_key_status, reqwest::StatusCode::NO_CONTENT); + assert_eq!(delete_status, reqwest::StatusCode::NO_CONTENT); } #[tokio::test(flavor = "multi_thread")] @@ -1087,11 +1127,7 @@ mod tests { let agents = service.list_agents(&default_workspace_id).await.unwrap(); assert!(agents.len() >= 2); - let api_keys = service - .list_platform_api_keys(&default_workspace_id) - .await - .unwrap(); - assert!(api_keys.len() >= 2); + assert!(agents.iter().any(|agent| agent.key_count > 0)); let logs = service .list_logs( diff --git a/apps/admin-api/src/routes/access.rs b/apps/admin-api/src/routes/access.rs index d8774a2..87ef0b5 100644 --- a/apps/admin-api/src/routes/access.rs +++ b/apps/admin-api/src/routes/access.rs @@ -9,7 +9,7 @@ use serde_json::{Value, json}; use crate::{ auth::AuthenticatedSession, error::ApiError, - service::{InvitationPayload, PlatformApiKeyPayload, UpdateMembershipPayload}, + service::{InvitationPayload, UpdateMembershipPayload}, state::AppState, }; @@ -30,12 +30,6 @@ pub struct WorkspaceMembershipPath { pub user_id: String, } -#[derive(Deserialize)] -pub struct WorkspacePlatformApiKeyPath { - pub workspace_id: String, - pub key_id: String, -} - pub async fn list_memberships( Path(path): Path, State(state): State, @@ -118,57 +112,6 @@ pub async fn delete_invitation( Ok(StatusCode::NO_CONTENT) } -pub async fn list_platform_api_keys( - Path(path): Path, - State(state): State, -) -> Result, ApiError> { - let items = state - .service - .list_platform_api_keys(&path.workspace_id.as_str().into()) - .await?; - Ok(Json(json!({ "items": items }))) -} - -pub async fn create_platform_api_key( - Path(path): Path, - State(state): State, - Json(payload): Json, -) -> Result, ApiError> { - let created = state - .service - .create_platform_api_key(&path.workspace_id.as_str().into(), payload) - .await?; - Ok(Json(json!(created))) -} - -pub async fn revoke_platform_api_key( - Path(path): Path, - State(state): State, -) -> Result { - state - .service - .revoke_platform_api_key( - &path.workspace_id.as_str().into(), - &path.key_id.as_str().into(), - ) - .await?; - Ok(StatusCode::NO_CONTENT) -} - -pub async fn delete_platform_api_key( - Path(path): Path, - State(state): State, -) -> Result { - state - .service - .delete_platform_api_key( - &path.workspace_id.as_str().into(), - &path.key_id.as_str().into(), - ) - .await?; - Ok(StatusCode::NO_CONTENT) -} - pub async fn export_workspace( Path(path): Path, State(state): State, diff --git a/apps/admin-api/src/routes/agents.rs b/apps/admin-api/src/routes/agents.rs index f27975c..faa7f69 100644 --- a/apps/admin-api/src/routes/agents.rs +++ b/apps/admin-api/src/routes/agents.rs @@ -7,7 +7,10 @@ use serde_json::{Value, json}; use crate::{ error::ApiError, - service::{AgentBindingPayload, AgentPayload, PublishPayload, UpdateAgentPayload}, + service::{ + AgentBindingPayload, AgentPayload, PlatformApiKeyPayload, PublishPayload, + UpdateAgentPayload, + }, state::AppState, }; @@ -29,6 +32,13 @@ pub struct WorkspaceAgentVersionPath { pub version: u32, } +#[derive(Deserialize)] +pub struct WorkspaceAgentPlatformApiKeyPath { + pub workspace_id: String, + pub agent_id: String, + pub key_id: String, +} + pub async fn list_agents( Path(path): Path, State(state): State, @@ -170,3 +180,63 @@ pub async fn archive_agent( .await?; Ok(Json(json!(updated))) } + +pub async fn list_agent_platform_api_keys( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let items = state + .service + .list_agent_platform_api_keys( + &path.workspace_id.as_str().into(), + &path.agent_id.as_str().into(), + ) + .await?; + Ok(Json(json!({ "items": items }))) +} + +pub async fn create_agent_platform_api_key( + Path(path): Path, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let created = state + .service + .create_agent_platform_api_key( + &path.workspace_id.as_str().into(), + &path.agent_id.as_str().into(), + payload, + ) + .await?; + Ok(Json(json!(created))) +} + +pub async fn revoke_agent_platform_api_key( + Path(path): Path, + State(state): State, +) -> Result { + state + .service + .revoke_agent_platform_api_key( + &path.workspace_id.as_str().into(), + &path.agent_id.as_str().into(), + &path.key_id.as_str().into(), + ) + .await?; + Ok(axum::http::StatusCode::NO_CONTENT) +} + +pub async fn delete_agent_platform_api_key( + Path(path): Path, + State(state): State, +) -> Result { + state + .service + .delete_agent_platform_api_key( + &path.workspace_id.as_str().into(), + &path.agent_id.as_str().into(), + &path.key_id.as_str().into(), + ) + .await?; + Ok(axum::http::StatusCode::NO_CONTENT) +} diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index fc6b47e..2563fb6 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -1279,7 +1279,7 @@ impl AdminService { .collect(); let operations = self.list_operations(workspace_id).await?; let agents = self.list_agents(workspace_id).await?; - let platform_api_keys = self.list_platform_api_keys(workspace_id).await?; + let platform_api_keys = self.registry.list_platform_api_keys(workspace_id).await?; Ok(WorkspaceExportResponse { workspace, @@ -1314,27 +1314,51 @@ impl AdminService { } #[instrument(skip(self))] - pub async fn list_platform_api_keys( + pub async fn list_agent_platform_api_keys( &self, workspace_id: &WorkspaceId, + agent_id: &AgentId, ) -> Result, ApiError> { self.ensure_workspace_exists(workspace_id).await?; - Ok(self.registry.list_platform_api_keys(workspace_id).await?) + self.registry + .get_agent_summary(workspace_id, agent_id) + .await? + .ok_or_else(|| { + ApiError::not_found_with_context( + format!("agent {} was not found", agent_id.as_str()), + json!({ "agent_id": agent_id.as_str() }), + ) + })?; + Ok(self + .registry + .list_platform_api_keys_for_agent(workspace_id, agent_id) + .await?) } - #[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), key_name = %payload.name))] - pub async fn create_platform_api_key( + #[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_name = %payload.name))] + pub async fn create_agent_platform_api_key( &self, workspace_id: &WorkspaceId, + agent_id: &AgentId, payload: PlatformApiKeyPayload, ) -> Result { self.ensure_workspace_exists(workspace_id).await?; + self.registry + .get_agent_summary(workspace_id, agent_id) + .await? + .ok_or_else(|| { + ApiError::not_found_with_context( + format!("agent {} was not found", agent_id.as_str()), + json!({ "agent_id": agent_id.as_str() }), + ) + })?; let secret = generate_access_secret("crk"); let api_key = PlatformApiKeyRecord { api_key: PlatformApiKey { id: PlatformApiKeyId::new(new_prefixed_id("pk")), workspace_id: workspace_id.clone(), + agent_id: Some(agent_id.clone()), name: payload.name, prefix: secret.chars().take(16).collect(), scopes: payload.scopes, @@ -1354,26 +1378,33 @@ impl AdminService { Ok(CreatedPlatformApiKeyResponse { api_key, secret }) } - #[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), key_id = %key_id.as_str()))] - pub async fn revoke_platform_api_key( + #[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_id = %key_id.as_str()))] + pub async fn revoke_agent_platform_api_key( &self, workspace_id: &WorkspaceId, + agent_id: &AgentId, key_id: &PlatformApiKeyId, ) -> Result<(), ApiError> { self.registry - .revoke_platform_api_key(workspace_id, key_id, &OffsetDateTime::now_utc()) + .revoke_platform_api_key_for_agent( + workspace_id, + agent_id, + key_id, + &OffsetDateTime::now_utc(), + ) .await?; Ok(()) } - #[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), key_id = %key_id.as_str()))] - pub async fn delete_platform_api_key( + #[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_id = %key_id.as_str()))] + pub async fn delete_agent_platform_api_key( &self, workspace_id: &WorkspaceId, + agent_id: &AgentId, key_id: &PlatformApiKeyId, ) -> Result<(), ApiError> { self.registry - .delete_platform_api_key(workspace_id, key_id) + .delete_platform_api_key_for_agent(workspace_id, agent_id, key_id) .await?; Ok(()) } @@ -2818,11 +2849,17 @@ impl AdminService { .into_iter() .map(|item| (item.agent_id.as_str().to_owned(), item.calls_total)) .collect::>(); - let key_count = self + let key_counts = self .registry .list_platform_api_keys(workspace_id) .await? - .len(); + .into_iter() + .fold(BTreeMap::new(), |mut counts, record| { + if let Some(agent_id) = record.api_key.agent_id { + *counts.entry(agent_id.as_str().to_owned()).or_insert(0usize) += 1; + } + counts + }); let mut items = Vec::with_capacity(summaries.len()); for summary in summaries { @@ -2837,7 +2874,7 @@ impl AdminService { items.push(AgentSummaryView { operation_count: operation_ids.len(), operation_ids, - key_count, + key_count: key_counts.get(summary.id.as_str()).copied().unwrap_or(0), calls_today: calls_today.get(summary.id.as_str()).copied().unwrap_or(0), mcp_endpoint: agent_mcp_endpoint( workspace.workspace.slug.as_str(), @@ -2891,7 +2928,7 @@ impl AdminService { .await?; let key_count = self .registry - .list_platform_api_keys(workspace_id) + .list_platform_api_keys_for_agent(workspace_id, agent_id) .await? .len(); @@ -3981,6 +4018,7 @@ impl AdminService { self.ensure_demo_platform_api_key( workspace_id, + &AgentId::new(revops_agent.id.clone()), "Web Console Demo Key", vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], false, @@ -3988,6 +4026,7 @@ impl AdminService { .await?; self.ensure_demo_platform_api_key( workspace_id, + &AgentId::new(support_agent.id.clone()), "Readonly Export Key", vec![PlatformApiKeyScope::Read], true, @@ -4045,14 +4084,6 @@ impl AdminService { }, ) .await?; - self.ensure_demo_platform_api_key( - &workspace_id, - "Growth Readonly Key", - vec![PlatformApiKeyScope::Read], - false, - ) - .await?; - Ok(()) } @@ -4109,20 +4140,22 @@ impl AdminService { async fn ensure_demo_platform_api_key( &self, workspace_id: &WorkspaceId, + agent_id: &AgentId, name: &str, scopes: Vec, revoke: bool, ) -> Result<(), ApiError> { let existing = self - .list_platform_api_keys(workspace_id) + .list_agent_platform_api_keys(workspace_id, agent_id) .await? .into_iter() .find(|record| record.api_key.name == name); let key = match existing { Some(record) => record, None => { - self.create_platform_api_key( + self.create_agent_platform_api_key( workspace_id, + agent_id, PlatformApiKeyPayload { name: name.to_owned(), scopes, @@ -4134,7 +4167,7 @@ impl AdminService { }; if revoke && key.api_key.status != PlatformApiKeyStatus::Revoked { - self.revoke_platform_api_key(workspace_id, &key.api_key.id) + self.revoke_agent_platform_api_key(workspace_id, agent_id, &key.api_key.id) .await?; } diff --git a/apps/mcp-server/src/app.rs b/apps/mcp-server/src/app.rs index f6c63b3..49ab8e0 100644 --- a/apps/mcp-server/src/app.rs +++ b/apps/mcp-server/src/app.rs @@ -1813,7 +1813,11 @@ async fn require_platform_api_key( let secret_hash = hash_access_secret(secret); let Some(api_key) = state .registry - .get_platform_api_key_by_secret_for_workspace_slug(&path.workspace_slug, &secret_hash) + .get_platform_api_key_by_secret_for_agent_slug( + &path.workspace_slug, + &path.agent_slug, + &secret_hash, + ) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? else { diff --git a/apps/mcp-server/src/main.rs b/apps/mcp-server/src/main.rs index ad4fc94..6e555bd 100644 --- a/apps/mcp-server/src/main.rs +++ b/apps/mcp-server/src/main.rs @@ -208,6 +208,10 @@ mod tests { "default" } + fn test_agent_id(agent_slug: &str) -> AgentId { + AgentId::new(format!("agent_{agent_slug}")) + } + fn build_test_app( registry: PostgresRegistry, refresh_interval: Duration, @@ -277,6 +281,7 @@ mod tests { publish_agent_for_operation(®istry, &operation, "sales-rest").await; let api_key = create_platform_api_key( ®istry, + "sales-rest", "mcp-rest", &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], ) @@ -383,6 +388,7 @@ mod tests { publish_agent_for_operation(®istry, &operation, "sales-request-id").await; let api_key = create_platform_api_key( ®istry, + "sales-request-id", "mcp-request-id", &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], ) @@ -466,6 +472,7 @@ mod tests { publish_agent_for_operation(®istry, &operation, "sales-generated-request-id").await; let api_key = create_platform_api_key( ®istry, + "sales-generated-request-id", "mcp-generated-request-id", &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], ) @@ -553,6 +560,7 @@ mod tests { publish_agent_for_operation(®istry, &operation, "sales-request-trace").await; let api_key = create_platform_api_key( ®istry, + "sales-request-trace", "mcp-request-trace", &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], ) @@ -633,6 +641,7 @@ mod tests { publish_agent_for_operation(®istry, &operation, "sales-graphql").await; let api_key = create_platform_api_key( ®istry, + "sales-graphql", "mcp-graphql", &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], ) @@ -697,6 +706,7 @@ mod tests { publish_agent_for_operation(®istry, &operation, "sales-grpc").await; let api_key = create_platform_api_key( ®istry, + "sales-grpc", "mcp-grpc", &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], ) @@ -742,8 +752,13 @@ mod tests { async fn requires_initialized_notification_before_tool_methods() { let registry = test_registry().await; publish_agent_with_bindings(®istry, "sales-init", vec![]).await; - let api_key = - create_platform_api_key(®istry, "mcp-init", &[PlatformApiKeyScope::Read]).await; + let api_key = create_platform_api_key( + ®istry, + "sales-init", + "mcp-init", + &[PlatformApiKeyScope::Read], + ) + .await; let base_url = spawn_mcp_server(build_test_app( registry, Duration::from_millis(0), @@ -802,6 +817,7 @@ mod tests { publish_agent_with_bindings(®istry, "sales-sse-init", vec![]).await; let api_key = create_platform_api_key( ®istry, + "sales-sse-init", "mcp-sse-init", &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], ) @@ -852,6 +868,7 @@ mod tests { publish_agent_with_bindings(®istry, "sales-get-sse", vec![]).await; let api_key = create_platform_api_key( ®istry, + "sales-get-sse", "mcp-get-sse", &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], ) @@ -903,6 +920,7 @@ mod tests { publish_agent_with_bindings(®istry, "sales-expired-session", vec![]).await; let api_key = create_platform_api_key( ®istry, + "sales-expired-session", "mcp-expired-session", &[PlatformApiKeyScope::Read], ) @@ -954,6 +972,7 @@ mod tests { publish_agent_with_bindings(®istry, "sales-get-rate-limit", vec![]).await; let api_key = create_platform_api_key( ®istry, + "sales-get-rate-limit", "mcp-get-rate-limit", &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], ) @@ -1003,6 +1022,7 @@ mod tests { publish_agent_with_bindings(®istry, "sales-get-sse-missing", vec![]).await; let api_key = create_platform_api_key( ®istry, + "sales-get-sse-missing", "mcp-get-sse-missing", &[PlatformApiKeyScope::Read], ) @@ -1032,6 +1052,7 @@ mod tests { publish_agent_with_bindings(®istry, "sales-delete-session", vec![]).await; let api_key = create_platform_api_key( ®istry, + "sales-delete-session", "mcp-delete-session", &[PlatformApiKeyScope::Read], ) @@ -1074,9 +1095,13 @@ mod tests { async fn initialize_accepts_json_only_response_negotiation() { let registry = test_registry().await; publish_agent_with_bindings(®istry, "sales-json-accept", vec![]).await; - let api_key = - create_platform_api_key(®istry, "mcp-json-accept", &[PlatformApiKeyScope::Read]) - .await; + let api_key = create_platform_api_key( + ®istry, + "sales-json-accept", + "mcp-json-accept", + &[PlatformApiKeyScope::Read], + ) + .await; let base_url = spawn_mcp_server(build_test_app( registry, Duration::from_millis(0), @@ -1117,6 +1142,7 @@ mod tests { publish_agent_with_bindings(®istry, "sales-get-bad-version", vec![]).await; let api_key = create_platform_api_key( ®istry, + "sales-get-bad-version", "mcp-get-bad-version", &[PlatformApiKeyScope::Read], ) @@ -1158,6 +1184,7 @@ mod tests { let mcp_url = agent_mcp_url(&base_url, "sales-refresh"); let api_key = create_platform_api_key( ®istry, + "sales-refresh", "mcp-refresh", &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], ) @@ -1252,9 +1279,17 @@ mod tests { vec![binding_for_operation(&operation_b)], ) .await; - let api_key = create_platform_api_key( + let api_key_a = create_platform_api_key( ®istry, - "mcp-agents", + "sales-a", + "mcp-agent-a", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + let api_key_b = create_platform_api_key( + ®istry, + "sales-b", + "mcp-agent-b", &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], ) .await; @@ -1268,13 +1303,13 @@ mod tests { let client = reqwest::Client::new(); let agent_a_url = agent_mcp_url(&base_url, "sales-a"); let agent_b_url = agent_mcp_url(&base_url, "sales-b"); - let session_a = initialize_session(&client, &agent_a_url, &api_key).await; - let session_b = initialize_session(&client, &agent_b_url, &api_key).await; + let session_a = initialize_session(&client, &agent_a_url, &api_key_a).await; + let session_b = initialize_session(&client, &agent_b_url, &api_key_b).await; let tools_a = post_jsonrpc( &client, &agent_a_url, - &api_key, + &api_key_a, Some(&session_a), json!({ "jsonrpc": "2.0", @@ -1287,7 +1322,7 @@ mod tests { let tools_b = post_jsonrpc( &client, &agent_b_url, - &api_key, + &api_key_b, Some(&session_b), json!({ "jsonrpc": "2.0", @@ -1302,6 +1337,76 @@ mod tests { assert_eq!(tools_b["result"]["tools"][0]["name"], "crm_update_lead"); } + #[tokio::test] + async fn rejects_initialize_with_key_from_different_agent() { + let registry = test_registry().await; + let upstream_base_url = spawn_upstream_server().await; + let operation_a = test_operation(&upstream_base_url, "crm_create_lead"); + let operation_b = test_operation(&upstream_base_url, "crm_update_lead"); + + for operation in [&operation_a, &operation_b] { + registry + .create_operation(&test_workspace_id(), operation, Some("alice")) + .await + .unwrap(); + registry + .publish_operation(PublishRequest { + workspace_id: &test_workspace_id(), + operation_id: &operation.id, + version: 1, + published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), + published_by: Some("alice"), + }) + .await + .unwrap(); + } + + publish_agent_with_bindings( + ®istry, + "sales-a", + vec![binding_for_operation(&operation_a)], + ) + .await; + publish_agent_with_bindings( + ®istry, + "sales-b", + vec![binding_for_operation(&operation_b)], + ) + .await; + let api_key_a = create_platform_api_key( + ®istry, + "sales-a", + "mcp-agent-a-only", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + + let base_url = spawn_mcp_server(build_test_app( + registry, + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + let response = client + .post(agent_mcp_url(&base_url, "sales-b")) + .header(header::ACCEPT, "application/json, text/event-stream") + .header(header::AUTHORIZATION, format!("Bearer {api_key_a}")) + .json(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25" + } + })) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); + } + #[tokio::test] async fn rejects_initialize_without_platform_api_key() { let registry = test_registry().await; @@ -1352,8 +1457,13 @@ mod tests { .await .unwrap(); publish_agent_for_operation(®istry, &operation, "sales-read-only").await; - let api_key = - create_platform_api_key(®istry, "mcp-read", &[PlatformApiKeyScope::Read]).await; + let api_key = create_platform_api_key( + ®istry, + "sales-read-only", + "mcp-read", + &[PlatformApiKeyScope::Read], + ) + .await; let base_url = spawn_mcp_server(build_test_app( registry, @@ -1411,6 +1521,7 @@ mod tests { publish_agent_for_operation(®istry, &operation, "sales-session").await; let api_key = create_platform_api_key( ®istry, + "sales-session", "mcp-session", &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], ) @@ -1558,9 +1669,17 @@ mod tests { publish_agent_for_operation(®istry, &operation_a, "sales-session-a").await; publish_agent_for_operation(®istry, &operation_b, "sales-session-b").await; - let api_key = create_platform_api_key( + let api_key_a = create_platform_api_key( ®istry, - "mcp-cross-session", + "sales-session-a", + "mcp-cross-session-a", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + let api_key_b = create_platform_api_key( + ®istry, + "sales-session-b", + "mcp-cross-session-b", &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], ) .await; @@ -1574,13 +1693,13 @@ mod tests { let client = reqwest::Client::new(); let agent_a_url = agent_mcp_url(&base_url, "sales-session-a"); let agent_b_url = agent_mcp_url(&base_url, "sales-session-b"); - let session_a = initialize_session(&client, &agent_a_url, &api_key).await; - let session_b = initialize_session(&client, &agent_b_url, &api_key).await; + let session_a = initialize_session(&client, &agent_a_url, &api_key_a).await; + let session_b = initialize_session(&client, &agent_b_url, &api_key_b).await; let start_response = post_jsonrpc( &client, &agent_a_url, - &api_key, + &api_key_a, Some(&session_a), json!({ "jsonrpc": "2.0", @@ -1601,7 +1720,7 @@ mod tests { let poll_response = post_jsonrpc( &client, &agent_b_url, - &api_key, + &api_key_b, Some(&session_b), json!({ "jsonrpc": "2.0", @@ -1650,6 +1769,7 @@ mod tests { publish_agent_for_operation(®istry, &operation, "sales-session-rate").await; let api_key = create_platform_api_key( ®istry, + "sales-session-rate", "mcp-session-rate", &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], ) @@ -1755,6 +1875,7 @@ mod tests { publish_agent_for_operation(®istry, &operation, "sales-async-rate").await; let api_key = create_platform_api_key( ®istry, + "sales-async-rate", "mcp-async-rate", &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], ) @@ -1866,6 +1987,7 @@ mod tests { publish_agent_for_operation(®istry, &operation, "sales-async-completed").await; let api_key = create_platform_api_key( ®istry, + "sales-async-completed", "mcp-async-completed", &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], ) @@ -1951,6 +2073,7 @@ mod tests { publish_agent_for_operation(®istry, &operation, "sales-rate-limited").await; let api_key = create_platform_api_key( ®istry, + "sales-rate-limited", "mcp-rate-limit", &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], ) @@ -2050,9 +2173,17 @@ mod tests { publish_agent_for_operation(®istry, &operation_a, "sales-async-a").await; publish_agent_for_operation(®istry, &operation_b, "sales-async-b").await; - let api_key = create_platform_api_key( + let api_key_a = create_platform_api_key( ®istry, - "mcp-cross-async", + "sales-async-a", + "mcp-cross-async-a", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + let api_key_b = create_platform_api_key( + ®istry, + "sales-async-b", + "mcp-cross-async-b", &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], ) .await; @@ -2066,13 +2197,13 @@ mod tests { let client = reqwest::Client::new(); let agent_a_url = agent_mcp_url(&base_url, "sales-async-a"); let agent_b_url = agent_mcp_url(&base_url, "sales-async-b"); - let session_a = initialize_session(&client, &agent_a_url, &api_key).await; - let session_b = initialize_session(&client, &agent_b_url, &api_key).await; + let session_a = initialize_session(&client, &agent_a_url, &api_key_a).await; + let session_b = initialize_session(&client, &agent_b_url, &api_key_b).await; let start_response = post_jsonrpc( &client, &agent_a_url, - &api_key, + &api_key_a, Some(&session_a), json!({ "jsonrpc": "2.0", @@ -2093,7 +2224,7 @@ mod tests { let status_response = post_jsonrpc( &client, &agent_b_url, - &api_key, + &api_key_b, Some(&session_b), json!({ "jsonrpc": "2.0", @@ -2141,6 +2272,7 @@ mod tests { publish_agent_for_operation(®istry, &operation, "sales-async").await; let api_key = create_platform_api_key( ®istry, + "sales-async", "mcp-async", &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], ) @@ -2290,6 +2422,7 @@ mod tests { publish_agent_for_operation(®istry, &operation, "sales-window").await; let api_key = create_platform_api_key( ®istry, + "sales-window", "mcp-window", &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], ) @@ -2418,6 +2551,7 @@ mod tests { async fn create_platform_api_key( registry: &PostgresRegistry, + agent_slug: &str, name: &str, scopes: &[PlatformApiKeyScope], ) -> String { @@ -2425,6 +2559,7 @@ mod tests { let api_key = PlatformApiKey { id: PlatformApiKeyId::new(format!("pk_{name}")), workspace_id: test_workspace_id(), + agent_id: Some(test_agent_id(agent_slug)), name: name.to_owned(), prefix: secret.chars().take(16).collect(), scopes: scopes.to_vec(), diff --git a/apps/ui/html/api-keys.html b/apps/ui/html/api-keys.html index ca3d7c9..0a62a4d 100644 --- a/apps/ui/html/api-keys.html +++ b/apps/ui/html/api-keys.html @@ -4,7 +4,7 @@ - Crank — API Keys + Crank — Agent Keys @@ -107,8 +107,8 @@ +
+
+
+
Agent access
+
Select the AI agent whose MCP endpoint should accept this key.
+
+
+
+
+ + +
+
+
+
+
Active keys
-
3 active · 1 revoked
+
3 active · 1 revoked