diff --git a/apps/admin-api/src/dto.rs b/apps/admin-api/src/dto.rs index 06de9e8..14cb8ff 100644 --- a/apps/admin-api/src/dto.rs +++ b/apps/admin-api/src/dto.rs @@ -1,8 +1,8 @@ use crank_core::{ AgentId, AgentStatus, AuthConfig, AuthKind, ExecutionMode, ExportMode, GeneratedDraft, InvocationLevel, InvocationSource, InvocationStatus, OperationSecurityLevel, OperationStatus, - PlatformApiKeyScope, Protocol, SecretKind, Target, UsagePeriod, WizardState, WorkspaceId, - WorkspaceStatus, + PlatformApiKeyKind, PlatformApiKeyScope, Protocol, SecretKind, Target, UsagePeriod, + WizardState, WorkspaceId, WorkspaceStatus, }; use crank_mapping::MappingSet; use crank_registry::{ @@ -211,7 +211,17 @@ pub struct AgentMutationResult { #[derive(Clone, Debug, Deserialize)] pub struct PlatformApiKeyPayload { pub name: String, + #[serde(default = "default_platform_api_key_kind")] + pub key_kind: PlatformApiKeyKind, pub scopes: Vec, + #[serde(default)] + pub expires_at: Option, + #[serde(default)] + pub allowed_origins: Vec, +} + +fn default_platform_api_key_kind() -> PlatformApiKeyKind { + PlatformApiKeyKind::McpClient } #[derive(Clone, Debug, Serialize)] diff --git a/apps/admin-api/src/service/api_keys.rs b/apps/admin-api/src/service/api_keys.rs index 2f72a87..d92c300 100644 --- a/apps/admin-api/src/service/api_keys.rs +++ b/apps/admin-api/src/service/api_keys.rs @@ -1,7 +1,10 @@ -use crank_core::{AgentId, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyStatus, WorkspaceId}; +use crank_core::{ + AgentId, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind, PlatformApiKeyScope, + PlatformApiKeyStatus, WorkspaceId, +}; use crank_registry::{CreatePlatformApiKeyRequest, PlatformApiKeyRecord}; use serde_json::json; -use time::OffsetDateTime; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tracing::instrument; use crate::{ @@ -53,7 +56,19 @@ impl AdminService { ) })?; - let secret = generate_access_secret("crk"); + validate_platform_api_key_payload(&payload)?; + + let expires_at = match payload.expires_at.as_deref() { + Some(value) => Some( + OffsetDateTime::parse(value, &Rfc3339) + .map_err(|_| ApiError::validation("expires_at must be RFC3339 timestamp"))?, + ), + None => None, + }; + let secret = generate_access_secret(match payload.key_kind { + PlatformApiKeyKind::McpClient => "crk", + PlatformApiKeyKind::Approval => "crk_appr", + }); let api_key = PlatformApiKeyRecord { api_key: PlatformApiKey { id: PlatformApiKeyId::new(new_prefixed_id("pk")), @@ -61,10 +76,13 @@ impl AdminService { agent_id: Some(agent_id.clone()), name: payload.name, prefix: secret.chars().take(16).collect(), + key_kind: payload.key_kind, scopes: payload.scopes, status: PlatformApiKeyStatus::Active, created_at: OffsetDateTime::now_utc(), last_used_at: None, + expires_at, + allowed_origins: payload.allowed_origins, }, }; @@ -109,3 +127,38 @@ impl AdminService { Ok(()) } } + +fn validate_platform_api_key_payload(payload: &PlatformApiKeyPayload) -> Result<(), ApiError> { + if payload.name.trim().is_empty() { + return Err(ApiError::validation("key name is required")); + } + if payload.scopes.is_empty() { + return Err(ApiError::validation("at least one key scope is required")); + } + + let valid = payload.scopes.iter().all(|scope| match payload.key_kind { + PlatformApiKeyKind::McpClient => matches!( + scope, + PlatformApiKeyScope::Read | PlatformApiKeyScope::Write | PlatformApiKeyScope::Deploy + ), + PlatformApiKeyKind::Approval => matches!( + scope, + PlatformApiKeyScope::Approve + | PlatformApiKeyScope::Deny + | PlatformApiKeyScope::ReadPending + ), + }); + if !valid { + return Err(ApiError::validation( + "key scopes do not match selected key kind", + )); + } + + if payload.key_kind == PlatformApiKeyKind::Approval && payload.allowed_origins.len() > 20 { + return Err(ApiError::validation( + "approval key can contain at most 20 allowed origins", + )); + } + + Ok(()) +} diff --git a/apps/admin-api/src/service/demo.rs b/apps/admin-api/src/service/demo.rs index 6f91072..5b7dd52 100644 --- a/apps/admin-api/src/service/demo.rs +++ b/apps/admin-api/src/service/demo.rs @@ -2,8 +2,8 @@ use std::collections::BTreeMap; use crank_core::{ AgentId, InvocationLevel, InvocationSource, InvocationStatus, MembershipRole, OperationId, - OperationSecurityLevel, PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, Target, - WizardState, WorkspaceId, + OperationSecurityLevel, PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus, + Protocol, Target, WizardState, WorkspaceId, }; use crank_mapping::{JsonPathRoot, infer_mapping_from_samples}; use crank_mapping::{MappingRule, MappingSet}; @@ -162,7 +162,10 @@ impl AdminService { agent_id, PlatformApiKeyPayload { name: name.to_owned(), + key_kind: PlatformApiKeyKind::McpClient, scopes, + expires_at: None, + allowed_origins: Vec::new(), }, ) .await? diff --git a/apps/admin-api/tests/integration/community_access_usage.rs b/apps/admin-api/tests/integration/community_access_usage.rs index 0eaf80b..9a0ac3b 100644 --- a/apps/admin-api/tests/integration/community_access_usage.rs +++ b/apps/admin-api/tests/integration/community_access_usage.rs @@ -153,6 +153,7 @@ async fn manages_agent_platform_api_keys() { .post(format!("{base_url}/agents/{agent_id}/platform-api-keys")) .json(&json!({ "name": "sales-routing-primary", + "key_kind": "mcp_client", "scopes": ["read", "write"] })) .send() @@ -164,6 +165,31 @@ async fn manages_agent_platform_api_keys() { .as_str() .unwrap() .to_owned(); + let created_approval_key = assert_success_json( + client + .post(format!("{base_url}/agents/{agent_id}/platform-api-keys")) + .json(&json!({ + "name": "sales-routing-approver", + "key_kind": "approval", + "scopes": ["approve", "deny"], + "allowed_origins": ["https://client.example.test"] + })) + .send() + .await + .unwrap(), + ) + .await; + let invalid_mixed_scope_status = client + .post(format!("{base_url}/agents/{agent_id}/platform-api-keys")) + .json(&json!({ + "name": "invalid-mixed-scope", + "key_kind": "approval", + "scopes": ["read", "approve"] + })) + .send() + .await + .unwrap() + .status(); let listed_keys = assert_success_json( client @@ -192,14 +218,27 @@ async fn manages_agent_platform_api_keys() { .status(); assert_eq!(created_key["api_key"]["api_key"]["agent_id"], agent_id); + assert_eq!(created_key["api_key"]["api_key"]["key_kind"], "mcp_client"); + assert_eq!( + created_approval_key["api_key"]["api_key"]["key_kind"], + "approval" + ); + assert!( + created_approval_key["secret"] + .as_str() + .unwrap() + .starts_with("crk_appr_") + ); + assert_eq!( + created_approval_key["api_key"]["api_key"]["allowed_origins"], + json!(["https://client.example.test"]) + ); + assert_eq!(invalid_mixed_scope_status, reqwest::StatusCode::BAD_REQUEST); assert_eq!( listed_keys["items"][0]["api_key"]["agent_id"], json!(agent_id) ); - assert_eq!( - listed_keys["items"][0]["api_key"]["name"], - "sales-routing-primary" - ); + assert_eq!(listed_keys["items"].as_array().unwrap().len(), 2); assert!(created_key["secret"].as_str().unwrap().starts_with("crk_")); assert_eq!(revoke_status, reqwest::StatusCode::NO_CONTENT); assert_eq!(delete_status, reqwest::StatusCode::NO_CONTENT); diff --git a/apps/mcp-server/tests/integration/catalog_access.rs b/apps/mcp-server/tests/integration/catalog_access.rs index cc0acde..6c65487 100644 --- a/apps/mcp-server/tests/integration/catalog_access.rs +++ b/apps/mcp-server/tests/integration/catalog_access.rs @@ -476,6 +476,38 @@ async fn rejects_initialize_without_platform_api_key() { assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); } +#[tokio::test] +async fn rejects_initialize_with_approval_platform_api_key() { + let registry = test_registry().await; + publish_agent_with_bindings(®istry, "sales-approval-key", vec![]).await; + let api_key = + create_approval_platform_api_key(®istry, "sales-approval-key", "approval-only").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-approval-key")) + .header(header::ACCEPT, "application/json, text/event-stream") + .header(header::AUTHORIZATION, format!("Bearer {api_key}")) + .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_tool_call_with_read_only_platform_api_key() { let registry = test_registry().await; diff --git a/apps/mcp-server/tests/integration/common.rs b/apps/mcp-server/tests/integration/common.rs index 57a8361..a0d3f5c 100644 --- a/apps/mcp-server/tests/integration/common.rs +++ b/apps/mcp-server/tests/integration/common.rs @@ -16,8 +16,9 @@ use axum::{ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use crank_core::{ Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ExecutionConfig, HttpMethod, - Operation, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, - PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription, WorkspaceId, + Operation, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind, + PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription, + WorkspaceId, }; use crank_mapping::{MappingRule, MappingSet}; use crank_registry::{ @@ -236,17 +237,55 @@ pub(super) async fn create_platform_api_key( name: &str, scopes: &[PlatformApiKeyScope], ) -> String { - let secret = format!("crk_{}_{}", name, uuid::Uuid::now_v7().simple()); + create_platform_api_key_with_kind( + registry, + agent_slug, + name, + PlatformApiKeyKind::McpClient, + scopes, + "crk", + ) + .await +} + +pub(super) async fn create_approval_platform_api_key( + registry: &PostgresRegistry, + agent_slug: &str, + name: &str, +) -> String { + create_platform_api_key_with_kind( + registry, + agent_slug, + name, + PlatformApiKeyKind::Approval, + &[PlatformApiKeyScope::Approve, PlatformApiKeyScope::Deny], + "crk_appr", + ) + .await +} + +async fn create_platform_api_key_with_kind( + registry: &PostgresRegistry, + agent_slug: &str, + name: &str, + key_kind: PlatformApiKeyKind, + scopes: &[PlatformApiKeyScope], + prefix: &str, +) -> String { + let secret = format!("{prefix}_{}_{}", name, uuid::Uuid::now_v7().simple()); let api_key = PlatformApiKey { id: PlatformApiKeyId::new(format!("pk_{name}")), workspace_id: test_workspace_id(), agent_id: Some(test_agent_id(agent_slug)), + key_kind, name: name.to_owned(), prefix: secret.chars().take(16).collect(), scopes: scopes.to_vec(), status: PlatformApiKeyStatus::Active, created_at: OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), last_used_at: None, + expires_at: None, + allowed_origins: Vec::new(), }; registry diff --git a/apps/ui/html/api-keys.html b/apps/ui/html/api-keys.html index fefbe21..523ed66 100644 --- a/apps/ui/html/api-keys.html +++ b/apps/ui/html/api-keys.html @@ -23,9 +23,48 @@ .scope-checkbox-name { font-size: 13px; font-weight: 500; color: var(--text-primary); } .scope-checkbox-desc { font-size: 11.5px; color: var(--text-muted); } .keys-card-list { display: none; } + .key-kind-tabs { + display: inline-flex; + gap: 4px; + padding: 4px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg-overlay); + } + .key-kind-tab { + border: 0; + border-radius: var(--radius); + background: transparent; + color: var(--text-secondary); + padding: 7px 12px; + font-size: 13px; + font-weight: 600; + } + .key-kind-tab.active { + background: var(--accent); + color: #fff; + } + .approval-warning-callout { + display: flex; + gap: 10px; + padding: 12px 14px; + border: 1px solid var(--amber-border); + border-radius: var(--radius-lg); + background: var(--amber-bg); + color: var(--text-primary); + font-size: 13px; + line-height: 1.55; + } + .approval-warning-callout svg { + color: var(--amber); + flex: 0 0 auto; + margin-top: 2px; + } @media (max-width: 720px) { #keys-table-wrap { display: none; } .keys-card-list { display: grid; } + .key-kind-tabs { width: 100%; } + .key-kind-tab { flex: 1; } } @@ -102,7 +141,7 @@
@@ -118,6 +157,16 @@ +
+
+
+ + +
+
+
+
+
@@ -170,25 +219,7 @@
Access reference
-
-
-
- read -
-
Initialize MCP sessions, ping the server, and list tools for a workspace agent.
-
-
-
- write -
-
Execute `tools/call` requests against published agent toolsets.
-
-
-
- deploy -
-
Reserved for deploy-scoped automation. Today it also permits MCP read/write flows.
-
+
@@ -198,7 +229,7 @@