feat: complete Epic 1 production foundation
This commit is contained in:
@@ -6,12 +6,14 @@ 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::{
|
||||
AdminService, CreatedPlatformApiKeyResponse, PlatformApiKeyPayload, generate_access_secret,
|
||||
hash_access_secret, new_prefixed_id,
|
||||
AdminAuditContext, AdminService, CreatedPlatformApiKeyResponse, CredentialAuditRecord,
|
||||
EphemeralMcpClientConfig, EphemeralMcpConnection, PlatformApiKeyPayload,
|
||||
generate_access_secret, hash_access_secret, new_prefixed_id,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -38,32 +40,84 @@ impl AdminService {
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_name = %payload.name))]
|
||||
#[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,
|
||||
payload: PlatformApiKeyPayload,
|
||||
mut payload: PlatformApiKeyPayload,
|
||||
audit_context: Option<&AdminAuditContext>,
|
||||
) -> Result<CreatedPlatformApiKeyResponse, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
self.registry
|
||||
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_or_else(|| {
|
||||
.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);
|
||||
}
|
||||
};
|
||||
|
||||
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 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 {
|
||||
@@ -83,55 +137,213 @@ impl AdminService {
|
||||
},
|
||||
};
|
||||
|
||||
self.registry
|
||||
if let Err(error) = self
|
||||
.registry
|
||||
.create_platform_api_key(CreatePlatformApiKeyRequest {
|
||||
api_key: &api_key.api_key,
|
||||
secret_hash: &hash_access_secret(&secret),
|
||||
})
|
||||
.await?;
|
||||
.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;
|
||||
|
||||
Ok(CreatedPlatformApiKeyResponse { api_key, secret })
|
||||
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), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_id = %key_id.as_str()))]
|
||||
#[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> {
|
||||
self.registry
|
||||
if let Err(error) = self
|
||||
.registry
|
||||
.revoke_platform_api_key_for_agent(
|
||||
workspace_id,
|
||||
agent_id,
|
||||
key_id,
|
||||
&OffsetDateTime::now_utc(),
|
||||
)
|
||||
.await?;
|
||||
.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), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_id = %key_id.as_str()))]
|
||||
#[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> {
|
||||
self.registry
|
||||
if let Err(error) = self
|
||||
.registry
|
||||
.delete_platform_api_key_for_agent(workspace_id, agent_id, key_id)
|
||||
.await?;
|
||||
.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 validate_platform_api_key_payload(payload: &PlatformApiKeyPayload) -> Result<(), ApiError> {
|
||||
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!(
|
||||
@@ -156,6 +368,66 @@ fn validate_platform_api_key_payload(payload: &PlatformApiKeyPayload) -> Result<
|
||||
"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",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user