Files
crank/apps/admin-api/src/service/api_keys.rs
T

434 lines
16 KiB
Rust

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<Vec<PlatformApiKeyRecord>, 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<CreatedPlatformApiKeyResponse, ApiError> {
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<EphemeralMcpClientConfig> {
["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<Option<OffsetDateTime>, 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<String, ApiError> {
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())
}