1395 lines
49 KiB
Rust
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,
|
|
}
|
|
}
|