feat: complete Epic 1 production foundation
This commit is contained in:
+108
-10
@@ -17,7 +17,9 @@ use crank_observability::{
|
||||
CriticalErrorCategory, MetricsConfig, ObservabilityConfig, ObservabilityLifecycle,
|
||||
OtlpTraceConfig, RedactionLimits, SentryConfig, ServiceIdentity, capture_critical_error,
|
||||
};
|
||||
use crank_registry::{PostgresPoolConfig, PostgresRegistry};
|
||||
use crank_registry::{
|
||||
MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate, PostgresPoolConfig, PostgresRegistry,
|
||||
};
|
||||
use crank_runtime::{
|
||||
RequestRateLimitConfig, RequestRateLimiter, RuntimeCacheConfig, RuntimeCacheStores,
|
||||
RuntimeLimits, SecretCrypto,
|
||||
@@ -65,6 +67,36 @@ fn safe_startup_diagnostic(error: &(dyn std::error::Error + 'static)) -> String
|
||||
})
|
||||
.to_string();
|
||||
}
|
||||
if let Some(crank_registry::RegistryError::MasterKeyIdentityMismatch { epoch }) =
|
||||
cause.downcast_ref::<crank_registry::RegistryError>()
|
||||
{
|
||||
return serde_json::json!({
|
||||
"status": "error",
|
||||
"code": "master_key_identity_mismatch",
|
||||
"stage": "startup.master_key_identity",
|
||||
"version": epoch,
|
||||
"recovery": "configure_same_master_key",
|
||||
})
|
||||
.to_string();
|
||||
}
|
||||
if cause
|
||||
.downcast_ref::<crank_registry::RegistryError>()
|
||||
.is_some_and(|error| {
|
||||
matches!(
|
||||
error,
|
||||
crank_registry::RegistryError::InvalidMasterKeyIdentity
|
||||
)
|
||||
})
|
||||
{
|
||||
return serde_json::json!({
|
||||
"status": "error",
|
||||
"code": "master_key_identity_invalid",
|
||||
"stage": "startup.master_key_identity",
|
||||
"version": null,
|
||||
"recovery": "contact_operator",
|
||||
})
|
||||
.to_string();
|
||||
}
|
||||
current = cause.source();
|
||||
}
|
||||
serde_json::json!({
|
||||
@@ -149,7 +181,11 @@ async fn run(
|
||||
cookie_secure: base_url.starts_with("https://"),
|
||||
bootstrap_admin: BootstrapAdminConfig {
|
||||
email: config.bootstrap_email.clone(),
|
||||
password: config.bootstrap_password.expose_secret().to_owned(),
|
||||
password: config
|
||||
.bootstrap_password
|
||||
.as_ref()
|
||||
.map(|password| password.expose_secret().to_owned())
|
||||
.unwrap_or_default(),
|
||||
display_name: config.bootstrap_display_name.clone(),
|
||||
},
|
||||
};
|
||||
@@ -163,10 +199,13 @@ async fn run(
|
||||
config.rate_limit.requests_per_second,
|
||||
config.rate_limit.burst,
|
||||
)?;
|
||||
let secret_crypto = SecretCrypto::new(config.runtime.master_key.expose_secret())?;
|
||||
let outbound_http_policy = crank_runtime::OutboundHttpPolicy::try_new(
|
||||
let secret_crypto =
|
||||
verified_startup_secret_crypto(®istry, config.runtime.master_key.expose_secret())
|
||||
.await?;
|
||||
let outbound_http_policy = crank_runtime::OutboundHttpPolicy::try_new_with_limits(
|
||||
config.runtime.outbound.allowed_hosts.clone(),
|
||||
config.runtime.outbound.denied_hosts.clone(),
|
||||
config.runtime.outbound.max_request_bytes,
|
||||
config.runtime.outbound.max_response_bytes,
|
||||
)?;
|
||||
let runtime = crank_runtime::community_with_outbound_policy(outbound_http_policy.clone())
|
||||
@@ -183,10 +222,10 @@ async fn run(
|
||||
secret_crypto,
|
||||
runtime,
|
||||
)
|
||||
.with_public_base_url(base_url)
|
||||
.with_outbound_http_policy(outbound_http_policy)
|
||||
.with_identity_provider(std::sync::Arc::new(identity_provider))
|
||||
.build();
|
||||
service.bootstrap_admin_user().await?;
|
||||
if config.demo_seed {
|
||||
service.seed_demo_assets().await?;
|
||||
}
|
||||
@@ -198,7 +237,7 @@ async fn run(
|
||||
} else {
|
||||
RequestRateLimiter::new(api_rate_limit)
|
||||
},
|
||||
trust_forwarded_headers: config.trust_forwarded_headers,
|
||||
trusted_proxy_ips: config.trusted_proxy_ips.clone(),
|
||||
};
|
||||
let app = build_app(state);
|
||||
let listener = TcpListener::bind(config.bind_addr).await?;
|
||||
@@ -285,9 +324,10 @@ fn preflight_config(config: &AdminProcessConfig) -> Result<(), crank_config::Con
|
||||
.map_err(|_| invalid("admin.rate_limit"))?;
|
||||
SecretCrypto::new(config.runtime.master_key.expose_secret())
|
||||
.map_err(|_| invalid("runtime.master_key"))?;
|
||||
crank_runtime::OutboundHttpPolicy::try_new(
|
||||
crank_runtime::OutboundHttpPolicy::try_new_with_limits(
|
||||
config.runtime.outbound.allowed_hosts.clone(),
|
||||
config.runtime.outbound.denied_hosts.clone(),
|
||||
config.runtime.outbound.max_request_bytes,
|
||||
config.runtime.outbound.max_response_bytes,
|
||||
)
|
||||
.map_err(|_| invalid("runtime.outbound"))?;
|
||||
@@ -315,6 +355,53 @@ fn preflight_config(config: &AdminProcessConfig) -> Result<(), crank_config::Con
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn verified_startup_secret_crypto(
|
||||
registry: &PostgresRegistry,
|
||||
master_key: &str,
|
||||
) -> Result<SecretCrypto, Box<dyn std::error::Error>> {
|
||||
let active = registry.active_master_key_identity().await?;
|
||||
let secret_crypto = if let Some(identity) = active {
|
||||
SecretCrypto::with_epoch(master_key, identity.epoch)?
|
||||
} else {
|
||||
let crypto = SecretCrypto::new(master_key)?;
|
||||
let mut after_secret_id: Option<String> = None;
|
||||
let mut after_version: Option<u32> = None;
|
||||
loop {
|
||||
let versions = registry
|
||||
.list_secret_versions_for_master_key_epoch_page(
|
||||
1,
|
||||
after_secret_id.as_deref(),
|
||||
after_version,
|
||||
1_000,
|
||||
)
|
||||
.await?;
|
||||
if versions.is_empty() {
|
||||
break;
|
||||
}
|
||||
for version in versions {
|
||||
after_secret_id = Some(version.secret_version.secret_id.as_str().to_owned());
|
||||
after_version = Some(version.secret_version.version);
|
||||
crypto.decrypt_for_epoch(
|
||||
&version.secret_version.key_version,
|
||||
version.master_key_epoch,
|
||||
&version.secret_version.ciphertext,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
crypto
|
||||
};
|
||||
let master_key_observed_at = time::OffsetDateTime::now_utc();
|
||||
registry
|
||||
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
|
||||
epoch: secret_crypto.master_key_epoch(),
|
||||
fingerprint: secret_crypto.master_key_fingerprint(),
|
||||
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
|
||||
observed_at: &master_key_observed_at,
|
||||
})
|
||||
.await?;
|
||||
Ok(secret_crypto)
|
||||
}
|
||||
|
||||
fn otlp_config(
|
||||
config: &ObservabilitySettings,
|
||||
) -> Result<OtlpTraceConfig, crank_observability::OtlpTraceConfigError> {
|
||||
@@ -395,12 +482,23 @@ fn spawn_invocation_log_cleanup(service: admin_api::service::AdminService, reten
|
||||
interval.tick().await;
|
||||
let cutoff = time::OffsetDateTime::now_utc() - time::Duration::days(retention_days);
|
||||
match service.cleanup_invocation_logs_before(cutoff).await {
|
||||
Ok(removed) if removed > 0 => info!(
|
||||
Ok(outcome) if outcome.deleted_records > 0 => info!(
|
||||
name: "admin.invocation_log_cleanup.completed",
|
||||
removed,
|
||||
status = ?outcome.status,
|
||||
removed = outcome.deleted_records,
|
||||
requested_cutoff = %outcome.policy.requested_cutoff,
|
||||
effective_cutoff = %outcome.policy.effective_cutoff,
|
||||
preserved_usage_window_days = outcome.policy.preserved_usage_window_days,
|
||||
"expired invocation logs removed"
|
||||
),
|
||||
Ok(_) => {}
|
||||
Ok(outcome) => info!(
|
||||
name: "admin.invocation_log_cleanup.noop",
|
||||
status = ?outcome.status,
|
||||
requested_cutoff = %outcome.policy.requested_cutoff,
|
||||
effective_cutoff = %outcome.policy.effective_cutoff,
|
||||
preserved_usage_window_days = outcome.policy.preserved_usage_window_days,
|
||||
"no expired invocation logs removed"
|
||||
),
|
||||
Err(_) => warn!(
|
||||
name: "admin.invocation_log_cleanup.failed",
|
||||
error_category = "registry_cleanup",
|
||||
|
||||
Reference in New Issue
Block a user