Split MCP and approval agent keys

This commit is contained in:
github-ops
2026-06-24 10:31:52 +00:00
parent d739f17393
commit 5922aea68f
16 changed files with 471 additions and 56 deletions
+12 -2
View File
@@ -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<PlatformApiKeyScope>,
#[serde(default)]
pub expires_at: Option<String>,
#[serde(default)]
pub allowed_origins: Vec<String>,
}
fn default_platform_api_key_kind() -> PlatformApiKeyKind {
PlatformApiKeyKind::McpClient
}
#[derive(Clone, Debug, Serialize)]
+56 -3
View File
@@ -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(())
}
+5 -2
View File
@@ -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?
@@ -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);