use crank_core::{ AgentId, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus, WorkspaceId, }; use crank_registry::{CreatePlatformApiKeyRequest, PlatformApiKeyRecord}; use serde_json::json; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tracing::instrument; use url::Url; use crate::{ error::ApiError, service::{ AdminAuditContext, AdminService, CreatedPlatformApiKeyResponse, CredentialAuditRecord, EphemeralMcpClientConfig, EphemeralMcpConnection, PlatformApiKeyPayload, generate_access_secret, hash_access_secret, new_prefixed_id, }, }; impl AdminService { #[instrument(skip(self))] pub async fn list_agent_platform_api_keys( &self, workspace_id: &WorkspaceId, agent_id: &AgentId, ) -> Result, ApiError> { 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() }), ) })?; Ok(self .registry .list_platform_api_keys_for_agent(workspace_id, agent_id) .await?) } #[instrument(skip(self, payload, audit_context), 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, mut payload: PlatformApiKeyPayload, audit_context: Option<&AdminAuditContext>, ) -> Result { let workspace = match self.get_workspace(workspace_id).await { Ok(workspace) => workspace, Err(error) => { self.record_credential_audit( audit_context, CredentialAuditRecord { action: "credential.platform_api_key.create_failed", target_kind: crank_core::AuditTargetKind::PlatformApiKey, workspace_id, target_id: "pending", credential_type: "platform_api_key", outcome: "failure", reason: error.code(), }, ) .await; return Err(error); } }; let agent_result = match self .registry .get_agent_summary(workspace_id, agent_id) .await { Ok(agent) => agent.ok_or_else(|| { ApiError::not_found_with_context( format!("agent {} was not found", agent_id.as_str()), json!({ "agent_id": agent_id.as_str() }), ) }), Err(error) => Err(ApiError::from(error)), }; let agent = match agent_result { Ok(agent) => agent, Err(error) => { self.record_credential_audit( audit_context, CredentialAuditRecord { action: "credential.platform_api_key.create_failed", target_kind: crank_core::AuditTargetKind::PlatformApiKey, workspace_id, target_id: "pending", credential_type: "platform_api_key", outcome: "failure", reason: error.code(), }, ) .await; return Err(error); } }; let expires_at = match validate_platform_api_key_payload(&mut payload) { Ok(expires_at) => expires_at, Err(error) => { self.record_credential_audit( audit_context, CredentialAuditRecord { action: "credential.platform_api_key.create_failed", target_kind: crank_core::AuditTargetKind::PlatformApiKey, workspace_id, target_id: "pending", credential_type: payload.key_kind.audit_credential_type(), outcome: "failure", reason: error.code(), }, ) .await; return Err(error); } }; let secret = generate_access_secret(payload.key_kind.secret_marker()); 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(), 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, }, }; if let Err(error) = self .registry .create_platform_api_key(CreatePlatformApiKeyRequest { api_key: &api_key.api_key, secret_hash: &hash_access_secret(&secret), }) .await { let error = ApiError::from(error); self.record_credential_audit( audit_context, CredentialAuditRecord { action: "credential.platform_api_key.create_failed", target_kind: crank_core::AuditTargetKind::PlatformApiKey, workspace_id, target_id: api_key.api_key.id.as_str(), credential_type: api_key.api_key.key_kind.audit_credential_type(), outcome: "failure", reason: error.code(), }, ) .await; return Err(error); } self.record_credential_audit( audit_context, CredentialAuditRecord { action: "credential.platform_api_key.created", target_kind: crank_core::AuditTargetKind::PlatformApiKey, workspace_id, target_id: api_key.api_key.id.as_str(), credential_type: api_key.api_key.key_kind.audit_credential_type(), outcome: "success", reason: "credential_created", }, ) .await; let connection = (api_key.api_key.key_kind == PlatformApiKeyKind::McpClient).then(|| { let endpoint = self.public_agent_mcp_endpoint(&workspace.workspace.slug, &agent.slug); EphemeralMcpConnection { endpoint: endpoint.clone(), clients: ephemeral_client_configs(&endpoint, &secret), secret_display: "once", } }); Ok(CreatedPlatformApiKeyResponse { api_key, secret, connection, }) } #[instrument(skip(self, audit_context), 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, audit_context: Option<&AdminAuditContext>, ) -> Result<(), ApiError> { if let Err(error) = self .registry .revoke_platform_api_key_for_agent( workspace_id, agent_id, key_id, &OffsetDateTime::now_utc(), ) .await { let error = ApiError::from(error); self.record_credential_audit( audit_context, CredentialAuditRecord { action: "credential.platform_api_key.revoke_failed", target_kind: crank_core::AuditTargetKind::PlatformApiKey, workspace_id, target_id: key_id.as_str(), credential_type: "platform_api_key", outcome: "failure", reason: error.code(), }, ) .await; return Err(error); } self.record_credential_audit( audit_context, CredentialAuditRecord { action: "credential.platform_api_key.revoked", target_kind: crank_core::AuditTargetKind::PlatformApiKey, workspace_id, target_id: key_id.as_str(), credential_type: "platform_api_key", outcome: "success", reason: "credential_revoked", }, ) .await; Ok(()) } #[instrument(skip(self, audit_context), 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, audit_context: Option<&AdminAuditContext>, ) -> Result<(), ApiError> { if let Err(error) = self .registry .delete_platform_api_key_for_agent(workspace_id, agent_id, key_id) .await { let error = ApiError::from(error); self.record_credential_audit( audit_context, CredentialAuditRecord { action: "credential.platform_api_key.delete_failed", target_kind: crank_core::AuditTargetKind::PlatformApiKey, workspace_id, target_id: key_id.as_str(), credential_type: "platform_api_key", outcome: "failure", reason: error.code(), }, ) .await; return Err(error); } self.record_credential_audit( audit_context, CredentialAuditRecord { action: "credential.platform_api_key.deleted", target_kind: crank_core::AuditTargetKind::PlatformApiKey, workspace_id, target_id: key_id.as_str(), credential_type: "platform_api_key", outcome: "success", reason: "credential_deleted", }, ) .await; Ok(()) } } fn ephemeral_client_configs(endpoint: &str, secret: &str) -> Vec { ["claude_desktop", "cursor", "vscode"] .into_iter() .map(|client| EphemeralMcpClientConfig { client: client.to_owned(), config: json!({ "transport": "streamable_http", "url": endpoint, "headers": {"Authorization": format!("Bearer {secret}")} }), }) .collect() } trait PlatformApiKeyKindAuditExt { fn audit_credential_type(self) -> &'static str; } impl PlatformApiKeyKindAuditExt for PlatformApiKeyKind { fn audit_credential_type(self) -> &'static str { match self { PlatformApiKeyKind::McpClient => "mcp_client_key", PlatformApiKeyKind::Approval => "approval_key", } } } fn validate_platform_api_key_payload( payload: &mut PlatformApiKeyPayload, ) -> Result, ApiError> { const MAX_KEY_NAME_CHARS: usize = 128; const MAX_SCOPES: usize = 8; payload.name = payload.name.trim().to_owned(); if payload.name.trim().is_empty() { return Err(ApiError::validation("key name is required")); } if payload.name.chars().count() > MAX_KEY_NAME_CHARS { return Err(ApiError::validation( "key name must be at most 128 characters", )); } if payload.scopes.is_empty() { return Err(ApiError::validation("at least one key scope is required")); } if payload.scopes.len() > MAX_SCOPES { return Err(ApiError::validation("too many key scopes")); } if payload .scopes .iter() .enumerate() .any(|(index, scope)| payload.scopes[..index].contains(scope)) { return Err(ApiError::validation( "key scopes must not contain duplicates", )); } 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", )); } if payload.key_kind == PlatformApiKeyKind::Approval { let mut normalized_origins = Vec::with_capacity(payload.allowed_origins.len()); for origin in &payload.allowed_origins { normalized_origins.push(validate_approval_origin(origin)?); } if normalized_origins .iter() .enumerate() .any(|(index, origin)| normalized_origins[..index].contains(origin)) { return Err(ApiError::validation( "allowed origins must not contain duplicates", )); } payload.allowed_origins = normalized_origins; } else if !payload.allowed_origins.is_empty() { return Err(ApiError::validation( "allowed origins are only supported for approval keys", )); } let expires_at = payload .expires_at .as_deref() .map(|value| { OffsetDateTime::parse(value, &Rfc3339) .map_err(|_| ApiError::validation("expires_at must be RFC3339 timestamp")) }) .transpose()?; if expires_at.is_some_and(|value| value <= OffsetDateTime::now_utc()) { return Err(ApiError::validation("expires_at must be in the future")); } Ok(expires_at) } fn validate_approval_origin(origin: &str) -> Result { const MAX_ORIGIN_LEN: usize = 2048; if origin.is_empty() || origin.len() > MAX_ORIGIN_LEN { return Err(ApiError::validation("allowed origin is invalid")); } if origin .bytes() .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace()) { return Err(ApiError::validation("allowed origin is invalid")); } let parsed = Url::parse(origin).map_err(|_| ApiError::validation("allowed origin is invalid"))?; if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() || !parsed.username().is_empty() || parsed.password().is_some() || parsed.path() != "/" || parsed.query().is_some() || parsed.fragment().is_some() { return Err(ApiError::validation("allowed origin is invalid")); } Ok(parsed.origin().ascii_serialization()) }