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

1395 lines
49 KiB
Rust

use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use crank_artifacts::ArtifactStore;
use crank_config::ExternalReferenceSettings;
use crank_core::{
AuditActor, AuditEvent, AuditEventId, AuditSink, AuditTarget, AuditTargetKind, AuthProfile,
CapabilityProfile, CommunityCapabilityProfile, CorrelationContext, EditionCapabilities,
ExecutionMode, IdentityError, IdentityProvider, InvocationLog, InvocationLogId,
InvocationSource, NoopAuditSink, OperationSecurityLevel, OwnerOnlyPolicyEngine, PolicyEngine,
ProductEdition, Protocol, SecretStatus, ToolQualityMappingRule, ToolQualityMappingSet,
ToolQualitySchemaKind, ToolQualitySchemaNode, UsagePeriod, WorkspaceId,
};
use crank_mapping::{MappingRule, MappingSet};
use crank_registry::{
AgentSummary, CreateInvocationLogRequest, InvocationHistoryWriteOutcome, OperationAgentRef,
OperationSummary, OperationUsageSummary, PostgresRegistry, RegistryError, RegistryOperation,
UsageBucket,
};
use crank_runtime::{
ExternalReferenceFetcher, OutboundHttpPolicy, ResolvedAuth, RuntimeError, RuntimeExecutor,
SecretCrypto,
};
use crank_schema::{Schema, SchemaKind};
use crank_trace::{DbOperation, ErrorCategory, Stage, StageOutcome, observe_db_query};
use rand::RngExt;
use serde_json::json;
use sha2::{Digest, Sha256};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tracing::Instrument;
use uuid::Uuid;
mod agents;
mod api_keys;
mod auth;
mod demo;
mod import_export;
mod imports;
mod observability;
mod onboarding;
mod operation_validation;
mod operations;
mod samples;
mod secrets;
mod upstreams;
mod workspaces;
use crate::{
auth::{AuthSettings, AuthenticatedSession},
error::ApiError,
storage::LocalArtifactStorage,
};
use operation_validation::{
validate_approval_policy, validate_execution_timeout, validate_idempotency_policy,
validate_protocol_target, validate_response_cache_policy,
};
#[derive(Clone)]
pub struct AdminService {
registry: PostgresRegistry,
artifact_store: Arc<ArtifactStore>,
runtime: RuntimeExecutor,
storage: LocalArtifactStorage,
auth_settings: AuthSettings,
secret_crypto: SecretCrypto,
identity_provider: Option<Arc<dyn IdentityProvider>>,
policy_engine: Arc<dyn PolicyEngine>,
audit_sink: Arc<dyn AuditSink>,
capability_profile: Arc<dyn CapabilityProfile>,
outbound_http_policy: OutboundHttpPolicy,
external_reference_fetcher: ExternalReferenceFetcher,
external_reference_normalization: crank_import::rest::NormalizationConfig,
external_reference_materialization_timeout: std::time::Duration,
public_base_url: String,
}
pub struct AdminServiceBuilder {
registry: PostgresRegistry,
storage_root: PathBuf,
artifact_store: Option<Arc<ArtifactStore>>,
auth_settings: AuthSettings,
secret_crypto: SecretCrypto,
runtime: RuntimeExecutor,
identity_provider: Option<Arc<dyn IdentityProvider>>,
policy_engine: Option<Arc<dyn PolicyEngine>>,
audit_sink: Option<Arc<dyn AuditSink>>,
capability_profile: Option<Arc<dyn CapabilityProfile>>,
outbound_http_policy: OutboundHttpPolicy,
external_reference_fetcher: ExternalReferenceFetcher,
external_reference_normalization: crank_import::rest::NormalizationConfig,
external_reference_settings: ExternalReferenceSettings,
public_base_url: String,
}
pub use crate::dto::*;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ReadinessChecks {
pub postgres: bool,
pub artifact_storage: bool,
}
impl ReadinessChecks {
pub const fn is_ready(self) -> bool {
self.postgres && self.artifact_storage
}
}
#[derive(Clone, Debug)]
pub struct AdminAuditContext {
actor: AuditActor,
request_id: String,
trace_id: String,
}
impl AdminAuditContext {
pub fn from_session_and_correlation(
session: &AuthenticatedSession,
correlation: &CorrelationContext,
) -> Self {
Self {
actor: AuditActor {
user_id: session.user.id.clone(),
email: session.user.email.clone(),
session_id: Some(session.session_id.clone()),
},
request_id: correlation.request_id().as_str().to_owned(),
trace_id: correlation.trace_id().as_str().to_owned(),
}
}
}
#[derive(Clone, Copy, Debug)]
pub(super) struct CredentialAuditRecord<'a> {
pub action: &'static str,
pub target_kind: AuditTargetKind,
pub workspace_id: &'a WorkspaceId,
pub target_id: &'a str,
pub credential_type: &'static str,
pub outcome: &'static str,
pub reason: &'a str,
}
impl AdminService {
pub async fn readiness(&self) -> ReadinessChecks {
let postgres = self.registry.ping().await.is_ok();
let store = Arc::clone(&self.artifact_store);
let artifact_storage = tokio::task::spawn_blocking(move || store.check_health())
.await
.is_ok_and(|result| result.is_ok());
ReadinessChecks {
postgres,
artifact_storage,
}
}
#[cfg(test)]
pub fn new(
registry: PostgresRegistry,
storage_root: PathBuf,
auth_settings: AuthSettings,
secret_crypto: SecretCrypto,
) -> Self {
Self::new_with_runtime(
registry,
storage_root,
auth_settings,
secret_crypto,
RuntimeExecutor::new(),
)
}
#[cfg(test)]
pub fn new_with_runtime(
registry: PostgresRegistry,
storage_root: PathBuf,
auth_settings: AuthSettings,
secret_crypto: SecretCrypto,
runtime: RuntimeExecutor,
) -> Self {
AdminServiceBuilder::new(
registry,
storage_root,
auth_settings,
secret_crypto,
runtime,
)
.build()
}
pub fn auth_settings(&self) -> &AuthSettings {
&self.auth_settings
}
pub fn policy_engine(&self) -> &Arc<dyn PolicyEngine> {
&self.policy_engine
}
pub fn audit_sink(&self) -> &Arc<dyn AuditSink> {
&self.audit_sink
}
pub fn capability_profile(&self) -> &Arc<dyn CapabilityProfile> {
&self.capability_profile
}
}
fn runtime_credential_error_code(error: &RuntimeError) -> &'static str {
match error {
RuntimeError::AuthorizationStoreUnavailable => "authorization_store_unavailable",
RuntimeError::MissingAuthProfile { .. } => "auth_profile_not_found",
RuntimeError::InvalidAuthProfileConfig { .. } => "auth_profile_invalid",
RuntimeError::MissingSecret { .. } => "secret_not_found",
RuntimeError::MissingSecretVersion { .. } => "secret_version_not_found",
RuntimeError::InvalidAuthSecretValue { .. } => "secret_invalid",
RuntimeError::SecretCrypto { .. } => "secret_crypto_failed",
_ => "credential_resolution_failed",
}
}
impl AdminServiceBuilder {
pub fn new(
registry: PostgresRegistry,
storage_root: PathBuf,
auth_settings: AuthSettings,
secret_crypto: SecretCrypto,
runtime: RuntimeExecutor,
) -> Self {
let outbound_http_policy = OutboundHttpPolicy::default();
let external_reference_settings = ExternalReferenceSettings {
allowed_url_prefixes: Vec::new(),
max_depth: 8,
max_documents: 32,
max_fetch_bytes: 256 * 1024,
fetch_timeout_ms: 5_000,
max_expanded_nodes: 10_000,
};
let external_reference_fetcher = ExternalReferenceFetcher::try_new(
outbound_http_policy.clone(),
external_reference_settings.allowed_url_prefixes.clone(),
external_reference_settings.max_fetch_bytes,
std::time::Duration::from_millis(external_reference_settings.fetch_timeout_ms),
)
.expect("static external reference defaults are valid");
Self {
registry,
storage_root,
artifact_store: None,
auth_settings,
secret_crypto,
runtime,
identity_provider: None,
policy_engine: None,
audit_sink: None,
capability_profile: None,
outbound_http_policy,
external_reference_fetcher,
external_reference_normalization: Default::default(),
external_reference_settings,
public_base_url: "http://localhost:3000".to_owned(),
}
}
pub fn with_identity_provider(mut self, provider: Arc<dyn IdentityProvider>) -> Self {
self.identity_provider = Some(provider);
self
}
pub fn with_outbound_http_policy(mut self, policy: OutboundHttpPolicy) -> Self {
self.external_reference_fetcher = ExternalReferenceFetcher::try_new(
policy.clone(),
self.external_reference_settings
.allowed_url_prefixes
.clone(),
self.external_reference_settings.max_fetch_bytes,
std::time::Duration::from_millis(self.external_reference_settings.fetch_timeout_ms),
)
.expect("validated external reference settings remain valid");
self.outbound_http_policy = policy;
self
}
pub fn with_external_reference_import(
mut self,
settings: &ExternalReferenceSettings,
) -> Result<Self, crank_runtime::ExternalReferenceFetchError> {
self.external_reference_fetcher = ExternalReferenceFetcher::try_new(
self.outbound_http_policy.clone(),
settings.allowed_url_prefixes.clone(),
settings.max_fetch_bytes,
std::time::Duration::from_millis(settings.fetch_timeout_ms),
)?;
self.external_reference_normalization.max_reference_depth = settings.max_depth;
self.external_reference_normalization
.max_reference_documents = settings.max_documents;
self.external_reference_normalization
.max_external_document_bytes = settings.max_fetch_bytes;
self.external_reference_normalization.max_expanded_nodes = settings.max_expanded_nodes;
self.external_reference_normalization
.external_references_enabled = !settings.allowed_url_prefixes.is_empty();
self.external_reference_settings = settings.clone();
Ok(self)
}
/// Reuses the process-wide immutable artifact authority for OpenAPI
/// ingress and reconciliation. Tests may omit this and use their private
/// storage root instead.
pub fn with_artifact_store(mut self, artifact_store: Arc<ArtifactStore>) -> Self {
self.artifact_store = Some(artifact_store);
self
}
pub fn with_public_base_url(mut self, public_base_url: String) -> Self {
self.public_base_url = public_base_url.trim_end_matches('/').to_owned();
self
}
#[allow(dead_code)]
pub fn with_policy_engine(mut self, policy_engine: Arc<dyn PolicyEngine>) -> Self {
self.policy_engine = Some(policy_engine);
self
}
#[allow(dead_code)]
pub fn with_audit_sink(mut self, audit_sink: Arc<dyn AuditSink>) -> Self {
self.audit_sink = Some(audit_sink);
self
}
#[allow(dead_code)]
pub fn with_capability_profile(
mut self,
capability_profile: Arc<dyn CapabilityProfile>,
) -> Self {
self.capability_profile = Some(capability_profile);
self
}
pub fn build(self) -> AdminService {
AdminService {
registry: self.registry,
artifact_store: self
.artifact_store
.unwrap_or_else(|| Arc::new(ArtifactStore::new(&self.storage_root))),
runtime: self.runtime,
storage: LocalArtifactStorage::new(self.storage_root),
auth_settings: self.auth_settings,
secret_crypto: self.secret_crypto,
identity_provider: self.identity_provider,
policy_engine: self
.policy_engine
.unwrap_or_else(|| Arc::new(OwnerOnlyPolicyEngine)),
audit_sink: self.audit_sink.unwrap_or_else(|| Arc::new(NoopAuditSink)),
capability_profile: self
.capability_profile
.unwrap_or_else(|| Arc::new(CommunityCapabilityProfile)),
outbound_http_policy: self.outbound_http_policy,
external_reference_fetcher: self.external_reference_fetcher,
external_reference_normalization: self.external_reference_normalization,
external_reference_materialization_timeout: std::time::Duration::from_millis(
self.external_reference_settings.fetch_timeout_ms,
),
public_base_url: self.public_base_url,
}
}
}
impl AdminService {
pub async fn export_workspace_catalog_snapshot(
&self,
workspace_id: &WorkspaceId,
) -> Result<WorkspaceCatalogSnapshotResponse, ApiError> {
let workspace = self.get_workspace(workspace_id).await?;
let operations = self.list_operations(workspace_id).await?;
let agents = self.list_agents(workspace_id).await?;
let platform_api_keys = self.registry.list_platform_api_keys(workspace_id).await?;
Ok(WorkspaceCatalogSnapshotResponse {
kind: "workspace_catalog_snapshot".to_owned(),
format_version: "1".to_owned(),
restorable: false,
included: vec![
"workspace_settings".to_owned(),
"operation_summaries".to_owned(),
"agent_summaries".to_owned(),
"platform_api_key_metadata".to_owned(),
],
excluded: vec![
"operation_versions_and_samples".to_owned(),
"agent_versions_and_bindings".to_owned(),
"secret_metadata_and_values".to_owned(),
"secret_values".to_owned(),
"invocation_logs_and_usage".to_owned(),
"authentication_sessions".to_owned(),
],
workspace,
operations,
agents,
platform_api_keys,
exported_at: now_string()?,
})
}
pub async fn list_protocol_capabilities(&self) -> Vec<ProtocolCapabilityView> {
let capabilities = self.get_capabilities().await;
capabilities
.supported_protocols
.into_iter()
.map(|protocol| protocol_capability_view(protocol, capabilities.edition))
.collect()
}
pub async fn get_capabilities(&self) -> EditionCapabilities {
self.capability_profile().capabilities()
}
async fn record_credential_audit(
&self,
audit_context: Option<&AdminAuditContext>,
record: CredentialAuditRecord<'_>,
) {
let Some(audit_context) = audit_context else {
return;
};
let event = AuditEvent {
id: AuditEventId::new(new_prefixed_id("audit")),
occurred_at: OffsetDateTime::now_utc(),
actor: audit_context.actor.clone(),
action: record.action.to_owned(),
target: AuditTarget {
workspace_id: record.workspace_id.clone(),
kind: record.target_kind,
id: record.target_id.to_owned(),
},
payload: json!({
"credential_type": record.credential_type,
"outcome": record.outcome,
"reason": record.reason,
"request_id": audit_context.request_id,
"trace_id": audit_context.trace_id
}),
source_ip: None,
user_agent: None,
};
if self.audit_sink.record(event).await.is_err() {
tracing::warn!(
name: "admin.credential_audit.lost",
action = record.action,
credential_type = record.credential_type,
outcome = record.outcome,
reason = record.reason,
request_id = %audit_context.request_id,
trace_id = %audit_context.trace_id,
error_code = "audit_sink_failed",
"credential audit event was not recorded"
);
}
}
async fn resolve_operation_auth(
&self,
workspace_id: &WorkspaceId,
execution_config: &crank_core::ExecutionConfig,
audit_context: Option<&AdminAuditContext>,
) -> Result<Option<ResolvedAuth>, RuntimeError> {
let Some(auth_profile_id) = execution_config.auth_profile_ref.as_ref() else {
return Ok(None);
};
let span = Stage::AuthResolve.span();
let result = async {
let auth_profile = observe_db_query(
DbOperation::AuthProfileRead,
self.registry
.get_auth_profile(workspace_id, auth_profile_id),
)
.await
.map_err(|_| RuntimeError::AuthorizationStoreUnavailable)?;
let Some(auth_profile) = auth_profile else {
return Err(RuntimeError::MissingAuthProfile {
auth_profile_id: auth_profile_id.as_str().to_owned(),
});
};
self.resolve_auth_profile(workspace_id, &auth_profile, audit_context)
.await
.map(Some)
}
.instrument(span.clone())
.await;
match &result {
Ok(_) => StageOutcome::Success.record(&span),
Err(_) => {
StageOutcome::Error.record(&span);
ErrorCategory::Configuration.record(&span);
}
}
if let Err(error) = &result {
self.record_credential_resolution_failure(
audit_context,
workspace_id,
crank_core::AuditTargetKind::AuthProfile,
auth_profile_id.as_str(),
"auth_profile",
runtime_credential_error_code(error),
)
.await;
}
result
}
async fn resolve_auth_profile(
&self,
workspace_id: &WorkspaceId,
auth_profile: &AuthProfile,
audit_context: Option<&AdminAuditContext>,
) -> Result<ResolvedAuth, RuntimeError> {
let mut secrets = BTreeMap::new();
let used_at = OffsetDateTime::now_utc();
for secret_id in auth_profile.config.secret_ids() {
let secret = observe_db_query(
DbOperation::SecretRead,
self.registry.get_secret(workspace_id, secret_id),
)
.await
.map_err(|_| RuntimeError::AuthorizationStoreUnavailable)?;
let Some(secret) = secret else {
let error = RuntimeError::MissingSecret {
secret_id: secret_id.as_str().to_owned(),
};
self.record_credential_resolution_failure(
audit_context,
workspace_id,
crank_core::AuditTargetKind::Secret,
secret_id.as_str(),
"secret",
runtime_credential_error_code(&error),
)
.await;
return Err(error);
};
if secret.secret.status != SecretStatus::Active {
let error = RuntimeError::InvalidAuthSecretValue {
secret_id: secret_id.as_str().to_owned(),
reason: "secret is not active".to_owned(),
};
self.record_credential_resolution_failure(
audit_context,
workspace_id,
crank_core::AuditTargetKind::Secret,
secret_id.as_str(),
"secret",
runtime_credential_error_code(&error),
)
.await;
return Err(error);
}
let version = observe_db_query(
DbOperation::SecretRead,
self.registry
.get_current_secret_version(workspace_id, secret_id),
)
.await
.map_err(|_| RuntimeError::AuthorizationStoreUnavailable)?;
let Some(version) = version else {
let error = RuntimeError::MissingSecretVersion {
secret_id: secret_id.as_str().to_owned(),
version: secret.secret.current_version,
};
self.record_credential_resolution_failure(
audit_context,
workspace_id,
crank_core::AuditTargetKind::Secret,
secret_id.as_str(),
"secret",
runtime_credential_error_code(&error),
)
.await;
return Err(error);
};
let plaintext = self.secret_crypto.decrypt_for_epoch(
&version.secret_version.key_version,
version.master_key_epoch,
&version.secret_version.ciphertext,
);
let plaintext = match plaintext {
Ok(plaintext) => plaintext,
Err(error) => {
self.record_credential_resolution_failure(
audit_context,
workspace_id,
crank_core::AuditTargetKind::Secret,
secret_id.as_str(),
"secret",
runtime_credential_error_code(&error),
)
.await;
return Err(error);
}
};
observe_db_query(
DbOperation::SecretTouch,
self.registry
.touch_secret(workspace_id, secret_id, &used_at),
)
.await
.unwrap_or_else(|_| {
tracing::warn!(
name: "admin.secret.touch_failed",
secret_id = %secret_id.as_str(),
request_id = audit_context
.map(|context| context.request_id.as_str())
.unwrap_or("unavailable"),
trace_id = audit_context
.map(|context| context.trace_id.as_str())
.unwrap_or("unavailable"),
error_code = "secret_touch_failed",
"secret last-used metadata was not updated"
);
});
secrets.insert(secret_id.clone(), plaintext);
}
let result = ResolvedAuth::from_profile(auth_profile, &secrets);
if let Err(error) = &result {
self.record_credential_resolution_failure(
audit_context,
workspace_id,
crank_core::AuditTargetKind::AuthProfile,
auth_profile.id.as_str(),
"auth_profile",
runtime_credential_error_code(error),
)
.await;
}
result
}
async fn record_credential_resolution_failure(
&self,
audit_context: Option<&AdminAuditContext>,
workspace_id: &WorkspaceId,
target_kind: crank_core::AuditTargetKind,
target_id: &str,
credential_type: &'static str,
reason: &'static str,
) {
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.resolve_denied",
target_kind,
workspace_id,
target_id,
credential_type,
outcome: "failure",
reason,
},
)
.await;
tracing::warn!(
name: "admin.credential.resolve_denied",
credential_type,
target_kind = ?target_kind,
target_id,
outcome = "failure",
reason,
request_id = audit_context
.map(|context| context.request_id.as_str())
.unwrap_or("unavailable"),
trace_id = audit_context
.map(|context| context.trace_id.as_str())
.unwrap_or("unavailable"),
"credential resolution was denied"
);
}
fn validate_operation_payload(&self, payload: &OperationPayload) -> Result<(), ApiError> {
self.validate_operation_capabilities(payload.protocol, payload.security_level)?;
validate_protocol_target(payload.protocol, &payload.target)?;
self.validate_outbound_target(&payload.target)?;
validate_execution_timeout(&payload.execution_config)?;
validate_response_cache_policy(&payload.target, &payload.execution_config)?;
validate_idempotency_policy(&payload.target, &payload.execution_config)?;
validate_approval_policy(&payload.execution_config)?;
payload.input_mapping.validate_paths()?;
payload.output_mapping.validate_paths()?;
Ok(())
}
fn validate_registry_operation(&self, operation: &RegistryOperation) -> Result<(), ApiError> {
self.validate_operation_capabilities(operation.protocol, operation.security_level)?;
validate_protocol_target(operation.protocol, &operation.target)?;
self.validate_outbound_target(&operation.target)?;
validate_execution_timeout(&operation.execution_config)?;
validate_response_cache_policy(&operation.target, &operation.execution_config)?;
validate_idempotency_policy(&operation.target, &operation.execution_config)?;
validate_approval_policy(&operation.execution_config)?;
operation.input_mapping.validate_paths()?;
operation.output_mapping.validate_paths()?;
Ok(())
}
async fn validate_registry_operation_in_workspace(
&self,
workspace_id: &WorkspaceId,
operation: &RegistryOperation,
) -> Result<(), ApiError> {
self.validate_registry_operation(operation)?;
if let Some(auth_profile_id) = operation.execution_config.auth_profile_ref.as_ref() {
let Some(auth_profile) = self
.registry
.get_auth_profile(workspace_id, auth_profile_id)
.await?
else {
return Err(ApiError::unprocessable_with_context(
"operation auth profile reference is unavailable",
json!({ "error_code": "operation_auth_profile_invalid" }),
));
};
self.validate_auth_profile_secret_refs_for_execution(workspace_id, &auth_profile)
.await?;
}
Ok(())
}
async fn validate_auth_profile_secret_refs_for_execution(
&self,
workspace_id: &WorkspaceId,
auth_profile: &AuthProfile,
) -> Result<(), ApiError> {
if auth_profile.kind != auth_profile.config.kind() {
return Err(ApiError::unprocessable_with_context(
"operation auth profile kind and config do not match",
json!({ "error_code": "operation_auth_profile_invalid" }),
));
}
for secret_id in auth_profile.config.secret_ids() {
let Some(secret) = self.registry.get_secret(workspace_id, secret_id).await? else {
return Err(ApiError::unprocessable_with_context(
"operation auth profile secret reference is unavailable",
json!({ "error_code": "operation_auth_profile_invalid" }),
));
};
if secret.secret.status != SecretStatus::Active {
return Err(RegistryError::SecretInactive {
secret_id: secret_id.as_str().to_owned(),
}
.into());
}
if self
.registry
.get_current_secret_version(workspace_id, secret_id)
.await?
.is_none()
{
return Err(ApiError::unprocessable_with_context(
"operation auth profile secret has no current version",
json!({ "error_code": "operation_auth_profile_invalid" }),
));
}
}
Ok(())
}
fn validate_outbound_target(&self, target: &crank_core::Target) -> Result<(), ApiError> {
match target {
crank_core::Target::Rest(rest) => self
.outbound_http_policy
.validate_base_url(&rest.base_url)
.map_err(|error| ApiError::validation(error.to_string())),
}
}
fn validate_operation_capabilities(
&self,
protocol: Protocol,
security_level: OperationSecurityLevel,
) -> Result<(), ApiError> {
let supported_protocols = [Protocol::Rest];
if !supported_protocols.contains(&protocol) {
return Err(ApiError::validation_with_context(
format!(
"protocol {} is not supported in Community",
serde_json::to_string(&protocol)
.unwrap_or_else(|_| "\"unknown\"".to_owned())
.trim_matches('"')
),
json!({
"protocol": protocol,
"edition": "community",
}),
));
}
if security_level != OperationSecurityLevel::Standard {
return Err(ApiError::validation_with_context(
format!(
"security level {} is not supported in Community",
serde_json::to_string(&security_level)
.unwrap_or_else(|_| "\"unknown\"".to_owned())
.trim_matches('"')
),
json!({
"security_level": security_level,
"edition": "community",
}),
));
}
Ok(())
}
async fn find_operation_by_name(
&self,
workspace_id: &WorkspaceId,
name: &str,
) -> Result<Option<OperationSummary>, ApiError> {
Ok(self
.registry
.list_operations(workspace_id)
.await?
.into_iter()
.find(|operation| operation.name == name))
}
async fn find_agent_by_slug(
&self,
workspace_id: &WorkspaceId,
slug: &str,
) -> Result<Option<AgentSummary>, ApiError> {
Ok(self
.registry
.list_agents(workspace_id)
.await?
.into_iter()
.find(|agent| agent.slug == slug))
}
async fn ensure_workspace_exists(&self, workspace_id: &WorkspaceId) -> Result<(), ApiError> {
self.get_workspace(workspace_id).await.map(|_| ())
}
async fn record_invocation(
&self,
request: InvocationRecordRequest<'_>,
) -> InvocationHistoryWriteOutcome {
let log = InvocationLog {
id: InvocationLogId::new(new_prefixed_id("log")),
workspace_id: request.workspace_id.clone(),
agent_id: request.agent_id.cloned(),
platform_api_key_id: None,
operation_id: request.operation.id.clone(),
operation_version: Some(request.operation.version),
source: request.source,
level: request.level,
status: request.status,
tool_name: request.operation.name.clone(),
message: request.message,
request_id: request.request_id.map(ToOwned::to_owned),
trace_id: request.trace_id.map(ToOwned::to_owned),
status_code: request.status_code,
duration_ms: request.duration_ms,
error_kind: request.error_kind,
execution_stage: request.execution_stage,
execution_error_code: request.execution_error_code,
retryability: request.retryability,
outcome_certainty: request.outcome_certainty,
request_preview: request.request_preview,
response_preview: request.response_preview,
created_at: OffsetDateTime::now_utc(),
};
let history_span = crank_trace::Stage::HistoryWrite.span();
let (outcome, db_span) = async {
let db_span = crank_trace::DbOperation::InvocationHistoryWrite.span();
let outcome = self
.registry
.create_invocation_log(CreateInvocationLogRequest { log: &log })
.instrument(db_span.clone())
.await;
(outcome, db_span)
}
.instrument(history_span.clone())
.await;
match outcome {
InvocationHistoryWriteOutcome::Recorded => {
crank_trace::StageOutcome::Success.record(&db_span);
crank_trace::StageOutcome::Success.record(&history_span);
}
InvocationHistoryWriteOutcome::Lost(_) => {
crank_trace::StageOutcome::Error.record(&db_span);
crank_trace::ErrorCategory::Database.record(&db_span);
crank_trace::StageOutcome::Error.record(&history_span);
crank_trace::ErrorCategory::History.record(&history_span);
}
}
drop(db_span);
drop(history_span);
observe_invocation_history_outcome(
outcome,
request.request_id,
request.trace_id,
request.status,
request.source,
);
outcome
}
}
fn observe_invocation_history_outcome(
outcome: InvocationHistoryWriteOutcome,
request_id: Option<&str>,
trace_id: Option<&str>,
status: crank_core::InvocationStatus,
source: InvocationSource,
) {
let Some(loss) = outcome.loss() else {
return;
};
crank_observability::record_operational_incident(
crank_observability::OperationalIncident::InvocationHistoryLost,
);
tracing::warn!(
name: "admin.invocation_history.lost",
request_id = request_id.unwrap_or_default(),
trace_id = trace_id.unwrap_or_default(),
source = invocation_source_label(source),
invocation_status = invocation_status_label(status),
error_category = loss.category.as_str(),
"invocation history was not recorded"
);
}
fn invocation_source_label(source: InvocationSource) -> &'static str {
match source {
InvocationSource::AdminTestRun => "admin_test_run",
InvocationSource::AgentToolCall => "agent_tool_call",
}
}
fn invocation_status_label(status: crank_core::InvocationStatus) -> &'static str {
match status {
crank_core::InvocationStatus::Ok => "ok",
crank_core::InvocationStatus::Error => "error",
}
}
fn validate_profile_display_name(value: &str) -> Result<String, ApiError> {
let display_name = value.trim();
if display_name.is_empty() {
return Err(ApiError::validation("display name is required"));
}
if display_name.chars().count() > 80 {
return Err(ApiError::validation(
"display name must be at most 80 characters",
));
}
if display_name
.chars()
.any(|character| character.is_control() || matches!(character, '<' | '>'))
{
return Err(ApiError::validation(
"display name contains unsupported characters",
));
}
Ok(display_name.to_owned())
}
fn validate_profile_email(value: &str) -> Result<String, ApiError> {
let email = value.trim().to_ascii_lowercase();
if email.is_empty()
|| email.len() > 254
|| !email.contains('@')
|| email.chars().any(|character| {
character.is_whitespace() || character.is_control() || matches!(character, '<' | '>')
})
{
return Err(ApiError::validation("a valid email address is required"));
}
Ok(email)
}
fn new_prefixed_id(prefix: &str) -> String {
format!("{prefix}_{}", Uuid::now_v7().simple())
}
fn generate_access_secret(marker: &str) -> String {
let mut secret_bytes = [0_u8; 32];
rand::rng().fill(&mut secret_bytes);
let random = URL_SAFE_NO_PAD.encode(secret_bytes);
format!("{marker}{random}")
}
fn hash_access_secret(secret: &str) -> String {
let digest = Sha256::digest(secret.as_bytes());
URL_SAFE_NO_PAD.encode(digest)
}
fn now_string() -> Result<String, ApiError> {
OffsetDateTime::now_utc()
.format(&Rfc3339)
.map_err(|error| ApiError::internal(error.to_string()))
}
fn format_timestamp(timestamp: OffsetDateTime) -> String {
timestamp
.format(&Rfc3339)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned())
}
fn map_identity_error(error: IdentityError) -> ApiError {
match error {
IdentityError::BadCredentials => ApiError::unauthorized("invalid email or password"),
IdentityError::AccountDisabled => ApiError::unauthorized("invalid email or password"),
IdentityError::NotSupportedForProvider => ApiError::internal(
"password login is not supported by the configured identity provider",
),
IdentityError::Internal(message) => ApiError::internal(message),
}
}
fn protocol_capability_view(
protocol: Protocol,
_edition: ProductEdition,
) -> ProtocolCapabilityView {
let supports_upload_artifacts = Vec::new();
ProtocolCapabilityView {
protocol,
supports_execution_modes: vec![ExecutionMode::Unary],
supports_transport_behaviors: vec!["request_response".to_owned()],
supports_auth_kinds: vec![
"none".to_owned(),
"bearer".to_owned(),
"basic".to_owned(),
"api_key_header".to_owned(),
"api_key_query".to_owned(),
],
supports_upload_artifacts,
supports_cursor_path: false,
supports_done_path: false,
supports_aggregation_mode: Vec::new(),
}
}
fn usage_window(
period: UsagePeriod,
) -> Result<(UsagePeriod, String, String, UsageBucket), ApiError> {
let now = OffsetDateTime::now_utc();
let (start, bucket) = match period {
UsagePeriod::Last30Minutes => (
now.checked_sub(time::Duration::minutes(30))
.ok_or_else(|| ApiError::internal("failed to compute usage period"))?,
UsageBucket::Hour,
),
UsagePeriod::LastHour => (
now.checked_sub(time::Duration::hours(1))
.ok_or_else(|| ApiError::internal("failed to compute usage period"))?,
UsageBucket::Hour,
),
UsagePeriod::Last6Hours => (
now.checked_sub(time::Duration::hours(6))
.ok_or_else(|| ApiError::internal("failed to compute usage period"))?,
UsageBucket::Hour,
),
UsagePeriod::Last24Hours => (
now.checked_sub(time::Duration::hours(24))
.ok_or_else(|| ApiError::internal("failed to compute usage period"))?,
UsageBucket::Hour,
),
UsagePeriod::Last7Days => (
now.checked_sub(time::Duration::days(7))
.ok_or_else(|| ApiError::internal("failed to compute usage period"))?,
UsageBucket::Day,
),
UsagePeriod::Last30Days => (
now.checked_sub(time::Duration::days(30))
.ok_or_else(|| ApiError::internal("failed to compute usage period"))?,
UsageBucket::Week,
),
UsagePeriod::Last90Days => (
now.checked_sub(time::Duration::days(90))
.ok_or_else(|| ApiError::internal("failed to compute usage period"))?,
UsageBucket::Month,
),
UsagePeriod::ThisMonth => (
OffsetDateTime::from_unix_timestamp(
now.unix_timestamp() - i64::from(now.day() - 1) * 24 * 60 * 60,
)
.map_err(|error| ApiError::internal(error.to_string()))?
.replace_time(time::Time::MIDNIGHT),
UsageBucket::Week,
),
};
let created_after = start
.format(&Rfc3339)
.map_err(|error| ApiError::internal(error.to_string()))?;
let created_before = now
.format(&Rfc3339)
.map_err(|error| ApiError::internal(error.to_string()))?;
Ok((period, created_after, created_before, bucket))
}
fn today_start_utc() -> Result<String, ApiError> {
OffsetDateTime::now_utc()
.replace_time(time::Time::MIDNIGHT)
.format(&Rfc3339)
.map_err(|error| ApiError::internal(error.to_string()))
}
fn default_usage_summary() -> OperationUsageSummaryView {
OperationUsageSummaryView {
calls_today: 0,
error_rate_pct: 0.0,
avg_latency_ms: 0,
}
}
fn usage_map(items: Vec<OperationUsageSummary>) -> BTreeMap<String, OperationUsageSummaryView> {
items
.into_iter()
.map(|item| {
(
item.operation_id.as_str().to_owned(),
OperationUsageSummaryView {
calls_today: item.calls_today,
error_rate_pct: item.error_rate_pct,
avg_latency_ms: item.avg_latency_ms,
},
)
})
.collect()
}
fn agent_ref_map(items: Vec<OperationAgentRef>) -> BTreeMap<String, Vec<OperationAgentRefView>> {
let mut map = BTreeMap::<String, Vec<OperationAgentRefView>>::new();
for item in items {
map.entry(item.operation_id.as_str().to_owned())
.or_default()
.push(OperationAgentRefView {
agent_id: item.agent_id.as_str().to_owned(),
agent_slug: item.agent_slug,
display_name: item.display_name,
});
}
map
}
fn map_agent_summary_view(summary: AgentSummary) -> AgentSummaryView {
AgentSummaryView {
id: summary.id.as_str().to_owned(),
workspace_id: summary.workspace_id.as_str().to_owned(),
slug: summary.slug,
display_name: summary.display_name,
description: summary.description,
status: summary.status,
current_draft_version: summary.current_draft_version,
latest_published_version: summary.latest_published_version,
catalog_revision: summary.catalog_revision,
created_at: format_timestamp(summary.created_at),
updated_at: format_timestamp(summary.updated_at),
published_at: summary.published_at.map(format_timestamp),
operation_count: 0,
operation_ids: Vec::new(),
tool_selection_policy: Default::default(),
key_count: 0,
calls_today: 0,
mcp_endpoint: String::new(),
}
}
pub(crate) fn agent_mcp_endpoint(workspace_slug: &str, agent_slug: &str) -> String {
format!("/mcp/v1/{workspace_slug}/{agent_slug}")
}
impl AdminService {
pub(crate) fn public_agent_mcp_endpoint(
&self,
workspace_slug: &str,
agent_slug: &str,
) -> String {
format!(
"{}{}",
self.public_base_url,
agent_mcp_endpoint(workspace_slug, agent_slug)
)
}
}
fn tool_quality_schema_node(schema: &Schema) -> ToolQualitySchemaNode {
ToolQualitySchemaNode {
kind: tool_quality_schema_kind(&schema.kind),
description: schema.description.clone(),
fields: schema
.fields
.iter()
.map(|(name, field)| (name.clone(), tool_quality_schema_node(field)))
.collect(),
items: schema
.items
.as_deref()
.map(tool_quality_schema_node)
.map(Box::new),
enum_values: schema.enum_values.clone(),
}
}
fn tool_quality_schema_kind(kind: &SchemaKind) -> ToolQualitySchemaKind {
match kind {
SchemaKind::Object => ToolQualitySchemaKind::Object,
SchemaKind::Array => ToolQualitySchemaKind::Array,
SchemaKind::String => ToolQualitySchemaKind::String,
SchemaKind::Integer => ToolQualitySchemaKind::Integer,
SchemaKind::Number => ToolQualitySchemaKind::Number,
SchemaKind::Boolean => ToolQualitySchemaKind::Boolean,
SchemaKind::Enum => ToolQualitySchemaKind::Enum,
SchemaKind::Null => ToolQualitySchemaKind::Null,
SchemaKind::Oneof => ToolQualitySchemaKind::Oneof,
}
}
fn tool_quality_mapping_set(mapping: &MappingSet) -> ToolQualityMappingSet {
ToolQualityMappingSet {
rules: mapping
.rules
.iter()
.map(tool_quality_mapping_rule)
.collect(),
}
}
fn tool_quality_mapping_rule(rule: &MappingRule) -> ToolQualityMappingRule {
ToolQualityMappingRule {
source: rule.source.clone(),
target: rule.target.clone(),
}
}
#[cfg(test)]
#[allow(clippy::items_after_test_module)]
mod tests {
use std::{
io,
sync::{Arc, Mutex},
};
use crank_core::{InvocationSource, InvocationStatus};
use crank_observability::{
ObservabilityConfig, OperationalIncident, RedactionLimits, ServiceIdentity,
operational_incident_total,
};
use crank_registry::{
InvocationHistoryLoss, InvocationHistoryLossCategory, InvocationHistoryWriteOutcome,
};
use serde_json::Value;
use tracing_subscriber::fmt::MakeWriter;
use super::{
observe_invocation_history_outcome, validate_profile_display_name, validate_profile_email,
};
#[test]
fn validates_profile_identity_fields() {
assert_eq!(
validate_profile_display_name(" Updated Owner ").unwrap(),
"Updated Owner"
);
assert_eq!(
validate_profile_email(" OWNER@CRANK.LOCAL ").unwrap(),
"owner@crank.local"
);
}
#[test]
fn rejects_profile_identity_page_text() {
assert!(validate_profile_display_name("<html>Crank</html>").is_err());
assert!(validate_profile_display_name("Crank\nOperations\nSave profile").is_err());
assert!(validate_profile_display_name(&"x".repeat(81)).is_err());
assert!(validate_profile_email("owner <html>@crank.local").is_err());
}
#[test]
fn emits_bounded_history_loss_incident() {
let writer = SharedLogWriter::default();
let subscriber = crank_observability::build_subscriber(
ObservabilityConfig::new(
ServiceIdentity::try_new("admin-api", "test", "test").unwrap(),
"info",
RedactionLimits::default(),
),
writer.clone(),
)
.unwrap();
let before = operational_incident_total(OperationalIncident::InvocationHistoryLost);
let dispatch = tracing::Dispatch::new(subscriber);
let _guard = tracing::dispatcher::set_default(&dispatch);
observe_invocation_history_outcome(
InvocationHistoryWriteOutcome::Lost(InvocationHistoryLoss {
category: InvocationHistoryLossCategory::InvalidRecord,
}),
Some("req_admin_dc08"),
Some("0af7651916cd43dd8448eb211c80319c"),
InvocationStatus::Error,
InvocationSource::AgentToolCall,
);
let output = writer.output();
assert!(!output.contains("dc08-canary-secret"));
let event: Value = output
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.find(|event: &Value| event["event"] == "admin.invocation_history.lost")
.unwrap();
assert_eq!(event["request_id"], "req_admin_dc08");
assert_eq!(event["fields"]["source"], "agent_tool_call");
assert_eq!(event["fields"]["invocation_status"], "error");
assert_eq!(event["fields"]["error_category"], "invalid_record");
assert!(operational_incident_total(OperationalIncident::InvocationHistoryLost) > before);
}
#[derive(Clone, Default)]
struct SharedLogWriter {
buffer: Arc<Mutex<Vec<u8>>>,
}
impl SharedLogWriter {
fn output(&self) -> String {
String::from_utf8(self.buffer.lock().unwrap().clone()).unwrap()
}
}
impl<'a> MakeWriter<'a> for SharedLogWriter {
type Writer = SharedLogGuard;
fn make_writer(&'a self) -> Self::Writer {
SharedLogGuard {
buffer: Arc::clone(&self.buffer),
}
}
}
struct SharedLogGuard {
buffer: Arc<Mutex<Vec<u8>>>,
}
impl io::Write for SharedLogGuard {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
self.buffer.lock().unwrap().extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
}
fn enrich_operation_summary(
summary: OperationSummary,
usage_summary: OperationUsageSummaryView,
agent_refs: Vec<OperationAgentRefView>,
) -> OperationSummaryView {
OperationSummaryView {
id: summary.id.as_str().to_owned(),
workspace_id: summary.workspace_id.as_str().to_owned(),
name: summary.name,
display_name: summary.display_name,
category: summary.category,
protocol: summary.protocol,
security_level: summary.security_level,
target_url: summary.target_url,
target_action: summary.target_action,
status: summary.status,
current_draft_version: summary.current_draft_version,
latest_published_version: summary.latest_published_version,
can_delete: summary.can_delete,
created_at: format_timestamp(summary.created_at),
updated_at: format_timestamp(summary.updated_at),
published_at: summary.published_at.map(format_timestamp),
usage_summary,
agent_refs,
}
}