feat: complete Epic 1 production foundation

This commit is contained in:
2026-08-25 01:24:11 +03:00
parent 767428436d
commit 182bde8ac0
298 changed files with 35719 additions and 5299 deletions
+372 -97
View File
@@ -4,23 +4,26 @@ use std::sync::Arc;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use crank_core::{
AuditSink, AuthProfile, CapabilityProfile, CommunityCapabilityProfile, EditionCapabilities,
AuditActor, AuditEvent, AuditEventId, AuditSink, AuditTarget, AuditTargetKind, AuthProfile,
CapabilityProfile, CommunityCapabilityProfile, CorrelationContext, EditionCapabilities,
ExecutionMode, IdentityError, IdentityProvider, InvocationLog, InvocationLogId,
InvocationSource, NoopAuditSink, OperationSecurityLevel, OwnerOnlyPolicyEngine, PolicyEngine,
ProductEdition, Protocol, ToolQualityMappingRule, ToolQualityMappingSet, ToolQualitySchemaKind,
ToolQualitySchemaNode, UsagePeriod, WorkspaceId,
ProductEdition, Protocol, SecretStatus, ToolQualityMappingRule, ToolQualityMappingSet,
ToolQualitySchemaKind, ToolQualitySchemaNode, UsagePeriod, WorkspaceId,
};
use crank_mapping::{MappingRule, MappingSet};
use crank_registry::{
AgentSummary, CreateInvocationLogRequest, InvocationHistoryWriteOutcome, OperationAgentRef,
OperationSummary, OperationUsageSummary, PostgresRegistry, RegistryOperation, UsageBucket,
OperationSummary, OperationUsageSummary, PostgresRegistry, RegistryError, RegistryOperation,
UsageBucket,
};
use crank_runtime::{
OutboundHttpPolicy, PreparedRequest, ResolvedAuth, RuntimeError, RuntimeExecutor, SecretCrypto,
OutboundHttpPolicy, ResolvedAuth, RuntimeError, RuntimeExecutor, SecretCrypto,
};
use crank_schema::{Schema, SchemaKind};
use crank_trace::{DbOperation, ErrorCategory, Stage, StageOutcome, observe_db_query};
use serde_json::{Value, json};
use rand::RngExt;
use serde_json::json;
use sha2::{Digest, Sha256};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tracing::Instrument;
@@ -33,6 +36,7 @@ mod demo;
mod import_export;
mod imports;
mod observability;
mod onboarding;
mod operation_validation;
mod operations;
mod samples;
@@ -40,7 +44,11 @@ mod secrets;
mod upstreams;
mod workspaces;
use crate::{auth::AuthSettings, error::ApiError, storage::LocalArtifactStorage};
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,
@@ -58,6 +66,7 @@ pub struct AdminService {
audit_sink: Arc<dyn AuditSink>,
capability_profile: Arc<dyn CapabilityProfile>,
outbound_http_policy: OutboundHttpPolicy,
public_base_url: String,
}
pub struct AdminServiceBuilder {
@@ -71,10 +80,46 @@ pub struct AdminServiceBuilder {
audit_sink: Option<Arc<dyn AuditSink>>,
capability_profile: Option<Arc<dyn CapabilityProfile>>,
outbound_http_policy: OutboundHttpPolicy,
public_base_url: String,
}
pub use crate::dto::*;
#[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) -> Result<(), ApiError> {
self.registry.ping().await?;
@@ -132,6 +177,19 @@ impl AdminService {
}
}
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,
@@ -151,6 +209,7 @@ impl AdminServiceBuilder {
audit_sink: None,
capability_profile: None,
outbound_http_policy: OutboundHttpPolicy::default(),
public_base_url: "http://localhost:3000".to_owned(),
}
}
@@ -164,6 +223,11 @@ impl AdminServiceBuilder {
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);
@@ -201,6 +265,7 @@ impl AdminServiceBuilder {
.capability_profile
.unwrap_or_else(|| Arc::new(CommunityCapabilityProfile)),
outbound_http_policy: self.outbound_http_policy,
public_base_url: self.public_base_url,
}
}
}
@@ -254,10 +319,56 @@ impl AdminService {
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);
@@ -271,15 +382,14 @@ impl AdminService {
.get_auth_profile(workspace_id, auth_profile_id),
)
.await
.map_err(|error| RuntimeError::SecretCrypto {
operation: "load auth profile",
details: error.to_string(),
})?
.ok_or_else(|| RuntimeError::MissingAuthProfile {
auth_profile_id: auth_profile_id.as_str().to_owned(),
})?;
.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)
self.resolve_auth_profile(workspace_id, &auth_profile, audit_context)
.await
.map(Some)
}
@@ -292,6 +402,17 @@ impl AdminService {
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
}
@@ -299,6 +420,7 @@ impl AdminService {
&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();
@@ -309,45 +431,156 @@ impl AdminService {
self.registry.get_secret(workspace_id, secret_id),
)
.await
.map_err(|error| RuntimeError::SecretCrypto {
operation: "load secret",
details: error.to_string(),
})?
.ok_or_else(|| RuntimeError::MissingSecret {
secret_id: secret_id.as_str().to_owned(),
})?;
.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(|error| RuntimeError::SecretCrypto {
operation: "load current secret version",
details: error.to_string(),
})?
.ok_or_else(|| RuntimeError::MissingSecretVersion {
secret_id: secret_id.as_str().to_owned(),
version: secret.secret.current_version,
})?;
let plaintext = self.secret_crypto.decrypt(
.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
.map_err(|error| RuntimeError::SecretCrypto {
operation: "touch secret",
details: error.to_string(),
})?;
.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);
}
ResolvedAuth::from_profile(auth_profile, &secrets)
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> {
@@ -376,6 +609,70 @@ impl AdminService {
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
@@ -462,7 +759,9 @@ impl AdminService {
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,
@@ -473,6 +772,10 @@ impl AdminService {
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(),
@@ -553,29 +856,6 @@ fn invocation_status_label(status: crank_core::InvocationStatus) -> &'static str
}
}
fn build_request_preview(
mapping: &MappingSet,
input: &Value,
) -> Result<Value, crank_mapping::MappingError> {
let prepared = if mapping.is_empty() {
PreparedRequest::default()
} else {
let mapped = mapping.apply(&json!({ "mcp": input }))?;
PreparedRequest::from_mapping_output(&mapped).map_err(|error| {
crank_mapping::MappingError::InvalidJsonPath {
path: error.to_string(),
}
})?
};
Ok(json!({
"path": prepared.path_params,
"query": prepared.query_params,
"headers": prepared.headers,
"body": prepared.body.unwrap_or(Value::Null)
}))
}
fn validate_profile_display_name(value: &str) -> Result<String, ApiError> {
let display_name = value.trim();
if display_name.is_empty() {
@@ -616,7 +896,9 @@ fn new_prefixed_id(prefix: &str) -> String {
}
fn generate_access_secret(marker: &str) -> String {
let random = URL_SAFE_NO_PAD.encode(Uuid::now_v7().as_bytes());
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}")
}
@@ -640,7 +922,7 @@ fn format_timestamp(timestamp: OffsetDateTime) -> String {
fn map_identity_error(error: IdentityError) -> ApiError {
match error {
IdentityError::BadCredentials => ApiError::unauthorized("invalid email or password"),
IdentityError::AccountDisabled => ApiError::forbidden("account is disabled"),
IdentityError::AccountDisabled => ApiError::unauthorized("invalid email or password"),
IdentityError::NotSupportedForProvider => ApiError::internal(
"password login is not supported by the configured identity provider",
),
@@ -648,32 +930,6 @@ fn map_identity_error(error: IdentityError) -> ApiError {
}
}
fn runtime_error_code(error: &RuntimeError) -> &'static str {
match error {
RuntimeError::Schema(_) => "schema_error",
RuntimeError::Mapping(_) => "mapping_error",
RuntimeError::InvalidPreparedRequest { .. } => "invalid_request",
RuntimeError::ConfirmationRequired { .. } => "confirmation_required",
RuntimeError::InvalidConfirmationToken { .. } => "invalid_confirmation_token",
RuntimeError::ConfirmationStoreUnavailable { .. } => "confirmation_unavailable",
RuntimeError::IdempotencyStoreUnavailable { .. } => "idempotency_unavailable",
RuntimeError::IdempotencyInProgress { .. } => "idempotency_in_progress",
RuntimeError::IdempotencyConflict { .. } => "idempotency_conflict",
RuntimeError::IdempotencyOutcomeUnknown { .. } => "idempotency_outcome_unknown",
RuntimeError::RestAdapter(_) => "rest_error",
RuntimeError::ProtocolAdapter(_) => "adapter_error",
RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol",
RuntimeError::ConcurrencyLimitExceeded { .. } => "runtime_overloaded",
RuntimeError::UnsupportedExecutionMode { .. } => "streaming_mode_error",
RuntimeError::MissingAuthProfile { .. } => "auth_profile_not_found",
RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => {
"secret_not_found"
}
RuntimeError::InvalidAuthSecretValue { .. } => "secret_value_error",
RuntimeError::SecretCrypto { .. } => "secret_crypto_error",
}
}
fn protocol_capability_view(
protocol: Protocol,
_edition: ProductEdition,
@@ -698,7 +954,9 @@ fn protocol_capability_view(
}
}
fn usage_window(period: UsagePeriod) -> Result<(UsagePeriod, String, UsageBucket), ApiError> {
fn usage_window(
period: UsagePeriod,
) -> Result<(UsagePeriod, String, String, UsageBucket), ApiError> {
let now = OffsetDateTime::now_utc();
let (start, bucket) = match period {
UsagePeriod::Last30Minutes => (
@@ -746,13 +1004,14 @@ fn usage_window(period: UsagePeriod) -> Result<(UsagePeriod, String, UsageBucket
),
};
Ok((
period,
start
.format(&Rfc3339)
.map_err(|error| ApiError::internal(error.to_string()))?,
bucket,
))
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> {
@@ -810,6 +1069,7 @@ fn map_agent_summary_view(summary: AgentSummary) -> AgentSummaryView {
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),
@@ -822,10 +1082,24 @@ fn map_agent_summary_view(summary: AgentSummary) -> AgentSummaryView {
}
}
fn agent_mcp_endpoint(workspace_slug: &str, agent_slug: &str) -> String {
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),
@@ -1013,6 +1287,7 @@ fn enrich_operation_summary(
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),