434 lines
16 KiB
Rust
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())
|
|
}
|