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, runtime: RuntimeExecutor, storage: LocalArtifactStorage, auth_settings: AuthSettings, secret_crypto: SecretCrypto, identity_provider: Option>, policy_engine: Arc, audit_sink: Arc, capability_profile: Arc, 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>, auth_settings: AuthSettings, secret_crypto: SecretCrypto, runtime: RuntimeExecutor, identity_provider: Option>, policy_engine: Option>, audit_sink: Option>, capability_profile: Option>, 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 { &self.policy_engine } pub fn audit_sink(&self) -> &Arc { &self.audit_sink } pub fn capability_profile(&self) -> &Arc { &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) -> 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.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) -> 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) -> Self { self.policy_engine = Some(policy_engine); self } #[allow(dead_code)] pub fn with_audit_sink(mut self, audit_sink: Arc) -> Self { self.audit_sink = Some(audit_sink); self } #[allow(dead_code)] pub fn with_capability_profile( mut self, capability_profile: Arc, ) -> 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 { 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 { 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, 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 { 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, 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, 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 { 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 { 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 { 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 { 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) -> BTreeMap { 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) -> BTreeMap> { let mut map = BTreeMap::>::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("Crank").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 @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>>, } 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>>, } impl io::Write for SharedLogGuard { fn write(&mut self, bytes: &[u8]) -> io::Result { 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, ) -> 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, } }