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
+40 -7
View File
@@ -1,10 +1,15 @@
use axum::{
Router, middleware,
Router,
extract::DefaultBodyLimit,
middleware,
routing::{delete, get, post},
};
use crate::{
auth::{require_session, require_workspace_session},
auth::{
enforce_browser_security, require_csrf_for_browser_mutations, require_session,
require_workspace_session,
},
rate_limit::apply_api_rate_limit,
request_context::apply_request_context,
routes::{
@@ -15,14 +20,18 @@ use crate::{
list_agent_platform_api_keys, list_agents, preview_tool_search, publish_agent,
revoke_agent_platform_api_key, save_agent_bindings, unpublish_agent, update_agent,
},
auth::{change_password, get_profile, get_session, login, logout, update_profile},
auth::{
bootstrap_status, change_password, complete_bootstrap, get_profile, get_session, login,
logout, refresh_session_csrf, update_profile,
},
auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles},
capabilities::get_capabilities,
imports::{create_openapi_import, preview_openapi_import},
observability::{
get_agent_usage, get_approval, get_log, get_operation_usage, get_usage, list_approvals,
list_logs,
approve_approval, deny_approval, export_logs_csv, export_usage_csv, get_agent_usage,
get_approval, get_log, get_operation_usage, get_usage, list_approvals, list_logs,
},
onboarding::{get_onboarding, record_onboarding_event, reset_onboarding_selection},
operations::{
analyze_operation_quality, archive_operation, create_operation, create_version,
delete_operation, export_operation, generate_draft, get_operation,
@@ -48,7 +57,10 @@ pub fn build_app(state: AppState) -> Router {
"/operations/analyze-quality",
post(analyze_operation_quality),
)
.route("/operations/import", post(import_operation))
.route(
"/operations/import",
post(import_operation).layer(DefaultBodyLimit::max(256 * 1024)),
)
.route(
"/operations/{operation_id}",
get(get_operation)
@@ -126,12 +138,22 @@ pub fn build_app(state: AppState) -> Router {
.route("/secrets/{secret_id}/rotate", post(rotate_secret))
.route("/export", get(export_workspace))
.route("/logs", get(list_logs))
.route("/logs/export.csv", get(export_logs_csv))
.route("/logs/{log_id}", get(get_log))
.route("/approvals", get(list_approvals))
.route("/approvals/{approval_id}", get(get_approval))
.route("/approvals/{approval_id}/approve", post(approve_approval))
.route("/approvals/{approval_id}/deny", post(deny_approval))
.route("/usage/export.csv", get(export_usage_csv))
.route("/usage", get(get_usage))
.route("/usage/operations/{operation_id}", get(get_operation_usage))
.route("/usage/agents/{agent_id}", get(get_agent_usage));
.route("/usage/agents/{agent_id}", get(get_agent_usage))
.route("/onboarding", get(get_onboarding))
.route("/onboarding/events", post(record_onboarding_event))
.route(
"/onboarding/reset-selection",
post(reset_onboarding_selection),
);
let workspace_root_router = Router::new()
.route("/capabilities", get(get_capabilities))
@@ -157,6 +179,7 @@ pub fn build_app(state: AppState) -> Router {
let protected_auth_router = Router::new()
.route("/logout", post(logout))
.route("/session", get(get_session))
.route("/session/csrf", post(refresh_session_csrf))
.route("/profile", get(get_profile).patch(update_profile))
.route("/password", post(change_password))
.layer(middleware::from_fn_with_state(
@@ -170,10 +193,20 @@ pub fn build_app(state: AppState) -> Router {
.nest(
"/api/auth",
Router::new()
.route("/bootstrap/status", get(bootstrap_status))
.route("/bootstrap/complete", post(complete_bootstrap))
.route("/login", post(login))
.merge(protected_auth_router),
)
.nest("/api/admin", admin_router)
.layer(middleware::from_fn_with_state(
state.clone(),
require_csrf_for_browser_mutations,
))
.layer(middleware::from_fn_with_state(
state.clone(),
enforce_browser_security,
))
.layer(middleware::from_fn_with_state(
state.clone(),
apply_api_rate_limit,
+131 -3
View File
@@ -1,22 +1,24 @@
use axum::{
extract::{OriginalUri, Request, State},
http::{HeaderValue, Method, header},
middleware::Next,
response::Response,
};
use axum_extra::extract::cookie::{Cookie, CookieJar};
use crank_community_auth::{
cleared_session_cookie as build_cleared_session_cookie,
cleared_session_cookie as build_cleared_session_cookie, create_csrf_token as build_csrf_token,
create_session_cookie as build_session_cookie, hash_password as community_hash_password,
session_cookie as build_session_cookie_header,
};
use crank_core::{User, UserSessionId, WorkspaceId};
use crank_core::{MembershipRole, User, UserSessionId, WorkspaceId};
use crank_registry::WorkspaceMembershipRecord;
use serde::Serialize;
use crate::{error::ApiError, state::AppState};
pub use crank_community_auth::{
SESSION_COOKIE_NAME, SessionCookie, extract_session_token, hash_session_secret, verify_password,
SESSION_COOKIE_NAME, SessionCookie, create_csrf_token, extract_session_token, hash_csrf_token,
hash_session_secret, verify_password,
};
#[derive(Clone)]
@@ -53,6 +55,10 @@ pub fn create_session_cookie(settings: &AuthSettings) -> Result<SessionCookie, A
.map_err(|error| ApiError::internal(format!("failed to create session cookie: {error}")))
}
pub fn create_csrf_token_value() -> String {
build_csrf_token()
}
pub fn session_cookie(settings: &AuthSettings, token: &str) -> Cookie<'static> {
build_session_cookie_header(token, settings.cookie_secure, settings.session_ttl_hours)
}
@@ -90,11 +96,81 @@ pub async fn require_workspace_session(
if !has_access {
return Err(ApiError::forbidden("workspace access denied"));
}
if !matches!(
request.method(),
&axum::http::Method::GET | &axum::http::Method::HEAD
) && !session.memberships.iter().any(|membership| {
membership.workspace.id == workspace_id && membership.role == MembershipRole::Owner
}) {
return Err(ApiError::forbidden("workspace owner access required"));
}
request.extensions_mut().insert(session);
Ok(next.run(request).await)
}
pub async fn require_csrf_for_browser_mutations(
State(state): State<AppState>,
jar: CookieJar,
request: Request,
next: Next,
) -> Result<Response, ApiError> {
if !requires_csrf(request.method(), request.uri().path()) {
return Ok(next.run(request).await);
}
let (session_id, _session_value) = extract_session_token(&jar)
.ok_or_else(|| ApiError::unauthorized("authentication required"))?;
let token = request
.headers()
.get("x-csrf-token")
.and_then(|value| value.to_str().ok())
.filter(|value| valid_csrf_token(value))
.ok_or_else(|| ApiError::forbidden("csrf validation failed"))?;
let csrf_hash = hash_csrf_token(
&session_id,
token,
&state.service.auth_settings().session_secret,
);
if !state
.service
.verify_session_csrf(&session_id, &csrf_hash)
.await?
{
return Err(ApiError::forbidden("csrf validation failed"));
}
Ok(next.run(request).await)
}
pub async fn enforce_browser_security(
State(state): State<AppState>,
request: Request,
next: Next,
) -> Result<Response, ApiError> {
if let Some(origin) = request.headers().get(header::ORIGIN)
&& is_cross_origin(
origin,
request.headers().get(header::HOST),
if state.service.auth_settings().cookie_secure {
"https"
} else {
"http"
},
)
&& request.uri().path().starts_with("/api/")
{
return Err(ApiError::forbidden("cross-origin admin request denied"));
}
let mut response = next.run(request).await;
let headers = response.headers_mut();
headers.insert(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
headers.insert("x-frame-options", HeaderValue::from_static("DENY"));
headers.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
Ok(response)
}
async fn resolve_authenticated_session(
state: AppState,
jar: &CookieJar,
@@ -122,3 +198,55 @@ fn workspace_id_from_path(path: &str) -> Option<WorkspaceId> {
Some(WorkspaceId::new(workspace_id))
}
fn requires_csrf(method: &Method, path: &str) -> bool {
if matches!(method, &Method::GET | &Method::HEAD | &Method::OPTIONS) {
return false;
}
if matches!(
path,
"/api/auth/login" | "/api/auth/bootstrap/complete" | "/api/auth/session/csrf"
) {
return false;
}
path.starts_with("/api/auth/") || path.starts_with("/api/admin/")
}
fn valid_csrf_token(value: &str) -> bool {
(32..=256).contains(&value.len())
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
}
fn is_cross_origin(
origin: &HeaderValue,
host: Option<&HeaderValue>,
expected_scheme: &str,
) -> bool {
let Some(host) = host.and_then(|value| value.to_str().ok()) else {
return true;
};
let Some(origin) = origin.to_str().ok() else {
return true;
};
let Ok(url) = url::Url::parse(origin) else {
return true;
};
url.host_str()
.zip(url.port_or_known_default())
.map(|(origin_host, origin_port)| {
let origin_authority = format!("{}://{origin_host}:{origin_port}", url.scheme());
let request_authority = if host.contains(':') {
format!("{expected_scheme}://{}", host.to_ascii_lowercase())
} else {
let default_port = if expected_scheme == "https" { 443 } else { 80 };
format!(
"{expected_scheme}://{}:{default_port}",
host.to_ascii_lowercase()
)
};
origin_authority.to_ascii_lowercase() != request_authority
})
.unwrap_or(true)
}
+758 -48
View File
@@ -1,15 +1,19 @@
use std::{process::ExitCode, time::Duration};
#[path = "crank_migrate/admin_auth_command.rs"]
mod admin_auth_command;
#[path = "crank_migrate/db_connect.rs"]
mod db_connect;
use crank_config::{ConfigSource, DatabaseSettings, parse_migrator};
use crank_config::{ConfigSource, parse_migrator};
use crank_registry::{
BackfillPolicy, MigrationApplyResult, MigrationAuthority, MigrationPreflight,
BackfillPolicy, MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate,
MasterKeyRotationRecord, MigrationApplyResult, MigrationAuthority, MigrationPreflight,
PostgresRegistry, RegistryError, SecretVersionRecord,
};
use crank_runtime::SecretCrypto;
use serde_json::json;
use sqlx::{
PgPool,
postgres::{PgConnectOptions, PgPoolOptions},
};
use std::{path::Path, process::ExitCode};
use time::OffsetDateTime;
const MASTER_KEY_ROTATION_PAGE_SIZE: i64 = 1_000;
#[tokio::main]
async fn main() -> ExitCode {
match run().await {
@@ -37,7 +41,6 @@ struct CliError {
recovery: &'static str,
version: Option<i64>,
}
impl CliError {
const fn new(code: &'static str, stage: &'static str, recovery: &'static str) -> Self {
Self {
@@ -47,7 +50,6 @@ impl CliError {
version: None,
}
}
fn from_migration(error: crank_registry::MigrationError) -> Self {
Self {
code: error.code(),
@@ -56,20 +58,87 @@ impl CliError {
version: error.version(),
}
}
fn from_registry(error: RegistryError) -> Self {
match error {
RegistryError::MasterKeyIdentityMismatch { .. } => Self::new(
"master_key_identity_mismatch",
"master_key.identity",
"use_matching_master_key",
),
RegistryError::InvalidMasterKeyIdentity => Self::new(
"master_key_identity_invalid",
"master_key.identity",
"verify_operator_input",
),
RegistryError::MasterKeyRotationInProgress => Self::new(
"master_key_rotation_in_progress",
"master_key.rotation",
"resume_verify_promote_or_abort_rotation",
),
RegistryError::MasterKeyRotationNotFound { .. } => Self::new(
"master_key_rotation_not_found",
"master_key.rotation",
"run_status",
),
RegistryError::MasterKeyRotationConflict => Self::new(
"master_key_rotation_conflict",
"master_key.rotation",
"run_status",
),
RegistryError::MasterKeyRotationVerificationFailed => Self::new(
"master_key_rotation_verification_failed",
"master_key.rotation",
"rerun_rotation_or_abort",
),
RegistryError::AdminBootstrapUnavailable => Self::new(
"admin_bootstrap_unavailable",
"admin_auth.bootstrap",
"use_existing_active_contract_or_wait_until_expired",
),
RegistryError::AdminBootstrapRejected => Self::new(
"admin_bootstrap_rejected",
"admin_auth.bootstrap",
"create_new_local_bootstrap_contract",
),
RegistryError::AdminRecoveryRejected => Self::new(
"admin_recovery_rejected",
"admin_auth.recovery",
"verify_local_inputs_and_master_key",
),
RegistryError::AdminLoginRateLimited { .. } => Self::new(
"admin_login_rate_limited",
"admin_auth.login",
"retry_after_delay",
),
RegistryError::AdminCsrfRejected => {
Self::new("admin_csrf_rejected", "admin_auth.csrf", "refresh_session")
}
RegistryError::Storage(_) => {
Self::new("storage_unavailable", "database.query", "contact_operator")
}
_ => Self::new("registry_error", "registry.operation", "contact_operator"),
}
}
}
async fn run() -> Result<ExitCode, CliError> {
let mut arguments = std::env::args().skip(1);
let requested = arguments.next();
let option = arguments.next();
if arguments.next().is_some() {
let args = std::env::args().skip(1).collect::<Vec<_>>();
if args.first().map(String::as_str) == Some("master-key") {
return run_master_key(&args[1..]).await;
}
if args.first().map(String::as_str) == Some("admin-auth") {
return admin_auth_command::run_admin_auth(&args[1..]).await;
}
let requested = args.first().map(String::as_str);
let option = args.get(1).map(String::as_str);
if args.len() > 2 {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"run_preflight",
));
}
let command = match requested.as_deref() {
let command = match requested {
None | Some("preflight") => "preflight",
Some("plan") => "plan",
Some("apply") => "apply",
@@ -117,7 +186,7 @@ async fn run() -> Result<ExitCode, CliError> {
})
.collect::<Vec<_>>();
let plan = json!({ "schema_version": 1, "sequence": sequence });
if option.as_deref() == Some("--check") {
if option == Some("--check") {
let bytes = std::fs::read("docs/schemas/migration-sequence.json")
.map_err(|_| CliError::new("contract_drift", "plan.read", "contact_operator"))?;
if bytes.len() > 65_536 {
@@ -162,7 +231,7 @@ async fn run() -> Result<ExitCode, CliError> {
.map_err(|_| CliError::new("config_invalid", "config.source", "run_preflight"))?,
)
.map_err(|_| CliError::new("config_invalid", "config.validate", "run_preflight"))?;
let pool = connect(&config.database).await?;
let pool = db_connect::connect(&config.database).await?;
if command == "apply" {
let result = MigrationAuthority::apply(&pool)
@@ -204,39 +273,680 @@ async fn run() -> Result<ExitCode, CliError> {
}
}
async fn connect(config: &DatabaseSettings) -> Result<PgPool, CliError> {
let options = if let Some(url) = &config.url {
url.expose_secret()
.parse::<PgConnectOptions>()
.map_err(|_| CliError::new("config_invalid", "database.source", "run_preflight"))?
} else {
PgConnectOptions::new()
.host(&config.host)
.port(config.port)
.database(&config.database)
.username(&config.username)
.password(config.password.expose_secret())
async fn run_master_key(arguments: &[String]) -> Result<ExitCode, CliError> {
let Some(command) = arguments.first().map(String::as_str) else {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"run_master_key_status",
));
};
for attempt in 1..=10 {
let result = PgPoolOptions::new()
.max_connections(config.pool.max_connections)
.min_connections(config.pool.min_connections)
.acquire_timeout(Duration::from_millis(config.pool.acquire_timeout_ms))
.idle_timeout(Duration::from_millis(config.pool.idle_timeout_ms))
.max_lifetime(Duration::from_millis(config.pool.max_lifetime_ms))
.connect_with(options.clone())
.await;
match result {
Ok(pool) => return Ok(pool),
Err(_) if attempt < 10 => {
tokio::time::sleep(Duration::from_secs(1)).await;
let options = MasterKeyOptions::parse(&arguments[1..])?;
let config = parse_migrator(
ConfigSource::from_os_for_migrator()
.map_err(|_| CliError::new("config_invalid", "config.source", "run_preflight"))?,
)
.map_err(|_| CliError::new("config_invalid", "config.validate", "run_preflight"))?;
let registry = db_connect::connect_registry(&config.database).await?;
match command {
"status" => {
ensure_no_options(&options)?;
let status = registry
.master_key_rotation_status()
.await
.map_err(CliError::from_registry)?;
println!(
"{}",
json!({
"status": "ok",
"active_epoch": status.active_identity.as_ref().map(|identity| identity.epoch),
"rotation_count": status.rotations.len(),
"rotations": status.rotations.iter().map(rotation_json).collect::<Vec<_>>(),
})
);
}
"preflight" => {
let preflight = master_key_preflight(&registry, &options).await?;
println!(
"{}",
json!({
"status": "preflight_ok",
"source_epoch": preflight.source_epoch,
"target_epoch": preflight.target_epoch,
"affected_secret_versions": preflight.affected_secret_versions,
"backup_ref": preflight.backup_ref.map(|_| "configured"),
})
);
}
"rotate" => {
let (rotation, current_crypto, target_crypto) =
if let Some(rotation) = active_rotation(&registry, &["running"]).await? {
let (current_crypto, target_crypto) =
rotation_crypto_for_resume(&registry, &options, &rotation).await?;
(rotation, current_crypto, target_crypto)
} else {
let preflight = master_key_preflight(&registry, &options).await?;
let now = OffsetDateTime::now_utc();
let rotation = registry
.begin_master_key_rotation(
preflight.source_epoch,
preflight.target_epoch,
preflight.target_crypto.master_key_fingerprint(),
preflight.backup_ref.as_deref(),
&now,
)
.await
.map_err(CliError::from_registry)?;
(rotation, preflight.current_crypto, preflight.target_crypto)
};
let processed = process_rotation_batch(
&registry,
&rotation,
&current_crypto,
&target_crypto,
options.max_versions,
)
.await?;
let status = if rotation.processed_secret_versions + processed
>= rotation.total_secret_versions
{
let now = OffsetDateTime::now_utc();
registry
.finish_master_key_rotation_batches(&rotation.id, &now)
.await
.map_err(CliError::from_registry)?
} else {
registry
.master_key_rotation_status()
.await
.map_err(CliError::from_registry)?
.rotations
.into_iter()
.find(|candidate| candidate.id == rotation.id)
.ok_or_else(|| {
CliError::new(
"master_key_rotation_not_found",
"master_key.rotation",
"run_status",
)
})?
};
println!(
"{}",
json!({
"status": status.state,
"rotation_id": status.id,
"source_epoch": status.source_epoch,
"target_epoch": status.target_epoch,
"processed_secret_versions": status.processed_secret_versions,
"total_secret_versions": status.total_secret_versions,
})
);
}
"verify" => {
let target_key = read_required_key_file(options.target_key_file.as_deref())?;
let rotation = active_rotation(&registry, &["verifying"])
.await?
.ok_or_else(|| {
CliError::new(
"master_key_rotation_conflict",
"master_key.rotation",
"run_rotate",
)
})?;
let target_crypto = SecretCrypto::with_epoch(&target_key, rotation.target_epoch)
.map_err(|_| {
CliError::new(
"master_key_invalid",
"master_key.input",
"verify_operator_input",
)
})?;
if target_crypto.master_key_fingerprint() != rotation.target_fingerprint {
return Err(CliError::new(
"master_key_identity_mismatch",
"master_key.identity",
"use_matching_target_key",
));
}
Err(_) => break,
let verified = verify_staged_targets(&registry, &rotation, &target_crypto).await?;
let now = OffsetDateTime::now_utc();
let status = registry
.verify_master_key_rotation(&rotation.id, verified, &now)
.await
.map_err(CliError::from_registry)?;
println!(
"{}",
json!({
"status": status.state,
"rotation_id": status.id,
"verified_secret_versions": status.verified_secret_versions,
"total_secret_versions": status.total_secret_versions,
})
);
}
"promote" => {
let target_key = read_required_key_file(options.target_key_file.as_deref())?;
let rotation = active_rotation(&registry, &["verified"])
.await?
.ok_or_else(|| {
CliError::new(
"master_key_rotation_conflict",
"master_key.rotation",
"run_verify",
)
})?;
let target_crypto = SecretCrypto::with_epoch(&target_key, rotation.target_epoch)
.map_err(|_| {
CliError::new(
"master_key_invalid",
"master_key.input",
"verify_operator_input",
)
})?;
if target_crypto.master_key_fingerprint() != rotation.target_fingerprint {
return Err(CliError::new(
"master_key_identity_mismatch",
"master_key.identity",
"use_matching_target_key",
));
}
verify_staged_targets(&registry, &rotation, &target_crypto).await?;
let now = OffsetDateTime::now_utc();
let status = registry
.promote_master_key_rotation(
&rotation.id,
MasterKeyIdentityCandidate {
epoch: rotation.target_epoch,
fingerprint: target_crypto.master_key_fingerprint(),
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &now,
},
&now,
)
.await
.map_err(CliError::from_registry)?;
println!(
"{}",
json!({
"status": status.state,
"rotation_id": status.id,
"active_epoch": status.target_epoch,
})
);
}
"abort" => {
let rotation_id = options
.rotation_id
.as_deref()
.ok_or_else(|| CliError::new("invalid_command", "cli.arguments", "run_status"))?;
let now = OffsetDateTime::now_utc();
let status = registry
.abort_master_key_rotation(rotation_id, &now)
.await
.map_err(CliError::from_registry)?;
println!(
"{}",
json!({
"status": status.state,
"rotation_id": status.id,
"active_epoch": status.source_epoch,
})
);
}
_ => {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"run_master_key_status",
));
}
}
Err(CliError::new(
"storage_unavailable",
"database.connect",
"contact_operator",
))
Ok(ExitCode::SUCCESS)
}
#[derive(Default)]
struct MasterKeyOptions {
current_key_file: Option<String>,
target_key_file: Option<String>,
backup_ref: Option<String>,
rotation_id: Option<String>,
max_versions: Option<usize>,
}
impl MasterKeyOptions {
fn parse(arguments: &[String]) -> Result<Self, CliError> {
let mut options = Self::default();
let mut index = 0;
while index < arguments.len() {
let key = arguments[index].as_str();
let Some(value) = arguments.get(index + 1) else {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"run_master_key_status",
));
};
match key {
"--current-key-file" => options.current_key_file = Some(value.clone()),
"--target-key-file" => options.target_key_file = Some(value.clone()),
"--backup-ref" => options.backup_ref = Some(value.clone()),
"--rotation-id" => options.rotation_id = Some(value.clone()),
"--max-versions" => {
let parsed = value.parse::<usize>().map_err(|_| {
CliError::new("invalid_command", "cli.arguments", "run_master_key_status")
})?;
if parsed == 0 || parsed > 10_000 {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"run_master_key_status",
));
}
options.max_versions = Some(parsed);
}
_ => {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"run_master_key_status",
));
}
}
index += 2;
}
Ok(options)
}
}
struct MasterKeyPreflight {
source_epoch: i64,
target_epoch: i64,
affected_secret_versions: usize,
backup_ref: Option<String>,
current_crypto: SecretCrypto,
target_crypto: SecretCrypto,
}
async fn master_key_preflight(
registry: &PostgresRegistry,
options: &MasterKeyOptions,
) -> Result<MasterKeyPreflight, CliError> {
let current_key = read_required_key_file(options.current_key_file.as_deref())?;
let target_key = read_required_key_file(options.target_key_file.as_deref())?;
let status = registry
.master_key_rotation_status()
.await
.map_err(CliError::from_registry)?;
if status.rotations.iter().any(|rotation| {
matches!(
rotation.state.as_str(),
"running" | "verifying" | "verified"
)
}) {
return Err(CliError::new(
"master_key_rotation_in_progress",
"master_key.rotation",
"resume_verify_promote_or_abort_rotation",
));
}
let active = status.active_identity.ok_or_else(|| {
CliError::new(
"master_key_identity_missing",
"master_key.identity",
"start_secret_process_once",
)
})?;
let current_crypto = SecretCrypto::with_epoch(&current_key, active.epoch).map_err(|_| {
CliError::new(
"master_key_invalid",
"master_key.input",
"verify_operator_input",
)
})?;
if current_crypto.master_key_fingerprint() != active.fingerprint {
return Err(CliError::new(
"master_key_identity_mismatch",
"master_key.identity",
"use_matching_current_key",
));
}
let target_epoch = active.epoch + 1;
let target_crypto = SecretCrypto::with_epoch(&target_key, target_epoch).map_err(|_| {
CliError::new(
"master_key_invalid",
"master_key.input",
"verify_operator_input",
)
})?;
if target_crypto.master_key_fingerprint() == current_crypto.master_key_fingerprint()
|| registry
.master_key_fingerprint_exists(target_crypto.master_key_fingerprint())
.await
.map_err(CliError::from_registry)?
{
return Err(CliError::new(
"master_key_rotation_conflict",
"master_key.rotation",
"choose_new_target_key",
));
}
let affected_secret_versions =
count_and_verify_current_versions(registry, active.epoch, &current_crypto).await?;
Ok(MasterKeyPreflight {
source_epoch: active.epoch,
target_epoch,
affected_secret_versions,
backup_ref: options.backup_ref.clone(),
current_crypto,
target_crypto,
})
}
async fn rotation_crypto_for_resume(
registry: &PostgresRegistry,
options: &MasterKeyOptions,
rotation: &MasterKeyRotationRecord,
) -> Result<(SecretCrypto, SecretCrypto), CliError> {
let current_key = read_required_key_file(options.current_key_file.as_deref())?;
let target_key = read_required_key_file(options.target_key_file.as_deref())?;
let active = registry
.active_master_key_identity()
.await
.map_err(CliError::from_registry)?
.ok_or_else(|| {
CliError::new(
"master_key_identity_missing",
"master_key.identity",
"start_secret_process_once",
)
})?;
let current_crypto =
SecretCrypto::with_epoch(&current_key, rotation.source_epoch).map_err(|_| {
CliError::new(
"master_key_invalid",
"master_key.input",
"verify_operator_input",
)
})?;
if active.epoch != rotation.source_epoch
|| active.fingerprint != current_crypto.master_key_fingerprint()
{
return Err(CliError::new(
"master_key_identity_mismatch",
"master_key.identity",
"use_matching_current_key",
));
}
let target_crypto =
SecretCrypto::with_epoch(&target_key, rotation.target_epoch).map_err(|_| {
CliError::new(
"master_key_invalid",
"master_key.input",
"verify_operator_input",
)
})?;
if target_crypto.master_key_fingerprint() != rotation.target_fingerprint {
return Err(CliError::new(
"master_key_identity_mismatch",
"master_key.identity",
"use_matching_target_key",
));
}
Ok((current_crypto, target_crypto))
}
async fn process_rotation_batch(
registry: &PostgresRegistry,
rotation: &MasterKeyRotationRecord,
current_crypto: &SecretCrypto,
target_crypto: &SecretCrypto,
max_versions: Option<usize>,
) -> Result<i64, CliError> {
let mut processed = 0_i64;
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(
rotation.source_epoch,
after_secret_id.as_deref(),
after_version,
MASTER_KEY_ROTATION_PAGE_SIZE,
)
.await
.map_err(CliError::from_registry)?;
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);
if version.target_master_key_epoch == Some(rotation.target_epoch) {
continue;
}
if max_versions.is_some_and(|limit| processed as usize >= limit) {
return Ok(processed);
}
let plaintext = decrypt_current(&version, current_crypto)?;
let target_ciphertext = target_crypto.encrypt(&plaintext).map_err(|_| {
CliError::new(
"master_key_rotation_verification_failed",
"master_key.rotation",
"rerun_rotation_or_abort",
)
})?;
let now = OffsetDateTime::now_utc();
registry
.stage_master_key_rotation_ciphertext(
&rotation.id,
&version.secret_version.secret_id,
version.secret_version.version,
rotation.source_epoch,
&target_ciphertext,
target_crypto.key_version(),
rotation.target_epoch,
&now,
)
.await
.map_err(CliError::from_registry)?;
processed += 1;
}
}
Ok(processed)
}
async fn verify_staged_targets(
registry: &PostgresRegistry,
rotation: &MasterKeyRotationRecord,
target_crypto: &SecretCrypto,
) -> Result<i64, CliError> {
let mut verified = 0_i64;
let mut after_secret_id: Option<String> = None;
let mut after_version: Option<u32> = None;
loop {
let versions = registry
.list_target_secret_versions_for_master_key_rotation_page(
rotation.target_epoch,
after_secret_id.as_deref(),
after_version,
MASTER_KEY_ROTATION_PAGE_SIZE,
)
.await
.map_err(CliError::from_registry)?;
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);
let ciphertext = version.target_ciphertext.as_deref().ok_or_else(|| {
CliError::new(
"master_key_rotation_verification_failed",
"master_key.rotation",
"rerun_rotation_or_abort",
)
})?;
let key_version = version.target_key_version.as_deref().ok_or_else(|| {
CliError::new(
"master_key_rotation_verification_failed",
"master_key.rotation",
"rerun_rotation_or_abort",
)
})?;
target_crypto
.decrypt_for_epoch(key_version, rotation.target_epoch, ciphertext)
.map_err(|_| {
CliError::new(
"master_key_rotation_verification_failed",
"master_key.rotation",
"rerun_rotation_or_abort",
)
})?;
verified += 1;
}
}
if verified != rotation.total_secret_versions {
return Err(CliError::new(
"master_key_rotation_verification_failed",
"master_key.rotation",
"rerun_rotation_or_abort",
));
}
Ok(verified)
}
async fn count_and_verify_current_versions(
registry: &PostgresRegistry,
epoch: i64,
current_crypto: &SecretCrypto,
) -> Result<usize, CliError> {
let mut count = 0_usize;
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(
epoch,
after_secret_id.as_deref(),
after_version,
MASTER_KEY_ROTATION_PAGE_SIZE,
)
.await
.map_err(CliError::from_registry)?;
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);
decrypt_current(&version, current_crypto)?;
count += 1;
}
}
Ok(count)
}
fn decrypt_current(
version: &SecretVersionRecord,
current_crypto: &SecretCrypto,
) -> Result<serde_json::Value, CliError> {
current_crypto
.decrypt_for_epoch(
&version.secret_version.key_version,
version.master_key_epoch,
&version.secret_version.ciphertext,
)
.map_err(|_| {
CliError::new(
"master_key_rotation_verification_failed",
"master_key.rotation",
"rerun_rotation_or_abort",
)
})
}
async fn active_rotation(
registry: &PostgresRegistry,
allowed_states: &[&str],
) -> Result<Option<MasterKeyRotationRecord>, CliError> {
let status = registry
.master_key_rotation_status()
.await
.map_err(CliError::from_registry)?;
Ok(status
.rotations
.into_iter()
.find(|rotation| allowed_states.contains(&rotation.state.as_str())))
}
fn rotation_json(rotation: &MasterKeyRotationRecord) -> serde_json::Value {
json!({
"id": rotation.id,
"source_epoch": rotation.source_epoch,
"target_epoch": rotation.target_epoch,
"state": rotation.state,
"backup_ref": rotation.backup_ref.as_ref().map(|_| "configured"),
"checkpoint_secret_id": rotation.checkpoint_secret_id,
"total_secret_versions": rotation.total_secret_versions,
"processed_secret_versions": rotation.processed_secret_versions,
"verified_secret_versions": rotation.verified_secret_versions,
"failure_code": rotation.failure_code,
})
}
fn read_required_key_file(path: Option<&str>) -> Result<String, CliError> {
let path = path.ok_or_else(|| {
CliError::new("invalid_command", "cli.arguments", "run_master_key_status")
})?;
if path.len() > 512 || path.bytes().any(|byte| byte.is_ascii_control()) {
return Err(CliError::new(
"master_key_input_invalid",
"master_key.input",
"verify_operator_input",
));
}
let metadata = std::fs::metadata(Path::new(path)).map_err(|_| {
CliError::new(
"master_key_input_invalid",
"master_key.input",
"verify_operator_input",
)
})?;
if !metadata.is_file() || metadata.len() > 16_384 {
return Err(CliError::new(
"master_key_input_invalid",
"master_key.input",
"verify_operator_input",
));
}
let value = std::fs::read_to_string(Path::new(path)).map_err(|_| {
CliError::new(
"master_key_input_invalid",
"master_key.input",
"verify_operator_input",
)
})?;
if value.trim().is_empty() {
return Err(CliError::new(
"master_key_input_invalid",
"master_key.input",
"verify_operator_input",
));
}
Ok(value)
}
fn ensure_no_options(options: &MasterKeyOptions) -> Result<(), CliError> {
if options.current_key_file.is_some()
|| options.target_key_file.is_some()
|| options.backup_ref.is_some()
|| options.rotation_id.is_some()
|| options.max_versions.is_some()
{
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"run_master_key_status",
));
}
Ok(())
}
@@ -0,0 +1,342 @@
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use crank_community_auth::hash_password;
use crank_config::{ConfigSource, parse_migrator};
use crank_registry::{
CreateAdminBootstrapContractRequest, MASTER_KEY_CIPHER_CONTRACT, PostgresRegistry,
RecoverAdminPasswordRequest,
};
use crank_runtime::SecretCrypto;
use rand::RngExt;
use serde_json::json;
use sha2::{Digest, Sha256};
use std::{
path::{Path, PathBuf},
process::ExitCode,
};
use time::{Duration as TimeDuration, OffsetDateTime};
use super::{CliError, db_connect::connect_registry};
pub(super) async fn run_admin_auth(arguments: &[String]) -> Result<ExitCode, CliError> {
let Some(command) = arguments.first().map(String::as_str) else {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"run_admin_auth_bootstrap_create",
));
};
let options = AdminAuthOptions::parse(&arguments[1..])?;
let config = parse_migrator(
ConfigSource::from_os_for_migrator()
.map_err(|_| CliError::new("config_invalid", "config.source", "run_preflight"))?,
)
.map_err(|_| CliError::new("config_invalid", "config.validate", "run_preflight"))?;
let registry = connect_registry(&config.database).await?;
match command {
"bootstrap-create" => create_bootstrap_contract(&registry, options).await?,
"bootstrap-complete" => complete_bootstrap_contract(&registry, options).await?,
"recover" => recover_admin_password(&registry, options).await?,
_ => {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"run_admin_auth_bootstrap_create",
));
}
}
Ok(ExitCode::SUCCESS)
}
async fn complete_bootstrap_contract(
registry: &PostgresRegistry,
options: AdminAuthOptions,
) -> Result<(), CliError> {
let token_path = options
.token_file
.as_deref()
.ok_or_else(|| CliError::new("invalid_command", "cli.arguments", "provide_token_file"))?;
let password_path = options.password_file.as_deref().ok_or_else(|| {
CliError::new("invalid_command", "cli.arguments", "provide_password_file")
})?;
let pepper_path = options.password_pepper_file.as_deref().ok_or_else(|| {
CliError::new(
"invalid_command",
"cli.arguments",
"provide_password_pepper_file",
)
})?;
let token = read_secret_file(token_path, "admin_auth.bootstrap_token")?;
let password = read_secret_file(password_path, "admin_auth.password")?;
let pepper = read_secret_file(pepper_path, "admin_auth.password_pepper")?;
if !(32..=256).contains(&token.len())
|| !(12..=256).contains(&password.len())
|| pepper.is_empty()
|| pepper.len() > 1_024
{
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"provide_bounded_secret_files",
));
}
let token_hash = admin_auth_hash("bootstrap", &token);
let password_hash = hash_password(&password, &pepper).map_err(|_| {
CliError::new(
"admin_bootstrap_rejected",
"admin_auth.bootstrap",
"verify_local_inputs",
)
})?;
let now = OffsetDateTime::now_utc();
let user_id = registry
.consume_admin_bootstrap_contract(crank_registry::ConsumeAdminBootstrapContractRequest {
token_hash: &token_hash,
password_hash: &password_hash,
now: &now,
})
.await
.map_err(CliError::from_registry)?;
println!(
"{}",
json!({
"status": "bootstrap_completed",
"user_id": user_id.as_str()
})
);
Ok(())
}
async fn recover_admin_password(
registry: &PostgresRegistry,
options: AdminAuthOptions,
) -> Result<(), CliError> {
let email = options
.email
.as_deref()
.ok_or_else(|| CliError::new("invalid_command", "cli.arguments", "provide_email"))?;
let password_path = options.password_file.as_deref().ok_or_else(|| {
CliError::new("invalid_command", "cli.arguments", "provide_password_file")
})?;
let pepper_path = options.password_pepper_file.as_deref().ok_or_else(|| {
CliError::new(
"invalid_command",
"cli.arguments",
"provide_password_pepper_file",
)
})?;
let master_key_path = options.master_key_file.as_deref().ok_or_else(|| {
CliError::new(
"invalid_command",
"cli.arguments",
"provide_master_key_file",
)
})?;
let master_key = read_secret_file(master_key_path, "master_key.input")?;
let active = registry
.active_master_key_identity()
.await
.map_err(CliError::from_registry)?
.ok_or_else(|| {
CliError::new(
"master_key_identity_missing",
"master_key.identity",
"start_service_once_with_current_master_key",
)
})?;
let crypto = SecretCrypto::with_epoch(&master_key, active.epoch).map_err(|_| {
CliError::new(
"master_key_invalid",
"master_key.input",
"provide_current_master_key_file",
)
})?;
if active.cipher_contract != MASTER_KEY_CIPHER_CONTRACT
|| active.fingerprint != crypto.master_key_fingerprint()
{
return Err(CliError::new(
"master_key_identity_mismatch",
"master_key.identity",
"use_matching_master_key",
));
}
let password = read_secret_file(password_path, "admin_auth.password")?;
let pepper = read_secret_file(pepper_path, "admin_auth.password_pepper")?;
if !(12..=256).contains(&password.len()) || pepper.is_empty() || pepper.len() > 1_024 {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"provide_bounded_secret_files",
));
}
let password_hash = hash_password(&password, &pepper).map_err(|_| {
CliError::new(
"admin_recovery_rejected",
"admin_auth.recovery",
"verify_local_inputs",
)
})?;
let audit_id = format!("audit_{}", uuid::Uuid::now_v7().simple());
let user_id = registry
.recover_admin_password(RecoverAdminPasswordRequest {
email,
password_hash: &password_hash,
audit_id: &audit_id,
})
.await
.map_err(CliError::from_registry)?;
println!(
"{}",
json!({
"status": "admin_recovered",
"user_id": user_id.as_str(),
"sessions_revoked": true,
"audit_id": audit_id
})
);
Ok(())
}
async fn create_bootstrap_contract(
registry: &PostgresRegistry,
options: AdminAuthOptions,
) -> Result<(), CliError> {
let email = options
.email
.as_deref()
.ok_or_else(|| CliError::new("invalid_command", "cli.arguments", "provide_email"))?;
let display_name = options.display_name.as_deref().unwrap_or("Crank Owner");
let ttl_seconds = options.ttl_seconds.unwrap_or(900);
if !(60..=86_400).contains(&ttl_seconds) {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"set_ttl_between_60_and_86400",
));
}
let token = random_token();
let contract_id = format!("boot_{}", uuid::Uuid::now_v7().simple());
let token_hash = admin_auth_hash("bootstrap", &token);
let now = OffsetDateTime::now_utc();
let expires_at = now
.checked_add(TimeDuration::seconds(ttl_seconds))
.ok_or_else(|| CliError::new("invalid_command", "cli.arguments", "reduce_ttl"))?;
let contract = registry
.create_admin_bootstrap_contract(CreateAdminBootstrapContractRequest {
id: &contract_id,
token_hash: &token_hash,
email,
display_name,
expires_at: &expires_at,
})
.await
.map_err(CliError::from_registry)?;
println!(
"{}",
json!({
"status": "bootstrap_created",
"contract_id": contract.id,
"expires_at": contract.expires_at,
"bootstrap_token": token,
"warning": "copy_once_token_not_logged_by_services"
})
);
Ok(())
}
#[derive(Default)]
struct AdminAuthOptions {
email: Option<String>,
display_name: Option<String>,
ttl_seconds: Option<i64>,
token_file: Option<PathBuf>,
password_file: Option<PathBuf>,
password_pepper_file: Option<PathBuf>,
master_key_file: Option<PathBuf>,
}
impl AdminAuthOptions {
fn parse(arguments: &[String]) -> Result<Self, CliError> {
let mut options = Self::default();
let mut index = 0;
while index < arguments.len() {
let key = arguments[index].as_str();
let Some(value) = arguments.get(index + 1) else {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"run_admin_auth_bootstrap_create",
));
};
match key {
"--email" => options.email = Some(value.clone()),
"--display-name" => options.display_name = Some(value.clone()),
"--ttl-seconds" => {
options.ttl_seconds = Some(value.parse::<i64>().map_err(|_| {
CliError::new("invalid_command", "cli.arguments", "set_ttl_seconds")
})?);
}
"--password-file" => options.password_file = Some(PathBuf::from(value)),
"--token-file" => options.token_file = Some(PathBuf::from(value)),
"--password-pepper-file" => {
options.password_pepper_file = Some(PathBuf::from(value));
}
"--master-key-file" => options.master_key_file = Some(PathBuf::from(value)),
_ => {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"run_admin_auth_bootstrap_create",
));
}
}
index += 2;
}
Ok(options)
}
}
fn read_secret_file(path: &Path, stage: &'static str) -> Result<String, CliError> {
let metadata = std::fs::metadata(path)
.map_err(|_| CliError::new("invalid_command", stage, "provide_readable_secret_file"))?;
if !metadata.is_file() || metadata.len() > 8_192 {
return Err(CliError::new(
"invalid_command",
stage,
"provide_bounded_secret_file",
));
}
let bytes = std::fs::read(path)
.map_err(|_| CliError::new("invalid_command", stage, "provide_readable_secret_file"))?;
if bytes.len() > 8_192 {
return Err(CliError::new(
"invalid_command",
stage,
"provide_bounded_secret_file",
));
}
let mut value = String::from_utf8(bytes)
.map_err(|_| CliError::new("invalid_command", stage, "provide_utf8_secret_file"))?;
if value.ends_with("\r\n") {
value.truncate(value.len() - 2);
} else if value.ends_with('\n') {
value.truncate(value.len() - 1);
}
Ok(value)
}
fn random_token() -> String {
let mut bytes = [0_u8; 32];
rand::rng().fill(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
fn admin_auth_hash(scope: &str, token: &str) -> String {
let mut digest = Sha256::new();
digest.update(scope.as_bytes());
digest.update(b":");
digest.update(token.as_bytes());
URL_SAFE_NO_PAD.encode(digest.finalize())
}
@@ -0,0 +1,78 @@
use crank_config::DatabaseSettings;
use crank_registry::{PostgresPoolConfig, PostgresRegistry, RegistryError};
use sqlx::{
PgPool,
postgres::{PgConnectOptions, PgPoolOptions},
};
use std::time::Duration;
use super::CliError;
pub(super) async fn connect(config: &DatabaseSettings) -> Result<PgPool, CliError> {
let options = connect_options(config)?;
for attempt in 1..=10 {
let result = PgPoolOptions::new()
.max_connections(config.pool.max_connections)
.min_connections(config.pool.min_connections)
.acquire_timeout(Duration::from_millis(config.pool.acquire_timeout_ms))
.idle_timeout(Duration::from_millis(config.pool.idle_timeout_ms))
.max_lifetime(Duration::from_millis(config.pool.max_lifetime_ms))
.connect_with(options.clone())
.await;
match result {
Ok(pool) => return Ok(pool),
Err(_) if attempt < 10 => tokio::time::sleep(Duration::from_secs(1)).await,
Err(_) => break,
}
}
Err(CliError::new(
"storage_unavailable",
"database.connect",
"contact_operator",
))
}
pub(super) async fn connect_registry(
config: &DatabaseSettings,
) -> Result<PostgresRegistry, CliError> {
let options = connect_options(config)?;
let pool_config = PostgresPoolConfig {
max_connections: config.pool.max_connections,
min_connections: config.pool.min_connections,
acquire_timeout_ms: config.pool.acquire_timeout_ms,
idle_timeout_ms: config.pool.idle_timeout_ms,
max_lifetime_ms: config.pool.max_lifetime_ms,
};
for attempt in 1..=10 {
let result =
PostgresRegistry::connect_with_options_and_pool_config(options.clone(), pool_config)
.await;
match result {
Ok(registry) => return Ok(registry),
Err(RegistryError::Storage(_)) if attempt < 10 => {
tokio::time::sleep(Duration::from_secs(1)).await;
}
Err(error) => return Err(CliError::from_registry(error)),
}
}
Err(CliError::new(
"storage_unavailable",
"database.connect",
"contact_operator",
))
}
fn connect_options(config: &DatabaseSettings) -> Result<PgConnectOptions, CliError> {
if let Some(url) = &config.url {
url.expose_secret()
.parse::<PgConnectOptions>()
.map_err(|_| CliError::new("config_invalid", "database.source", "run_preflight"))
} else {
Ok(PgConnectOptions::new()
.host(&config.host)
.port(config.port)
.database(&config.database)
.username(&config.username)
.password(config.password.expose_secret()))
}
}
+168 -6
View File
@@ -1,13 +1,15 @@
use crank_core::{
AgentId, AgentStatus, ApprovalRequestStatus, AuthConfig, AuthKind, ExecutionMode, ExportMode,
GeneratedDraft, InvocationLevel, InvocationSource, InvocationStatus, OperationSecurityLevel,
OperationStatus, PlatformApiKeyKind, PlatformApiKeyScope, Protocol, SecretKind, Target,
ToolSelectionPolicy, UsagePeriod, WizardState, WorkspaceId, WorkspaceStatus,
GeneratedDraft, InvocationLevel, InvocationSource, InvocationStatus, OperationAvailability,
OperationSecurityLevel, OperationStatus, OperationVersionState, PlatformApiKeyKind,
PlatformApiKeyScope, Protocol, SecretKind, Target, ToolSelectionPolicy, UsagePeriod,
WizardState, WorkspaceId, WorkspaceStatus,
};
use crank_mapping::MappingSet;
use crank_registry::{
PlatformApiKeyRecord, RegistryOperation, UsageAgentBreakdown, UsageOperationBreakdown,
UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord,
InvocationLogRecord, PlatformApiKeyRecord, RegistryOperation, UsageAgentBreakdown,
UsageOperationBreakdown, UsageOutcomeGroup, UsageSummary, UsageTimelinePoint,
WorkspaceMembershipRecord, WorkspaceRecord,
};
use crank_schema::Schema;
use serde::{Deserialize, Serialize};
@@ -19,11 +21,23 @@ pub struct LoginPayload {
pub password: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct CompleteBootstrapPayload {
pub token: String,
pub password: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct BootstrapStatusResponse {
pub bootstrap_required: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct SessionResponse {
pub user: crank_core::User,
pub memberships: Vec<WorkspaceMembershipRecord>,
pub current_workspace_id: Option<String>,
pub csrf_token: String,
}
#[derive(Clone, Debug, Deserialize)]
@@ -80,12 +94,19 @@ pub struct PublishPayload {
pub struct TestRunPayload {
pub version: u32,
pub input: Value,
#[serde(default)]
pub confirmation_token: Option<String>,
#[serde(default)]
pub locale: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct TestRunResult {
pub ok: bool,
pub mode: ExecutionMode,
pub tested_version: u32,
pub request_id: String,
pub trace_id: String,
pub request_preview: Value,
pub response_preview: Value,
pub errors: Vec<Value>,
@@ -228,6 +249,7 @@ pub struct AgentSummaryView {
pub status: AgentStatus,
pub current_draft_version: u32,
pub latest_published_version: Option<u32>,
pub catalog_revision: i64,
pub created_at: String,
pub updated_at: String,
pub published_at: Option<String>,
@@ -266,6 +288,91 @@ fn default_platform_api_key_kind() -> PlatformApiKeyKind {
pub struct CreatedPlatformApiKeyResponse {
pub api_key: PlatformApiKeyRecord,
pub secret: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub connection: Option<EphemeralMcpConnection>,
}
#[derive(Clone, Debug, Serialize)]
pub struct EphemeralMcpClientConfig {
pub client: String,
pub config: Value,
}
#[derive(Clone, Debug, Serialize)]
pub struct EphemeralMcpConnection {
pub endpoint: String,
pub clients: Vec<EphemeralMcpClientConfig>,
pub secret_display: &'static str,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OnboardingEventPayload {
pub event: String,
pub idempotency_key: String,
pub expected_revision: i64,
#[serde(default)]
pub completed_steps: Option<Value>,
}
#[derive(Clone, Debug, Serialize)]
pub struct OnboardingStepView {
pub id: crank_core::OnboardingStepId,
pub completed: bool,
pub status: String,
pub action_code: String,
pub reason_code: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct OnboardingFirstCallEvidence {
pub log_id: String,
pub agent_id: String,
pub key_id: String,
pub operation_id: String,
pub operation_version: u32,
pub tool_name: String,
pub occurred_at: String,
pub request_id: Option<String>,
pub trace_id: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct OnboardingResponse {
pub schema_version: u16,
pub workspace_id: String,
pub revision: i64,
pub status: String,
pub completed: bool,
pub eligible_since: Option<String>,
pub steps: Vec<OnboardingStepView>,
pub operation_id: Option<String>,
pub operation_version: Option<u32>,
pub agent_id: Option<String>,
pub catalog_revision: Option<i64>,
pub platform_api_key_id: Option<String>,
pub mcp_endpoint: Option<String>,
pub first_call: Option<OnboardingFirstCallEvidence>,
}
#[derive(Clone, Debug, Serialize)]
pub struct OnboardingEventResponse {
pub accepted: bool,
#[serde(flatten)]
pub onboarding: OnboardingResponse,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ResetOnboardingSelectionPayload {
pub expected_revision: i64,
}
#[derive(Clone, Debug, Serialize)]
pub struct ResetOnboardingSelectionResponse {
pub selection_reset: bool,
#[serde(flatten)]
pub onboarding: OnboardingResponse,
}
#[derive(Clone, Debug, Serialize)]
@@ -285,11 +392,16 @@ pub struct WorkspaceCatalogSnapshotResponse {
#[derive(Clone, Debug, Deserialize)]
pub struct LogsQuery {
pub level: Option<InvocationLevel>,
pub status: Option<InvocationStatus>,
pub outcome_group: Option<UsageOutcomeGroup>,
pub search: Option<String>,
pub source: Option<InvocationSource>,
pub operation_id: Option<String>,
pub agent_id: Option<String>,
pub period: Option<UsagePeriod>,
pub created_after: Option<String>,
pub created_before: Option<String>,
pub cursor: Option<String>,
pub limit: Option<u32>,
}
@@ -299,10 +411,19 @@ pub struct ApprovalsQuery {
pub limit: Option<u32>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct ApprovalDecisionPayload {
pub approve: String,
#[serde(default)]
pub note: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct UsageRequestQuery {
pub period: Option<UsagePeriod>,
pub source: Option<InvocationSource>,
pub created_after: Option<String>,
pub created_before: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
@@ -311,6 +432,13 @@ pub struct UsageOverviewResponse {
pub timeline: Vec<UsageTimelinePoint>,
pub operations: Vec<UsageOperationBreakdown>,
pub agents: Vec<UsageAgentBreakdown>,
pub outcomes: Vec<crank_registry::UsageOutcomeBreakdown>,
}
#[derive(Clone, Debug, Serialize)]
pub struct LogsListResponse {
pub items: Vec<InvocationLogRecord>,
pub next_cursor: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
@@ -362,7 +490,35 @@ pub struct ExportQuery {
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct YamlOperationDocument {
pub format_version: String,
pub kind: String,
pub operation: PortableOperation,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct PortableOperation {
pub name: String,
pub display_name: String,
#[serde(default = "default_operation_category")]
pub category: String,
pub protocol: Protocol,
#[serde(default)]
pub security_level: OperationSecurityLevel,
pub target: Target,
pub input_schema: Schema,
pub output_schema: Schema,
pub input_mapping: MappingSet,
pub output_mapping: MappingSet,
pub execution_config: crank_core::ExecutionConfig,
pub tool_description: crank_core::ToolDescription,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LegacyYamlOperationDocument {
pub format_version: String,
pub kind: String,
pub operation: RegistryOperation,
@@ -473,6 +629,7 @@ pub struct OperationSummaryView {
pub status: OperationStatus,
pub current_draft_version: u32,
pub latest_published_version: Option<u32>,
pub can_delete: bool,
pub created_at: String,
pub updated_at: String,
pub published_at: Option<String>,
@@ -490,6 +647,7 @@ pub struct OperationDetailView {
pub protocol: Protocol,
pub security_level: OperationSecurityLevel,
pub status: OperationStatus,
pub availability: OperationAvailability,
pub current_draft_version: u32,
pub latest_published_version: Option<u32>,
pub created_at: String,
@@ -503,7 +661,7 @@ pub struct OperationDetailView {
#[derive(Clone, Debug, Serialize)]
pub struct VersionRef {
pub version: u32,
pub status: OperationStatus,
pub status: OperationVersionState,
}
#[derive(Clone, Debug, Serialize)]
@@ -558,6 +716,10 @@ pub(crate) struct InvocationRecordRequest<'a> {
pub message: String,
pub status_code: Option<u16>,
pub error_kind: Option<String>,
pub execution_stage: Option<crank_core::ExecutionStage>,
pub execution_error_code: Option<crank_core::ExecutionErrorCode>,
pub retryability: Option<crank_core::Retryability>,
pub outcome_certainty: Option<crank_core::OutcomeCertainty>,
pub duration_ms: u64,
pub request_preview: Value,
pub response_preview: Value,
+257 -114
View File
@@ -31,6 +31,11 @@ pub enum ApiError {
context: Option<Value>,
},
#[error("{message}")]
Unprocessable {
message: String,
context: Option<Value>,
},
#[error("{message}")]
NotFound {
message: String,
context: Option<Value>,
@@ -41,6 +46,16 @@ pub enum ApiError {
context: Option<Value>,
},
#[error("{message}")]
PayloadTooLarge {
message: String,
context: Option<Value>,
},
#[error("{message}")]
PreconditionRequired {
message: String,
context: Option<Value>,
},
#[error("{message}")]
RateLimited {
message: String,
context: Option<Value>,
@@ -95,6 +110,13 @@ impl ApiError {
}
}
pub(crate) fn unprocessable_with_context(message: impl Into<String>, context: Value) -> Self {
Self::Unprocessable {
message: message.into(),
context: Some(context),
}
}
pub(crate) fn not_found_with_context(message: impl Into<String>, context: Value) -> Self {
Self::NotFound {
message: message.into(),
@@ -109,25 +131,58 @@ impl ApiError {
}
}
pub(crate) fn payload_too_large_with_context(
message: impl Into<String>,
context: Value,
) -> Self {
Self::PayloadTooLarge {
message: message.into(),
context: Some(context),
}
}
pub(crate) fn precondition_required_with_context(
message: impl Into<String>,
context: Value,
) -> Self {
Self::PreconditionRequired {
message: message.into(),
context: Some(context),
}
}
fn status_code(&self) -> StatusCode {
match self {
Self::Unauthorized { .. } => StatusCode::UNAUTHORIZED,
Self::Forbidden { .. } => StatusCode::FORBIDDEN,
Self::Validation { .. } => StatusCode::BAD_REQUEST,
Self::Unprocessable { .. } => StatusCode::UNPROCESSABLE_ENTITY,
Self::NotFound { .. } => StatusCode::NOT_FOUND,
Self::Conflict { .. } => StatusCode::CONFLICT,
Self::PayloadTooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE,
Self::PreconditionRequired { .. } => StatusCode::PRECONDITION_REQUIRED,
Self::RateLimited { .. } => StatusCode::TOO_MANY_REQUESTS,
Self::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn code(&self) -> &'static str {
pub(crate) fn code(&self) -> &str {
if let Some(code) = self
.context_ref()
.and_then(|context| context.get("error_code"))
.and_then(Value::as_str)
{
return code;
}
match self {
Self::Unauthorized { .. } => "unauthorized",
Self::Forbidden { .. } => "forbidden",
Self::Validation { .. } => "validation_error",
Self::Unprocessable { .. } => "unprocessable_entity",
Self::NotFound { .. } => "not_found",
Self::Conflict { .. } => "conflict",
Self::PayloadTooLarge { .. } => "payload_too_large",
Self::PreconditionRequired { .. } => "precondition_required",
Self::RateLimited { .. } => "rate_limited",
Self::Internal { .. } => "internal_error",
}
@@ -147,8 +202,11 @@ impl IntoResponse for ApiError {
Self::Unauthorized { .. }
| Self::Forbidden { .. }
| Self::Validation { .. }
| Self::Unprocessable { .. }
| Self::NotFound { .. }
| Self::Conflict { .. }
| Self::PayloadTooLarge { .. }
| Self::PreconditionRequired { .. }
| Self::RateLimited { .. } => {
warn!(
name: "admin.response.rejected",
@@ -182,17 +240,24 @@ impl IntoResponse for ApiError {
}
impl ApiError {
fn context(&self) -> Option<Value> {
fn context_ref(&self) -> Option<&Value> {
match self {
Self::Unauthorized { context, .. }
| Self::Forbidden { context, .. }
| Self::Validation { context, .. }
| Self::Unprocessable { context, .. }
| Self::NotFound { context, .. }
| Self::Conflict { context, .. }
| Self::PayloadTooLarge { context, .. }
| Self::PreconditionRequired { context, .. }
| Self::RateLimited { context, .. }
| Self::Internal { context, .. } => context.clone(),
| Self::Internal { context, .. } => context.as_ref(),
}
}
fn context(&self) -> Option<Value> {
self.context_ref().cloned()
}
}
impl From<RegistryError> for ApiError {
@@ -228,10 +293,41 @@ impl From<RegistryError> for ApiError {
format!("platform api key {key_id} was not found"),
json!({ "key_id": key_id }),
),
RegistryError::PlatformApiKeyInactive { key_id } => Self::conflict_with_context(
"platform api key is not active",
json!({
"key_id": key_id,
"error_code": "platform_api_key_not_active",
"recovery": "create_replacement_key"
}),
),
RegistryError::SecretNotFound { secret_id } => Self::not_found_with_context(
format!("secret {secret_id} was not found"),
json!({ "secret_id": secret_id }),
),
RegistryError::SecretInactive { secret_id } => Self::conflict_with_context(
"secret is not active",
json!({
"secret_id": secret_id,
"error_code": "secret_not_active",
"recovery": "rotate_or_replace_secret"
}),
),
RegistryError::SecretConcurrentUpdate { secret_id } => Self::conflict_with_context(
"secret was updated concurrently",
json!({
"secret_id": secret_id,
"error_code": "secret_concurrent_update",
"recovery": "reload"
}),
),
RegistryError::MasterKeyRotationInProgress => Self::conflict_with_context(
"master key rotation is in progress",
json!({
"error_code": "master_key_rotation_in_progress",
"recovery": "retry_after_rotation"
}),
),
RegistryError::InvocationLogNotFound { log_id } => Self::not_found_with_context(
format!("invocation log {log_id} was not found"),
json!({ "log_id": log_id }),
@@ -268,6 +364,57 @@ impl From<RegistryError> for ApiError {
json!({ "operation_id": operation_id }),
)
}
RegistryError::OperationArchived { operation_id } => Self::conflict_with_context(
format!("operation {operation_id} is archived"),
json!({ "operation_id": operation_id, "error_code": "operation_archived" }),
),
RegistryError::OperationStaleVersion {
operation_id,
expected,
actual,
} => Self::conflict_with_context(
format!("operation {operation_id} has a stale base version"),
json!({
"operation_id": operation_id,
"current_version": expected,
"provided_version": actual,
"error_code": "operation_stale_version",
"recovery": "reload"
}),
),
RegistryError::InvalidOperationTransition {
operation_id,
from,
action,
} => Self::conflict_with_context(
format!("operation {operation_id} cannot perform {action} from {from}"),
json!({
"operation_id": operation_id,
"state": from,
"action": action,
"error_code": "operation_invalid_transition"
}),
),
RegistryError::OperationDeleteForbidden { operation_id } => {
Self::conflict_with_context(
format!(
"operation {operation_id} cannot be deleted because durable history exists"
),
json!({
"operation_id": operation_id,
"error_code": "operation_delete_forbidden"
}),
)
}
RegistryError::OperationAuthProfileUnavailable { operation_id } => {
Self::unprocessable_with_context(
"operation auth profile reference is unavailable",
json!({
"operation_id": operation_id,
"error_code": "operation_auth_profile_invalid"
}),
)
}
RegistryError::AuthProfileNotFound { auth_profile_id } => Self::not_found_with_context(
format!("auth profile {auth_profile_id} was not found"),
json!({ "auth_profile_id": auth_profile_id }),
@@ -276,6 +423,17 @@ impl From<RegistryError> for ApiError {
format!("operation {operation_id} already exists"),
json!({ "operation_id": operation_id }),
),
RegistryError::PlatformApiKeyNameAlreadyExists { workspace_id, name } => {
Self::conflict_with_context(
"platform api key name already exists",
json!({
"workspace_id": workspace_id,
"name": name,
"error_code": "platform_api_key_name_conflict",
"recovery": "choose_different_name"
}),
)
}
RegistryError::WorkspaceSlugAlreadyExists { slug } => Self::conflict_with_context(
format!("workspace with slug {slug} already exists"),
json!({ "slug": slug }),
@@ -286,6 +444,8 @@ impl From<RegistryError> for ApiError {
json!({
"workspace_id": workspace_id,
"name": name,
"error_code": "secret_name_conflict",
"recovery": "choose_different_name"
}),
)
}
@@ -297,12 +457,29 @@ impl From<RegistryError> for ApiError {
json!({
"secret_id": secret_id,
"auth_profile_id": auth_profile_id,
"error_code": "secret_referenced_by_auth_profile",
"recovery": "remove_or_update_auth_profile_reference"
}),
),
RegistryError::UserEmailAlreadyExists { email } => Self::conflict_with_context(
format!("user with email {email} already exists"),
json!({ "email": email }),
),
RegistryError::AdminBootstrapUnavailable | RegistryError::AdminBootstrapRejected => {
Self::unauthorized("bootstrap request is invalid or expired")
}
RegistryError::AdminRecoveryRejected => Self::unauthorized("recovery request rejected"),
RegistryError::AdminLoginRateLimited { retry_after_ms } => {
Self::rate_limited_with_context(
"login temporarily unavailable",
json!({
"retry_after_ms": retry_after_ms.clamp(1, 300_000),
"error_code": "login_throttled",
"recovery": "retry_after_delay"
}),
)
}
RegistryError::AdminCsrfRejected => Self::forbidden("csrf validation failed"),
RegistryError::InvalidInitialVersion {
operation_id,
version,
@@ -337,6 +514,39 @@ impl From<RegistryError> for ApiError {
"actual": actual,
}),
),
RegistryError::ImmutableAgentVersion { agent_id, version } => {
Self::conflict_with_context(
"Agent Version is immutable; reload before editing",
json!({
"agent_id": agent_id,
"version": version,
"error_code": "agent_stale_revision",
"recovery": "reload"
}),
)
}
RegistryError::AgentStaleRevision { agent_id } => Self::conflict_with_context(
"Agent state changed; reload before retrying",
json!({
"agent_id": agent_id,
"error_code": "agent_stale_revision",
"recovery": "reload"
}),
),
RegistryError::InvalidAgentTransition {
agent_id,
from,
action,
} => Self::conflict_with_context(
format!("agent {agent_id} cannot transition from {from} using {action}"),
json!({
"agent_id": agent_id,
"from": from,
"action": action,
"error_code": "agent_invalid_transition",
"recovery": "reload"
}),
),
RegistryError::ImmutableOperationFieldChanged {
operation_id,
field,
@@ -347,6 +557,14 @@ impl From<RegistryError> for ApiError {
"field": field,
}),
),
RegistryError::OnboardingStaleRevision => Self::conflict_with_context(
"onboarding state changed; reload before retrying",
json!({"error_code":"onboarding_stale_revision","recovery":"reload"}),
),
RegistryError::OnboardingIncomplete => Self::unprocessable_with_context(
"onboarding domain steps are not complete",
json!({"error_code":"onboarding_incomplete"}),
),
RegistryError::InvalidEnumRepresentation { field } => Self::validation_with_context(
format!("unsupported enum representation for field {field}"),
json!({ "field": field }),
@@ -370,10 +588,18 @@ impl From<RegistryError> for ApiError {
format!("import job {job_id} was already applied with different parameters"),
json!({ "job_id": job_id }),
),
RegistryError::Storage(_) => Self::internal("registry operation failed"),
RegistryError::Migration(_)
| RegistryError::Storage(_)
| RegistryError::Serialization(_)
| RegistryError::InvalidCorrelationIdentity { .. } => Self::internal(value.to_string()),
| RegistryError::MasterKeyIdentityMismatch { .. }
| RegistryError::InvalidMasterKeyIdentity
| RegistryError::MasterKeyRotationNotFound { .. }
| RegistryError::MasterKeyRotationConflict
| RegistryError::MasterKeyRotationVerificationFailed
| RegistryError::InvalidCorrelationIdentity { .. }
| RegistryError::InvalidExecutionRecord { .. } => {
Self::internal("registry operation failed")
}
}
}
}
@@ -405,117 +631,34 @@ impl From<StorageError> for ApiError {
}
pub fn runtime_test_failure(error: &RuntimeError) -> Value {
let failure =
crank_runtime::normalize_runtime_error(error, &crank_core::CorrelationContext::generate());
execution_test_failure(&failure)
}
pub fn execution_test_failure(failure: &crank_core::ExecutionFailure) -> Value {
execution_test_failure_localized(failure, crank_core::ExecutionLocale::En)
}
pub fn execution_test_failure_localized(
failure: &crank_core::ExecutionFailure,
locale: crank_core::ExecutionLocale,
) -> Value {
let mut payload = json!({
"code": runtime_test_failure_code(error),
"message": safe_runtime_test_failure_message(error)
"code": failure.error_code().as_str(),
"message": failure.error_code().message(locale),
"stage": failure.stage().as_str(),
"retryability": failure.retryability().as_str(),
"outcome_certainty": failure.outcome_certainty().as_str(),
});
if let Some(context) = runtime_error_context(error) {
payload["context"] = context;
if let Some(status) = failure.upstream_status() {
payload["context"] = json!({ "upstream_status": status });
}
if let Some(challenge) = failure.confirmation() {
payload["context"] = json!({
"confirmation_token": challenge.token(),
"expires_in_ms": challenge.expires_in_ms(),
});
}
payload
}
fn safe_runtime_test_failure_message(error: &RuntimeError) -> &'static str {
match error {
RuntimeError::Schema(_) => "input schema validation failed",
RuntimeError::Mapping(_) => "input mapping failed",
RuntimeError::RestAdapter(_) | RuntimeError::ProtocolAdapter(_) => {
"upstream execution failed"
}
RuntimeError::UnsupportedProtocol { .. } => "operation protocol is unsupported",
RuntimeError::ConcurrencyLimitExceeded { .. } => "runtime concurrency limit exceeded",
RuntimeError::InvalidPreparedRequest { .. } => "prepared request is invalid",
RuntimeError::ConfirmationRequired { .. } => "operation confirmation is required",
RuntimeError::InvalidConfirmationToken { .. } => "confirmation token is invalid",
RuntimeError::ConfirmationStoreUnavailable { .. } => "confirmation store is unavailable",
RuntimeError::IdempotencyStoreUnavailable { .. } => "idempotency store is unavailable",
RuntimeError::IdempotencyInProgress { .. } => "idempotent execution is in progress",
RuntimeError::IdempotencyConflict { .. } => "idempotency key conflicts with the request",
RuntimeError::IdempotencyOutcomeUnknown { .. } => "previous execution outcome is unknown",
RuntimeError::UnsupportedExecutionMode { .. } => "execution mode is unsupported",
RuntimeError::MissingAuthProfile { .. } => "authorization profile is missing",
RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => {
"authorization secret is missing"
}
RuntimeError::InvalidAuthSecretValue { .. } => "authorization secret is invalid",
RuntimeError::SecretCrypto { .. } => "authorization secret processing failed",
}
}
fn runtime_test_failure_code(error: &RuntimeError) -> &'static str {
match error {
RuntimeError::Schema(_) => "runtime_schema_error",
RuntimeError::Mapping(_) => "runtime_mapping_error",
RuntimeError::RestAdapter(_) => "runtime_rest_error",
RuntimeError::ProtocolAdapter(_) => "runtime_adapter_error",
RuntimeError::UnsupportedProtocol { .. } => "runtime_protocol_error",
RuntimeError::ConcurrencyLimitExceeded { .. } => "runtime_overloaded",
RuntimeError::InvalidPreparedRequest { .. } => "runtime_request_error",
RuntimeError::ConfirmationRequired { .. } => "runtime_confirmation_required",
RuntimeError::InvalidConfirmationToken { .. } => "runtime_confirmation_error",
RuntimeError::ConfirmationStoreUnavailable { .. } => "runtime_confirmation_unavailable",
RuntimeError::IdempotencyStoreUnavailable { .. } => "runtime_idempotency_unavailable",
RuntimeError::IdempotencyInProgress { .. } => "runtime_idempotency_in_progress",
RuntimeError::IdempotencyConflict { .. } => "runtime_idempotency_conflict",
RuntimeError::IdempotencyOutcomeUnknown { .. } => "runtime_idempotency_outcome_unknown",
RuntimeError::UnsupportedExecutionMode { .. } => "runtime_streaming_mode_error",
RuntimeError::MissingAuthProfile { .. } => "runtime_auth_profile_error",
RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => {
"runtime_secret_error"
}
RuntimeError::InvalidAuthSecretValue { .. } => "runtime_secret_value_error",
RuntimeError::SecretCrypto { .. } => "runtime_secret_crypto_error",
}
}
pub fn runtime_error_context(error: &RuntimeError) -> Option<Value> {
match error {
RuntimeError::InvalidPreparedRequest { field, .. } => Some(json!({
"field": field,
})),
RuntimeError::ConfirmationRequired {
confirmation_token,
expires_in_ms,
safety_class,
..
} => Some(json!({
"confirmation_token": confirmation_token,
"expires_in_ms": expires_in_ms,
"safety_class": safety_class,
})),
RuntimeError::InvalidConfirmationToken { operation_id }
| RuntimeError::ConfirmationStoreUnavailable { operation_id }
| RuntimeError::IdempotencyStoreUnavailable { operation_id }
| RuntimeError::IdempotencyInProgress { operation_id }
| RuntimeError::IdempotencyConflict { operation_id }
| RuntimeError::IdempotencyOutcomeUnknown { operation_id } => Some(json!({
"operation_id": operation_id,
})),
RuntimeError::InvalidAuthSecretValue { secret_id, .. } => Some(json!({
"secret_id": secret_id,
})),
RuntimeError::SecretCrypto { .. } => None,
RuntimeError::MissingAuthProfile { auth_profile_id } => Some(json!({
"auth_profile_id": auth_profile_id,
})),
RuntimeError::MissingSecret { secret_id } => Some(json!({
"secret_id": secret_id,
})),
RuntimeError::MissingSecretVersion { secret_id, version } => Some(json!({
"secret_id": secret_id,
"version": version,
})),
RuntimeError::UnsupportedExecutionMode { operation_id, mode } => Some(json!({
"operation_id": operation_id,
"mode": mode,
})),
RuntimeError::UnsupportedProtocol { protocol } => Some(json!({
"protocol": protocol,
})),
RuntimeError::ConcurrencyLimitExceeded { kind, limit } => Some(json!({
"kind": kind,
"limit": limit,
})),
_ => None,
}
}
+108 -10
View File
@@ -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(&registry, 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",
+39 -15
View File
@@ -10,20 +10,23 @@ use crank_runtime::{RateLimitCheckError, RateLimitRejection};
use crate::{error::ApiError, state::AppState};
#[derive(Clone, Debug)]
pub struct ClientIdentityBucket(pub String);
pub async fn apply_api_rate_limit(
State(state): State<AppState>,
request: Request,
mut request: Request,
next: Next,
) -> Result<Response, ApiError> {
let peer_ip = request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|ConnectInfo(address)| address.ip());
let key = rate_limit_key(
let key = client_rate_limit_key(
request.headers(),
request.uri().path(),
peer_ip,
state.trust_forwarded_headers,
&state.trusted_proxy_ips,
);
if let Err(error) = state.api_rate_limiter.check(&key).await {
return match error {
@@ -37,6 +40,8 @@ pub async fn apply_api_rate_limit(
};
}
request.extensions_mut().insert(ClientIdentityBucket(key));
Ok(next.run(request).await)
}
@@ -46,13 +51,15 @@ fn rejection_context(rejection: RateLimitRejection) -> serde_json::Value {
})
}
fn rate_limit_key(
pub fn client_rate_limit_key(
headers: &HeaderMap,
path: &str,
peer_ip: Option<IpAddr>,
trust_forwarded_headers: bool,
trusted_proxy_ips: &[IpAddr],
) -> String {
if trust_forwarded_headers && let Some(client_ip) = forwarded_client_ip(headers) {
if peer_ip.is_some_and(|peer_ip| trusted_proxy_ips.contains(&peer_ip))
&& let Some(client_ip) = forwarded_client_ip(headers)
{
return format!("ip:{client_ip}");
}
@@ -63,7 +70,8 @@ fn rate_limit_key(
format!("anonymous:{path}")
}
/// Resolves the client IP from proxy headers, assuming a single trusted proxy.
/// Resolves the client IP from proxy headers after the immediate peer was
/// matched against the trusted-proxy allowlist.
///
/// `X-Real-IP` is preferred because a trusted proxy (e.g. nginx) sets it to the
/// real peer address. For `X-Forwarded-For` the proxy *appends* the observed
@@ -96,7 +104,7 @@ mod tests {
use axum::http::{HeaderMap, HeaderValue, header::COOKIE};
use super::rate_limit_key;
use super::client_rate_limit_key;
fn peer() -> Option<IpAddr> {
Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)))
@@ -112,7 +120,7 @@ mod tests {
headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.5"));
assert_eq!(
rate_limit_key(&headers, "/api/auth/login", peer(), true),
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]),
"ip:10.0.0.5"
);
}
@@ -124,7 +132,23 @@ mod tests {
headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.9"));
assert_eq!(
rate_limit_key(&headers, "/api/auth/login", peer(), false),
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[]),
"ip:203.0.113.7"
);
}
#[test]
fn ignores_forwarded_headers_from_unlisted_peer() {
let mut headers = HeaderMap::new();
headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.9"));
assert_eq!(
client_rate_limit_key(
&headers,
"/api/auth/login",
peer(),
&[IpAddr::V4(Ipv4Addr::new(198, 51, 100, 10))]
),
"ip:203.0.113.7"
);
}
@@ -139,7 +163,7 @@ mod tests {
headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.9"));
assert_eq!(
rate_limit_key(&headers, "/api/auth/login", peer(), true),
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]),
"ip:10.0.0.9"
);
}
@@ -155,7 +179,7 @@ mod tests {
);
assert_eq!(
rate_limit_key(&headers, "/api/auth/login", peer(), true),
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]),
"ip:10.0.0.6"
);
}
@@ -165,7 +189,7 @@ mod tests {
let headers = HeaderMap::new();
assert_eq!(
rate_limit_key(&headers, "/api/auth/login", peer(), true),
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]),
"ip:203.0.113.7"
);
}
@@ -180,7 +204,7 @@ mod tests {
);
assert_eq!(
rate_limit_key(&headers, "/api/auth/login", peer(), true),
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]),
"ip:203.0.113.7"
);
}
@@ -190,7 +214,7 @@ mod tests {
let headers = HeaderMap::new();
assert_eq!(
rate_limit_key(&headers, "/api/auth/login", None, false),
client_rate_limit_key(&headers, "/api/auth/login", None, &[]),
"anonymous:/api/auth/login"
);
}
+1
View File
@@ -5,6 +5,7 @@ pub mod auth_profiles;
pub mod capabilities;
pub mod imports;
pub mod observability;
pub mod onboarding;
pub mod operations;
pub mod secrets;
pub mod upstreams;
+92 -5
View File
@@ -1,15 +1,19 @@
use axum::{
Json,
Extension, Json,
extract::{Path, State},
http::{HeaderMap, header},
response::IntoResponse,
};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::{
auth::AuthenticatedSession,
error::ApiError,
request_context::RequestContext,
service::{
AgentCatalogPayload, AgentPayload, PlatformApiKeyPayload, PublishPayload,
ToolSearchPreviewPayload, UpdateAgentPayload,
AdminAuditContext, AgentCatalogPayload, AgentPayload, PlatformApiKeyPayload,
PublishPayload, ToolSearchPreviewPayload, UpdateAgentPayload,
},
state::AppState,
};
@@ -77,7 +81,7 @@ pub async fn create_agent(
pub async fn get_agent(
Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
) -> Result<impl IntoResponse, ApiError> {
let agent = state
.service
.get_agent(
@@ -85,20 +89,30 @@ pub async fn get_agent(
&path.agent_id.as_str().into(),
)
.await?;
Ok(Json(json!(agent)))
let etag = crate::service::AdminService::agent_state_etag(&agent);
let mut headers = HeaderMap::new();
headers.insert(
header::ETAG,
header::HeaderValue::from_str(&etag)
.map_err(|_| ApiError::internal("invalid agent etag"))?,
);
Ok((headers, Json(json!(agent))))
}
pub async fn update_agent(
Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<UpdateAgentPayload>,
) -> Result<Json<Value>, ApiError> {
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
let updated = state
.service
.update_agent(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
payload,
Some(&expected_state),
)
.await?;
Ok(Json(json!(updated)))
@@ -107,12 +121,15 @@ pub async fn update_agent(
pub async fn delete_agent(
Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, ApiError> {
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
let deleted = state
.service
.delete_agent(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
Some(&expected_state),
)
.await?;
Ok(Json(json!(deleted)))
@@ -136,30 +153,79 @@ pub async fn get_agent_version(
pub async fn save_agent_bindings(
Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<AgentCatalogPayload>,
) -> Result<Json<Value>, ApiError> {
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
let record = state
.service
.save_agent_bindings(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
payload,
Some(&expected_state),
)
.await?;
Ok(Json(json!(record)))
}
async fn require_agent_precondition(
state: &AppState,
path: &WorkspaceAgentPath,
headers: &HeaderMap,
) -> Result<crank_registry::AgentStateExpectation, ApiError> {
let agent = state
.service
.get_agent(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
)
.await?;
let expected = crate::service::AdminService::agent_state_etag(&agent);
let provided = headers
.get(header::IF_MATCH)
.and_then(|value| value.to_str().ok())
.ok_or_else(|| {
ApiError::precondition_required_with_context(
"If-Match is required for Agent mutation",
json!({
"error_code": "agent_precondition_required",
"current_version": agent.current_draft_version,
"latest_published_version": agent.latest_published_version,
"catalog_revision": agent.catalog_revision,
"recovery": "reload"
}),
)
})?;
if provided != expected {
return Err(ApiError::conflict_with_context(
"Agent state changed; reload before retrying",
json!({
"error_code": "agent_stale_revision",
"current_version": agent.current_draft_version,
"latest_published_version": agent.latest_published_version,
"catalog_revision": agent.catalog_revision,
"recovery": "reload"
}),
));
}
crate::service::AdminService::agent_state_expectation(&agent)
}
pub async fn publish_agent(
Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<PublishPayload>,
) -> Result<Json<Value>, ApiError> {
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
let published = state
.service
.publish_agent(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
payload.version,
Some(&expected_state),
)
.await?;
Ok(Json(json!(published)))
@@ -168,12 +234,15 @@ pub async fn publish_agent(
pub async fn unpublish_agent(
Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, ApiError> {
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
let updated = state
.service
.unpublish_agent(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
Some(&expected_state),
)
.await?;
Ok(Json(json!(updated)))
@@ -182,12 +251,15 @@ pub async fn unpublish_agent(
pub async fn archive_agent(
Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, ApiError> {
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
let updated = state
.service
.archive_agent(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
Some(&expected_state),
)
.await?;
Ok(Json(json!(updated)))
@@ -210,14 +282,19 @@ pub async fn list_agent_platform_api_keys(
pub async fn create_agent_platform_api_key(
Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Extension(request_context): Extension<RequestContext>,
Json(payload): Json<PlatformApiKeyPayload>,
) -> Result<Json<Value>, ApiError> {
let audit_context =
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
let created = state
.service
.create_agent_platform_api_key(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
payload,
Some(&audit_context),
)
.await?;
Ok(Json(json!(created)))
@@ -226,13 +303,18 @@ pub async fn create_agent_platform_api_key(
pub async fn revoke_agent_platform_api_key(
Path(path): Path<WorkspaceAgentPlatformApiKeyPath>,
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Extension(request_context): Extension<RequestContext>,
) -> Result<axum::http::StatusCode, ApiError> {
let audit_context =
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
state
.service
.revoke_agent_platform_api_key(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
&path.key_id.as_str().into(),
Some(&audit_context),
)
.await?;
Ok(axum::http::StatusCode::NO_CONTENT)
@@ -241,13 +323,18 @@ pub async fn revoke_agent_platform_api_key(
pub async fn delete_agent_platform_api_key(
Path(path): Path<WorkspaceAgentPlatformApiKeyPath>,
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Extension(request_context): Extension<RequestContext>,
) -> Result<axum::http::StatusCode, ApiError> {
let audit_context =
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
state
.service
.delete_agent_platform_api_key(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
&path.key_id.as_str().into(),
Some(&audit_context),
)
.await?;
Ok(axum::http::StatusCode::NO_CONTENT)
+53 -2
View File
@@ -5,18 +5,52 @@ use serde_json::json;
use crate::{
auth::{AuthenticatedSession, cleared_session_cookie, extract_session_token, session_cookie},
error::ApiError,
rate_limit::ClientIdentityBucket,
service::{
ChangePasswordPayload, LoginPayload, UpdateCurrentWorkspacePayload, UpdateProfilePayload,
ChangePasswordPayload, CompleteBootstrapPayload, LoginPayload,
UpdateCurrentWorkspacePayload, UpdateProfilePayload,
},
state::AppState,
};
pub async fn bootstrap_status(
State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, ApiError> {
Ok(Json(serde_json::json!(
state.service.bootstrap_status().await?
)))
}
pub async fn complete_bootstrap(
State(state): State<AppState>,
jar: CookieJar,
Json(payload): Json<CompleteBootstrapPayload>,
) -> Result<impl IntoResponse, ApiError> {
let (session_data, session) = state.service.complete_bootstrap(payload).await?;
let cookie_value = format!(
"{}.{}",
session_data.session_id.as_str(),
session_data.value
);
let jar = jar.add(session_cookie(state.service.auth_settings(), &cookie_value));
Ok((jar, Json(serde_json::json!(session))))
}
pub async fn login(
State(state): State<AppState>,
jar: CookieJar,
client_bucket: Option<Extension<ClientIdentityBucket>>,
Json(payload): Json<LoginPayload>,
) -> Result<impl IntoResponse, ApiError> {
let (session_data, session) = state.service.login(payload).await?;
let client_bucket = client_bucket
.as_ref()
.map(|Extension(bucket)| bucket.0.as_str())
.unwrap_or("anonymous:/api/auth/login");
let (session_data, session) = state
.service
.login_with_client_bucket(payload, client_bucket)
.await?;
let cookie_value = format!(
"{}.{}",
session_data.session_id.as_str(),
@@ -54,6 +88,22 @@ pub async fn get_session(
Ok(Json(serde_json::json!(session)))
}
pub async fn refresh_session_csrf(
State(state): State<AppState>,
jar: CookieJar,
) -> Result<Json<serde_json::Value>, ApiError> {
let (session_id, session_value) = extract_session_token(&jar)
.ok_or_else(|| ApiError::unauthorized("authentication required"))?;
state
.service
.get_session(&session_id, &session_value)
.await?
.ok_or_else(|| ApiError::unauthorized("session is invalid or expired"))?;
let csrf_token = state.service.rotate_session_csrf_token(&session_id).await?;
Ok(Json(json!({ "csrf_token": csrf_token })))
}
pub async fn get_profile(
Extension(session): Extension<AuthenticatedSession>,
) -> Result<Json<serde_json::Value>, ApiError> {
@@ -73,6 +123,7 @@ pub async fn update_profile(
.service
.update_profile(
&session.user.id,
&session.session_id,
session.current_workspace_id.as_ref(),
payload,
)
+17 -3
View File
@@ -1,11 +1,17 @@
use axum::{
Json,
Extension, Json,
extract::{Path, State},
};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::{error::ApiError, service::AuthProfilePayload, state::AppState};
use crate::{
auth::AuthenticatedSession,
error::ApiError,
request_context::RequestContext,
service::{AdminAuditContext, AuthProfilePayload},
state::AppState,
};
#[derive(Deserialize)]
pub struct WorkspacePath {
@@ -32,11 +38,19 @@ pub async fn list_auth_profiles(
pub async fn create_auth_profile(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Extension(request_context): Extension<RequestContext>,
Json(payload): Json<AuthProfilePayload>,
) -> Result<Json<Value>, ApiError> {
let audit_context =
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
let profile = state
.service
.create_auth_profile(&path.workspace_id.as_str().into(), payload)
.create_auth_profile(
&path.workspace_id.as_str().into(),
payload,
Some(&audit_context),
)
.await?;
Ok(Json(json!(profile)))
}
+81 -2
View File
@@ -1,13 +1,18 @@
use axum::{
Json,
extract::{Path, Query, State},
http::{
HeaderValue, StatusCode,
header::{CONTENT_DISPOSITION, CONTENT_TYPE},
},
response::{IntoResponse, Response},
};
use serde_json::{Value, json};
use crate::{
error::ApiError,
routes::access::WorkspacePath,
service::{ApprovalsQuery, LogsQuery, UsageRequestQuery},
service::{ApprovalDecisionPayload, ApprovalsQuery, LogsQuery, UsageRequestQuery},
state::AppState,
};
@@ -44,7 +49,49 @@ pub async fn list_logs(
.service
.list_logs(&path.workspace_id.as_str().into(), query)
.await?;
Ok(Json(json!({ "items": items })))
Ok(Json(json!(items)))
}
pub async fn export_logs_csv(
Path(path): Path<WorkspacePath>,
Query(query): Query<LogsQuery>,
State(state): State<AppState>,
) -> Result<Response, ApiError> {
let csv = state
.service
.export_logs_csv(&path.workspace_id.as_str().into(), query)
.await?;
let mut response = (StatusCode::OK, csv).into_response();
response.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_static("text/csv; charset=utf-8"),
);
response.headers_mut().insert(
CONTENT_DISPOSITION,
HeaderValue::from_static("attachment; filename=\"crank-invocation-history.csv\""),
);
Ok(response)
}
pub async fn export_usage_csv(
Path(path): Path<WorkspacePath>,
Query(query): Query<UsageRequestQuery>,
State(state): State<AppState>,
) -> Result<Response, ApiError> {
let csv = state
.service
.export_usage_csv(&path.workspace_id.as_str().into(), query)
.await?;
let mut response = (StatusCode::OK, csv).into_response();
response.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_static("text/csv; charset=utf-8"),
);
response.headers_mut().insert(
CONTENT_DISPOSITION,
HeaderValue::from_static("attachment; filename=\"crank-usage.csv\""),
);
Ok(response)
}
pub async fn get_log(
@@ -87,6 +134,38 @@ pub async fn get_approval(
Ok(Json(json!(item)))
}
pub async fn approve_approval(
Path(path): Path<WorkspaceApprovalPath>,
State(state): State<AppState>,
Json(payload): Json<ApprovalDecisionPayload>,
) -> Result<Json<Value>, ApiError> {
let item = state
.service
.approve_approval(
&path.workspace_id.as_str().into(),
&path.approval_id.as_str().into(),
payload,
)
.await?;
Ok(Json(json!(item)))
}
pub async fn deny_approval(
Path(path): Path<WorkspaceApprovalPath>,
State(state): State<AppState>,
Json(payload): Json<ApprovalDecisionPayload>,
) -> Result<Json<Value>, ApiError> {
let item = state
.service
.deny_approval(
&path.workspace_id.as_str().into(),
&path.approval_id.as_str().into(),
payload,
)
.await?;
Ok(Json(json!(item)))
}
pub async fn get_usage(
Path(path): Path<WorkspacePath>,
Query(query): Query<UsageRequestQuery>,
+59
View File
@@ -0,0 +1,59 @@
use axum::{
Json,
extract::{Path, State},
};
use crate::{
error::ApiError,
service::{
OnboardingEventPayload, OnboardingEventResponse, OnboardingResponse,
ResetOnboardingSelectionPayload, ResetOnboardingSelectionResponse,
},
state::AppState,
};
#[derive(serde::Deserialize)]
pub struct WorkspaceOnboardingPath {
pub workspace_id: String,
}
pub async fn reset_onboarding_selection(
Path(path): Path<WorkspaceOnboardingPath>,
State(state): State<AppState>,
Json(payload): Json<ResetOnboardingSelectionPayload>,
) -> Result<Json<ResetOnboardingSelectionResponse>, ApiError> {
Ok(Json(
state
.service
.reset_onboarding_selection(
&path.workspace_id.as_str().into(),
payload.expected_revision,
)
.await?,
))
}
pub async fn get_onboarding(
Path(path): Path<WorkspaceOnboardingPath>,
State(state): State<AppState>,
) -> Result<Json<OnboardingResponse>, ApiError> {
Ok(Json(
state
.service
.get_onboarding(&path.workspace_id.as_str().into())
.await?,
))
}
pub async fn record_onboarding_event(
Path(path): Path<WorkspaceOnboardingPath>,
State(state): State<AppState>,
Json(payload): Json<OnboardingEventPayload>,
) -> Result<Json<OnboardingEventResponse>, ApiError> {
Ok(Json(
state
.service
.record_onboarding_event(&path.workspace_id.as_str().into(), payload)
.await?,
))
}
+102 -7
View File
@@ -1,9 +1,10 @@
use axum::{
Json,
extract::{Extension, Path, Query, State},
extract::{Extension, Path, Query, State, rejection::StringRejection},
http::{HeaderMap, StatusCode, header},
response::IntoResponse,
};
use crank_registry::OperationStateExpectation;
use serde::Deserialize;
use serde_json::{Value, json};
@@ -79,7 +80,7 @@ pub async fn analyze_operation_quality(
pub async fn get_operation(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
) -> Result<impl IntoResponse, ApiError> {
let operation = state
.service
.get_operation(
@@ -87,20 +88,30 @@ pub async fn get_operation(
&path.operation_id.as_str().into(),
)
.await?;
Ok(Json(json!(operation)))
let etag = crate::service::AdminService::operation_state_etag(&operation);
let mut headers = HeaderMap::new();
headers.insert(
header::ETAG,
header::HeaderValue::from_str(&etag)
.map_err(|_| ApiError::internal("invalid operation etag"))?,
);
Ok((headers, Json(json!(operation))))
}
pub async fn update_operation(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<UpdateOperationPayload>,
) -> Result<Json<Value>, ApiError> {
let expected = require_operation_precondition(&state, &path, &headers).await?;
let result = state
.service
.update_operation(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
payload,
&expected,
)
.await?;
Ok(Json(json!(result)))
@@ -109,12 +120,15 @@ pub async fn update_operation(
pub async fn delete_operation(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, ApiError> {
let expected = require_operation_precondition(&state, &path, &headers).await?;
let result = state
.service
.delete_operation(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
&expected,
)
.await?;
Ok(Json(json!(result)))
@@ -123,7 +137,7 @@ pub async fn delete_operation(
pub async fn get_operation_version(
Path(path): Path<WorkspaceOperationVersionPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
) -> Result<impl IntoResponse, ApiError> {
let version = state
.service
.get_operation_version(
@@ -132,20 +146,30 @@ pub async fn get_operation_version(
path.version,
)
.await?;
Ok(Json(json!(version)))
let etag = crate::service::AdminService::operation_version_etag(&version)?;
let mut headers = HeaderMap::new();
headers.insert(
header::ETAG,
header::HeaderValue::from_str(&etag)
.map_err(|_| ApiError::internal("invalid operation version etag"))?,
);
Ok((headers, Json(json!(version))))
}
pub async fn create_version(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<NewVersionPayload>,
) -> Result<Json<Value>, ApiError> {
let expected = require_operation_precondition(&state, &path, &headers).await?;
let created = state
.service
.create_version(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
payload,
&expected,
)
.await?;
Ok(Json(json!(created)))
@@ -154,14 +178,17 @@ pub async fn create_version(
pub async fn publish_operation(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<PublishPayload>,
) -> Result<Json<Value>, ApiError> {
let expected = require_operation_precondition(&state, &path, &headers).await?;
let published = state
.service
.publish_operation(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
payload.version,
&expected,
)
.await?;
Ok(Json(json!(published)))
@@ -170,17 +197,63 @@ pub async fn publish_operation(
pub async fn archive_operation(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, ApiError> {
let expected = require_operation_precondition(&state, &path, &headers).await?;
let archived = state
.service
.archive_operation(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
&expected,
)
.await?;
Ok(Json(json!(archived)))
}
async fn require_operation_precondition(
state: &AppState,
path: &WorkspaceOperationPath,
headers: &HeaderMap,
) -> Result<OperationStateExpectation, ApiError> {
let detail = state
.service
.get_operation(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
)
.await?;
let expected = crate::service::AdminService::operation_state_etag(&detail);
let provided = headers
.get(header::IF_MATCH)
.and_then(|value| value.to_str().ok())
.ok_or_else(|| {
ApiError::precondition_required_with_context(
"If-Match is required for Operation mutation",
json!({
"error_code": "operation_precondition_required",
"current_version": detail.current_draft_version,
"recovery": "reload"
}),
)
})?;
if provided != expected {
return Err(ApiError::conflict_with_context(
"Operation state changed; reload before retrying",
json!({
"error_code": "operation_stale_version",
"current_version": detail.current_draft_version,
"recovery": "reload"
}),
));
}
Ok(OperationStateExpectation {
current_draft_version: detail.current_draft_version,
status: detail.status,
latest_published_version: detail.latest_published_version,
})
}
pub async fn run_test(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
@@ -285,11 +358,33 @@ pub async fn import_operation(
Path(path): Path<WorkspacePath>,
Query(query): Query<ImportQuery>,
State(state): State<AppState>,
body: String,
headers: HeaderMap,
body: Result<String, StringRejection>,
) -> Result<Json<Value>, ApiError> {
let body = body.map_err(|rejection| match rejection {
StringRejection::InvalidUtf8(_) => ApiError::unprocessable_with_context(
"operation yaml is not valid UTF-8",
json!({ "error_code": "operation_yaml_invalid" }),
),
StringRejection::FailedToBufferBody(_) => ApiError::payload_too_large_with_context(
"operation yaml exceeds the 256 KiB limit",
json!({ "error_code": "operation_yaml_too_large" }),
),
_ => ApiError::unprocessable_with_context(
"operation yaml body is invalid",
json!({ "error_code": "operation_yaml_invalid" }),
),
})?;
let imported = state
.service
.import_operation(&path.workspace_id.as_str().into(), query, &body)
.import_operation(
&path.workspace_id.as_str().into(),
query,
&body,
headers
.get(header::IF_MATCH)
.and_then(|value| value.to_str().ok()),
)
.await?;
Ok(Json(json!(imported)))
}
+15 -1
View File
@@ -8,7 +8,8 @@ use serde_json::{Value, json};
use crate::{
auth::AuthenticatedSession,
error::ApiError,
service::{RotateSecretPayload, SecretPayload},
request_context::RequestContext,
service::{AdminAuditContext, RotateSecretPayload, SecretPayload},
state::AppState,
};
@@ -38,14 +39,18 @@ pub async fn create_secret(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Extension(request_context): Extension<RequestContext>,
Json(payload): Json<SecretPayload>,
) -> Result<Json<Value>, ApiError> {
let audit_context =
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
let secret = state
.service
.create_secret(
&path.workspace_id.as_str().into(),
Some(&session.user.id),
payload,
Some(&audit_context),
)
.await?;
Ok(Json(json!(secret)))
@@ -69,8 +74,11 @@ pub async fn rotate_secret(
Path(path): Path<WorkspaceSecretPath>,
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Extension(request_context): Extension<RequestContext>,
Json(payload): Json<RotateSecretPayload>,
) -> Result<Json<Value>, ApiError> {
let audit_context =
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
let secret = state
.service
.rotate_secret(
@@ -78,6 +86,7 @@ pub async fn rotate_secret(
&path.secret_id.as_str().into(),
Some(&session.user.id),
payload,
Some(&audit_context),
)
.await?;
Ok(Json(json!(secret)))
@@ -86,12 +95,17 @@ pub async fn rotate_secret(
pub async fn delete_secret(
Path(path): Path<WorkspaceSecretPath>,
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, ApiError> {
let audit_context =
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
state
.service
.delete_secret(
&path.workspace_id.as_str().into(),
&path.secret_id.as_str().into(),
Some(&audit_context),
)
.await?;
Ok(Json(json!({ "ok": true })))
+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),
+155 -69
View File
@@ -5,11 +5,13 @@ use crank_core::{
ToolAccessMode, ToolSelectionPolicy, UsagePeriod, WorkspaceId, search_tool_catalog,
};
use crank_registry::{
AgentVersionRecord, CreateAgentDraftVersionRequest, CreateAgentRequest, PublishAgentRequest,
SaveAgentBindingsRequest, SaveAgentCatalogConfigRequest, UsageBucket, UsageQuery,
AgentStateExpectation, AgentVersionRecord, CreateAgentDraftVersionRequest, CreateAgentRequest,
PublishAgentRequest, SaveAgentCatalogConfigRequest, UpdateAgentSummaryRequest, UsageBucket,
UsageQuery,
};
use serde_json::json;
use time::OffsetDateTime;
use sha2::{Digest, Sha256};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tracing::{info, instrument};
use crate::{
@@ -17,12 +19,50 @@ use crate::{
service::{
AdminService, AgentCatalogPayload, AgentMutationResult, AgentPayload, AgentSummaryView,
CreatedAgentResponse, PublishAgentResponse, ToolSearchPreviewPayload, UpdateAgentPayload,
agent_mcp_endpoint, format_timestamp, map_agent_summary_view, new_prefixed_id,
agent_mcp_endpoint, format_timestamp, map_agent_summary_view, new_prefixed_id, now_string,
today_start_utc,
},
};
impl AdminService {
pub fn agent_state_etag(agent: &AgentSummaryView) -> String {
let policy =
serde_json::to_string(&agent.tool_selection_policy).unwrap_or_else(|_| "{}".to_owned());
let operation_ids = agent.operation_ids.join(",");
let material = format!(
"agent-state-v2\0{}\0{}\0{}\0{}\0{}\0{}\0{}\0{:?}\0{:?}\0{}\0{}\0{}\0{}\0{}",
agent.workspace_id,
agent.id,
agent.slug,
agent.display_name,
agent.description,
agent.updated_at,
agent.current_draft_version,
agent.status,
agent.latest_published_version,
agent.catalog_revision,
agent.operation_count,
operation_ids,
policy,
agent.key_count
);
format!("\"{:x}\"", Sha256::digest(material.as_bytes()))
}
pub fn agent_state_expectation(
agent: &AgentSummaryView,
) -> Result<AgentStateExpectation, ApiError> {
let updated_at = OffsetDateTime::parse(&agent.updated_at, &Rfc3339)
.map_err(|_| ApiError::internal("invalid agent state timestamp"))?;
Ok(AgentStateExpectation {
status: agent.status,
current_draft_version: agent.current_draft_version,
latest_published_version: agent.latest_published_version,
catalog_revision: agent.catalog_revision,
updated_at,
})
}
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str()))]
pub async fn preview_tool_search(
&self,
@@ -110,13 +150,16 @@ impl AdminService {
self.ensure_workspace_exists(workspace_id).await?;
let workspace = self.get_workspace(workspace_id).await?;
let summaries = self.registry.list_agents(workspace_id).await?;
let usage_start = today_start_utc()?;
let usage_end = now_string()?;
let usage = self
.registry
.list_usage_by_agent(UsageQuery {
workspace_id,
period: UsagePeriod::Last24Hours,
source: None,
created_after: &today_start_utc()?,
created_after: &usage_start,
created_before: &usage_end,
bucket: UsageBucket::Hour,
})
.await?;
@@ -189,6 +232,8 @@ impl AdminService {
.iter()
.map(|binding| binding.operation_id.as_str().to_owned())
.collect::<Vec<_>>();
let usage_start = today_start_utc()?;
let usage_end = now_string()?;
let usage = self
.registry
.get_usage_for_agent(
@@ -196,7 +241,8 @@ impl AdminService {
workspace_id,
period: UsagePeriod::Last24Hours,
source: None,
created_after: &today_start_utc()?,
created_after: &usage_start,
created_before: &usage_end,
bucket: UsageBucket::Hour,
},
agent_id,
@@ -317,6 +363,7 @@ impl AdminService {
workspace_id: &WorkspaceId,
agent_id: &AgentId,
payload: UpdateAgentPayload,
expected_state: Option<&AgentStateExpectation>,
) -> Result<AgentMutationResult, ApiError> {
let existing = self
.registry
@@ -328,6 +375,20 @@ impl AdminService {
json!({ "agent_id": agent_id.as_str() }),
)
})?;
if existing.status == AgentStatus::Published
&& (payload.slug != existing.slug
|| payload.display_name != existing.display_name
|| payload.description != existing.description)
{
return Err(ApiError::conflict_with_context(
"published Agent summary is immutable; unpublish before editing",
json!({
"agent_id": agent_id.as_str(),
"error_code": "agent_published_summary_immutable",
"recovery": "unpublish_edit_publish"
}),
));
}
if payload.slug != existing.slug
&& self
@@ -346,10 +407,13 @@ impl AdminService {
.update_agent_summary(
workspace_id,
agent_id,
&payload.slug,
&payload.display_name,
&payload.description,
&updated_at,
UpdateAgentSummaryRequest {
slug: &payload.slug,
display_name: &payload.display_name,
description: &payload.description,
updated_at: &updated_at,
expected_state,
},
)
.await?;
@@ -365,6 +429,7 @@ impl AdminService {
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
expected_state: Option<&AgentStateExpectation>,
) -> Result<AgentMutationResult, ApiError> {
let existing = self
.registry
@@ -376,8 +441,20 @@ impl AdminService {
json!({ "agent_id": agent_id.as_str() }),
)
})?;
if existing.latest_published_version.is_some() {
return Err(ApiError::conflict_with_context(
"published Agent cannot be deleted; archive or unpublish it instead",
json!({
"agent_id": agent_id.as_str(),
"error_code": "agent_delete_forbidden",
"recovery": "archive_or_unpublish"
}),
));
}
self.registry.delete_agent(workspace_id, agent_id).await?;
self.registry
.delete_agent(workspace_id, agent_id, expected_state)
.await?;
Ok(AgentMutationResult {
agent_id: agent_id.as_str().to_owned(),
@@ -392,6 +469,7 @@ impl AdminService {
workspace_id: &WorkspaceId,
agent_id: &AgentId,
payload: AgentCatalogPayload,
expected_state: Option<&AgentStateExpectation>,
) -> Result<AgentVersionRecord, ApiError> {
let current_version = self
.ensure_editable_agent_version(workspace_id, agent_id)
@@ -408,6 +486,8 @@ impl AdminService {
enabled: binding.enabled,
})
.collect::<Vec<_>>();
self.validate_exact_published_agent_bindings(workspace_id, &bindings)
.await?;
let tool_selection_policy = requested_policy
.unwrap_or_else(|| current_version.snapshot.tool_selection_policy.clone());
validate_tool_selection_policy(&tool_selection_policy, &bindings)?;
@@ -419,6 +499,7 @@ impl AdminService {
agent_version: current_version.version,
bindings: &bindings,
tool_selection_policy: &tool_selection_policy,
expected_state,
})
.await?;
info!(
@@ -474,19 +555,23 @@ impl AdminService {
workspace_id: &WorkspaceId,
agent_id: &AgentId,
version: u32,
expected_state: Option<&AgentStateExpectation>,
) -> Result<PublishAgentResponse, ApiError> {
let agent_version = self
.get_agent_version(workspace_id, agent_id, version)
.await?;
let published_bindings = self
.published_agent_bindings(workspace_id, &agent_version.bindings)
.await?;
validate_tool_selection_policy(
&agent_version.snapshot.tool_selection_policy,
&published_bindings,
&agent_version.bindings,
)?;
if published_bindings.is_empty() {
if agent_version
.bindings
.iter()
.filter(|binding| binding.enabled)
.count()
== 0
{
return Err(ApiError::conflict_with_context(
"agent cannot be published without published enabled tools",
json!({
@@ -497,36 +582,10 @@ impl AdminService {
));
}
self.validate_exact_published_agent_bindings(workspace_id, &agent_version.bindings)
.await?;
let published_at = OffsetDateTime::now_utc();
if published_bindings != agent_version.bindings {
let draft_version = AgentVersion {
agent_id: agent_id.clone(),
version: agent_version.version + 1,
status: AgentStatus::Draft,
instructions: agent_version.snapshot.instructions.clone(),
tool_selection_policy: agent_version.snapshot.tool_selection_policy.clone(),
created_at: published_at,
};
self.registry
.create_agent_draft_version(CreateAgentDraftVersionRequest {
workspace_id,
agent_id,
version: &draft_version,
bindings: &agent_version.bindings,
updated_at: &published_at,
})
.await?;
self.registry
.save_agent_bindings(SaveAgentBindingsRequest {
workspace_id,
agent_id,
agent_version: agent_version.version,
bindings: &published_bindings,
})
.await?;
}
self.registry
.publish_agent(PublishAgentRequest {
@@ -535,6 +594,7 @@ impl AdminService {
version,
published_at: &published_at,
published_by: None,
expected_state,
})
.await?;
info!(
@@ -552,41 +612,65 @@ impl AdminService {
})
}
async fn published_agent_bindings(
async fn validate_exact_published_agent_bindings(
&self,
workspace_id: &WorkspaceId,
bindings: &[AgentOperationBinding],
) -> Result<Vec<AgentOperationBinding>, ApiError> {
let mut published = Vec::new();
) -> Result<(), ApiError> {
for binding in bindings {
if !binding.enabled {
continue;
}
let Some(summary) = self
.registry
.get_operation_summary(workspace_id, &binding.operation_id)
.await?
else {
continue;
return Err(ApiError::not_found_with_context(
"operation was not found for Agent binding",
json!({
"error_code": "agent_binding_scope_denied",
"operation_id": binding.operation_id.as_str()
}),
));
};
if summary.status == crank_core::OperationStatus::Archived {
return Err(ApiError::unprocessable_with_context(
"archived operation cannot be added to an Agent binding",
json!({
"error_code": "agent_binding_archived_operation",
"operation_id": binding.operation_id.as_str()
}),
));
}
let Some(operation_version) = summary.latest_published_version else {
continue;
let Some(version) = self
.registry
.get_operation_version(
workspace_id,
&binding.operation_id,
binding.operation_version,
)
.await?
else {
return Err(ApiError::unprocessable_with_context(
"operation version is not published for Agent binding",
json!({
"error_code": "agent_binding_not_published",
"operation_id": binding.operation_id.as_str(),
"operation_version": binding.operation_version
}),
));
};
published.push(AgentOperationBinding {
operation_id: summary.id,
operation_version,
tool_name: binding.tool_name.clone(),
tool_title: binding.tool_title.clone(),
tool_description_override: binding.tool_description_override.clone(),
enabled: true,
});
if !version.snapshot.is_published() {
return Err(ApiError::unprocessable_with_context(
"operation version is not published for Agent binding",
json!({
"error_code": "agent_binding_not_published",
"operation_id": binding.operation_id.as_str(),
"operation_version": binding.operation_version
}),
));
}
}
Ok(published)
Ok(())
}
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str()))]
@@ -594,11 +678,12 @@ impl AdminService {
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
expected_state: Option<&AgentStateExpectation>,
) -> Result<AgentMutationResult, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let updated_at = OffsetDateTime::now_utc();
self.registry
.unpublish_agent(workspace_id, agent_id, &updated_at)
.unpublish_agent(workspace_id, agent_id, &updated_at, expected_state)
.await?;
info!(
name: "admin.agent.unpublished",
@@ -618,11 +703,12 @@ impl AdminService {
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
expected_state: Option<&AgentStateExpectation>,
) -> Result<AgentMutationResult, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let updated_at = OffsetDateTime::now_utc();
self.registry
.archive_agent(workspace_id, agent_id, &updated_at)
.archive_agent(workspace_id, agent_id, &updated_at, expected_state)
.await?;
info!(
name: "admin.agent.archived",
+300 -28
View File
@@ -6,12 +6,14 @@ use crank_registry::{CreatePlatformApiKeyRequest, PlatformApiKeyRecord};
use serde_json::json;
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tracing::instrument;
use url::Url;
use crate::{
error::ApiError,
service::{
AdminService, CreatedPlatformApiKeyResponse, PlatformApiKeyPayload, generate_access_secret,
hash_access_secret, new_prefixed_id,
AdminAuditContext, AdminService, CreatedPlatformApiKeyResponse, CredentialAuditRecord,
EphemeralMcpClientConfig, EphemeralMcpConnection, PlatformApiKeyPayload,
generate_access_secret, hash_access_secret, new_prefixed_id,
},
};
@@ -38,32 +40,84 @@ impl AdminService {
.await?)
}
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_name = %payload.name))]
#[instrument(skip(self, payload, audit_context), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_name = %payload.name))]
pub async fn create_agent_platform_api_key(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
payload: PlatformApiKeyPayload,
mut payload: PlatformApiKeyPayload,
audit_context: Option<&AdminAuditContext>,
) -> Result<CreatedPlatformApiKeyResponse, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
self.registry
let workspace = match self.get_workspace(workspace_id).await {
Ok(workspace) => workspace,
Err(error) => {
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.platform_api_key.create_failed",
target_kind: crank_core::AuditTargetKind::PlatformApiKey,
workspace_id,
target_id: "pending",
credential_type: "platform_api_key",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
};
let agent_result = match self
.registry
.get_agent_summary(workspace_id, agent_id)
.await?
.ok_or_else(|| {
.await
{
Ok(agent) => agent.ok_or_else(|| {
ApiError::not_found_with_context(
format!("agent {} was not found", agent_id.as_str()),
json!({ "agent_id": agent_id.as_str() }),
)
})?;
}),
Err(error) => Err(ApiError::from(error)),
};
let agent = match agent_result {
Ok(agent) => agent,
Err(error) => {
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.platform_api_key.create_failed",
target_kind: crank_core::AuditTargetKind::PlatformApiKey,
workspace_id,
target_id: "pending",
credential_type: "platform_api_key",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
};
validate_platform_api_key_payload(&payload)?;
let expires_at = match payload.expires_at.as_deref() {
Some(value) => Some(
OffsetDateTime::parse(value, &Rfc3339)
.map_err(|_| ApiError::validation("expires_at must be RFC3339 timestamp"))?,
),
None => None,
let expires_at = match validate_platform_api_key_payload(&mut payload) {
Ok(expires_at) => expires_at,
Err(error) => {
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.platform_api_key.create_failed",
target_kind: crank_core::AuditTargetKind::PlatformApiKey,
workspace_id,
target_id: "pending",
credential_type: payload.key_kind.audit_credential_type(),
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
};
let secret = generate_access_secret(payload.key_kind.secret_marker());
let api_key = PlatformApiKeyRecord {
@@ -83,55 +137,213 @@ impl AdminService {
},
};
self.registry
if let Err(error) = self
.registry
.create_platform_api_key(CreatePlatformApiKeyRequest {
api_key: &api_key.api_key,
secret_hash: &hash_access_secret(&secret),
})
.await?;
.await
{
let error = ApiError::from(error);
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.platform_api_key.create_failed",
target_kind: crank_core::AuditTargetKind::PlatformApiKey,
workspace_id,
target_id: api_key.api_key.id.as_str(),
credential_type: api_key.api_key.key_kind.audit_credential_type(),
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.platform_api_key.created",
target_kind: crank_core::AuditTargetKind::PlatformApiKey,
workspace_id,
target_id: api_key.api_key.id.as_str(),
credential_type: api_key.api_key.key_kind.audit_credential_type(),
outcome: "success",
reason: "credential_created",
},
)
.await;
Ok(CreatedPlatformApiKeyResponse { api_key, secret })
let connection = (api_key.api_key.key_kind == PlatformApiKeyKind::McpClient).then(|| {
let endpoint = self.public_agent_mcp_endpoint(&workspace.workspace.slug, &agent.slug);
EphemeralMcpConnection {
endpoint: endpoint.clone(),
clients: ephemeral_client_configs(&endpoint, &secret),
secret_display: "once",
}
});
Ok(CreatedPlatformApiKeyResponse {
api_key,
secret,
connection,
})
}
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_id = %key_id.as_str()))]
#[instrument(skip(self, audit_context), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_id = %key_id.as_str()))]
pub async fn revoke_agent_platform_api_key(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
key_id: &PlatformApiKeyId,
audit_context: Option<&AdminAuditContext>,
) -> Result<(), ApiError> {
self.registry
if let Err(error) = self
.registry
.revoke_platform_api_key_for_agent(
workspace_id,
agent_id,
key_id,
&OffsetDateTime::now_utc(),
)
.await?;
.await
{
let error = ApiError::from(error);
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.platform_api_key.revoke_failed",
target_kind: crank_core::AuditTargetKind::PlatformApiKey,
workspace_id,
target_id: key_id.as_str(),
credential_type: "platform_api_key",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.platform_api_key.revoked",
target_kind: crank_core::AuditTargetKind::PlatformApiKey,
workspace_id,
target_id: key_id.as_str(),
credential_type: "platform_api_key",
outcome: "success",
reason: "credential_revoked",
},
)
.await;
Ok(())
}
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_id = %key_id.as_str()))]
#[instrument(skip(self, audit_context), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_id = %key_id.as_str()))]
pub async fn delete_agent_platform_api_key(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
key_id: &PlatformApiKeyId,
audit_context: Option<&AdminAuditContext>,
) -> Result<(), ApiError> {
self.registry
if let Err(error) = self
.registry
.delete_platform_api_key_for_agent(workspace_id, agent_id, key_id)
.await?;
.await
{
let error = ApiError::from(error);
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.platform_api_key.delete_failed",
target_kind: crank_core::AuditTargetKind::PlatformApiKey,
workspace_id,
target_id: key_id.as_str(),
credential_type: "platform_api_key",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.platform_api_key.deleted",
target_kind: crank_core::AuditTargetKind::PlatformApiKey,
workspace_id,
target_id: key_id.as_str(),
credential_type: "platform_api_key",
outcome: "success",
reason: "credential_deleted",
},
)
.await;
Ok(())
}
}
fn validate_platform_api_key_payload(payload: &PlatformApiKeyPayload) -> Result<(), ApiError> {
fn ephemeral_client_configs(endpoint: &str, secret: &str) -> Vec<EphemeralMcpClientConfig> {
["claude_desktop", "cursor", "vscode"]
.into_iter()
.map(|client| EphemeralMcpClientConfig {
client: client.to_owned(),
config: json!({
"transport": "streamable_http",
"url": endpoint,
"headers": {"Authorization": format!("Bearer {secret}")}
}),
})
.collect()
}
trait PlatformApiKeyKindAuditExt {
fn audit_credential_type(self) -> &'static str;
}
impl PlatformApiKeyKindAuditExt for PlatformApiKeyKind {
fn audit_credential_type(self) -> &'static str {
match self {
PlatformApiKeyKind::McpClient => "mcp_client_key",
PlatformApiKeyKind::Approval => "approval_key",
}
}
}
fn validate_platform_api_key_payload(
payload: &mut PlatformApiKeyPayload,
) -> Result<Option<OffsetDateTime>, ApiError> {
const MAX_KEY_NAME_CHARS: usize = 128;
const MAX_SCOPES: usize = 8;
payload.name = payload.name.trim().to_owned();
if payload.name.trim().is_empty() {
return Err(ApiError::validation("key name is required"));
}
if payload.name.chars().count() > MAX_KEY_NAME_CHARS {
return Err(ApiError::validation(
"key name must be at most 128 characters",
));
}
if payload.scopes.is_empty() {
return Err(ApiError::validation("at least one key scope is required"));
}
if payload.scopes.len() > MAX_SCOPES {
return Err(ApiError::validation("too many key scopes"));
}
if payload
.scopes
.iter()
.enumerate()
.any(|(index, scope)| payload.scopes[..index].contains(scope))
{
return Err(ApiError::validation(
"key scopes must not contain duplicates",
));
}
let valid = payload.scopes.iter().all(|scope| match payload.key_kind {
PlatformApiKeyKind::McpClient => matches!(
@@ -156,6 +368,66 @@ fn validate_platform_api_key_payload(payload: &PlatformApiKeyPayload) -> Result<
"approval key can contain at most 20 allowed origins",
));
}
if payload.key_kind == PlatformApiKeyKind::Approval {
let mut normalized_origins = Vec::with_capacity(payload.allowed_origins.len());
for origin in &payload.allowed_origins {
normalized_origins.push(validate_approval_origin(origin)?);
}
if normalized_origins
.iter()
.enumerate()
.any(|(index, origin)| normalized_origins[..index].contains(origin))
{
return Err(ApiError::validation(
"allowed origins must not contain duplicates",
));
}
payload.allowed_origins = normalized_origins;
} else if !payload.allowed_origins.is_empty() {
return Err(ApiError::validation(
"allowed origins are only supported for approval keys",
));
}
Ok(())
let expires_at = payload
.expires_at
.as_deref()
.map(|value| {
OffsetDateTime::parse(value, &Rfc3339)
.map_err(|_| ApiError::validation("expires_at must be RFC3339 timestamp"))
})
.transpose()?;
if expires_at.is_some_and(|value| value <= OffsetDateTime::now_utc()) {
return Err(ApiError::validation("expires_at must be in the future"));
}
Ok(expires_at)
}
fn validate_approval_origin(origin: &str) -> Result<String, ApiError> {
const MAX_ORIGIN_LEN: usize = 2048;
if origin.is_empty() || origin.len() > MAX_ORIGIN_LEN {
return Err(ApiError::validation("allowed origin is invalid"));
}
if origin
.bytes()
.any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace())
{
return Err(ApiError::validation("allowed origin is invalid"));
}
let parsed =
Url::parse(origin).map_err(|_| ApiError::validation("allowed origin is invalid"))?;
if !matches!(parsed.scheme(), "http" | "https")
|| parsed.host_str().is_none()
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.path() != "/"
|| parsed.query().is_some()
|| parsed.fragment().is_some()
{
return Err(ApiError::validation("allowed origin is invalid"));
}
Ok(parsed.origin().ascii_serialization())
}
+238 -40
View File
@@ -1,20 +1,101 @@
use crank_core::{LoginOutcome, MembershipRole, UserSessionId, WorkspaceId};
use crank_core::{
LoginOutcome, MembershipRole, User, UserId, UserSessionId, UserStatus, WorkspaceId,
};
use crank_registry::{AdminSecurityAuditRequest, ConsumeAdminBootstrapContractRequest};
use serde_json::json;
use time::OffsetDateTime;
use tracing::instrument;
use crate::{
auth::{
AuthenticatedSession, SessionCookie, create_session_cookie, hash_password,
hash_session_secret, verify_password,
AuthenticatedSession, SessionCookie, create_csrf_token_value, create_session_cookie,
hash_csrf_token, hash_password, hash_session_secret, verify_password,
},
error::ApiError,
service::{
AdminService, ChangePasswordPayload, LoginPayload, SessionResponse, UpdateProfilePayload,
map_identity_error, validate_profile_display_name, validate_profile_email,
AdminService, BootstrapStatusResponse, ChangePasswordPayload, CompleteBootstrapPayload,
LoginPayload, SessionResponse, UpdateProfilePayload, hash_access_secret,
map_identity_error, new_prefixed_id, validate_profile_display_name, validate_profile_email,
},
};
impl AdminService {
pub async fn bootstrap_status(&self) -> Result<BootstrapStatusResponse, ApiError> {
Ok(BootstrapStatusResponse {
bootstrap_required: !self.registry.has_password_admin().await?,
})
}
pub async fn complete_bootstrap(
&self,
payload: CompleteBootstrapPayload,
) -> Result<(SessionCookie, SessionResponse), ApiError> {
if payload.token.len() < 32 || payload.token.len() > 256 {
self.record_admin_security_audit(None, "bootstrap_rejected", "rejected", "bootstrap")
.await;
return Err(Self::constant_bootstrap_error());
}
if payload.password.len() < 12 || payload.password.len() > 256 {
self.record_admin_security_audit(None, "bootstrap_rejected", "rejected", "bootstrap")
.await;
return Err(Self::constant_bootstrap_error());
}
let now = OffsetDateTime::now_utc();
let bootstrap_token_hash = hash_access_secret(&format!("bootstrap:{}", payload.token));
if !self
.registry
.admin_bootstrap_contract_is_consumable(&bootstrap_token_hash, &now)
.await?
{
self.record_admin_security_audit(None, "bootstrap_rejected", "rejected", "bootstrap")
.await;
return Err(Self::constant_bootstrap_error());
}
let password_hash = hash_password(&payload.password, &self.auth_settings.password_pepper)?;
let user_id = self
.registry
.consume_admin_bootstrap_contract(ConsumeAdminBootstrapContractRequest {
token_hash: &bootstrap_token_hash,
password_hash: &password_hash,
now: &now,
})
.await
.map_err(|_| Self::constant_bootstrap_error());
let user_id = match user_id {
Ok(user_id) => user_id,
Err(error) => {
self.record_admin_security_audit(
None,
"bootstrap_rejected",
"rejected",
"bootstrap",
)
.await;
return Err(error);
}
};
self.record_admin_security_audit(
Some(&user_id),
"bootstrap_completed",
"success",
"bootstrap",
)
.await;
let user = self
.registry
.get_auth_user_by_id(&user_id)
.await?
.ok_or_else(|| ApiError::internal("bootstrap user was not found"))?
.user;
self.create_session_for_user(user).await
}
fn constant_bootstrap_error() -> ApiError {
ApiError::unauthorized("bootstrap request is invalid or expired")
}
pub async fn bootstrap_admin_user(&self) -> Result<(), ApiError> {
let password_hash = hash_password(
&self.auth_settings.bootstrap_admin.password,
@@ -83,36 +164,99 @@ impl AdminService {
Ok(())
}
pub async fn verify_session_csrf(
&self,
session_id: &UserSessionId,
csrf_hash: &str,
) -> Result<bool, ApiError> {
Ok(self
.registry
.verify_session_csrf(session_id, csrf_hash)
.await?)
}
pub async fn login(
&self,
payload: LoginPayload,
) -> Result<(SessionCookie, SessionResponse), ApiError> {
let authenticated = self.authenticate_login(&payload).await?;
self.login_with_client_bucket(payload, "anonymous").await
}
pub async fn login_with_client_bucket(
&self,
payload: LoginPayload,
client_bucket: &str,
) -> Result<(SessionCookie, SessionResponse), ApiError> {
let scope_hash = hash_access_secret(&format!(
"login:{}:{}",
client_bucket,
payload.email.trim().to_ascii_lowercase()
));
if let Some(locked_until) = self
.registry
.login_backoff_locked_until(&scope_hash)
.await?
{
let retry_after_ms = (locked_until - OffsetDateTime::now_utc())
.whole_milliseconds()
.clamp(1, 300_000);
return Err(ApiError::rate_limited_with_context(
"login temporarily unavailable",
json!({
"retry_after_ms": retry_after_ms,
"error_code": "login_throttled",
"recovery": "retry_after_delay"
}),
));
}
let authenticated = match self.authenticate_login(&payload).await {
Ok(authenticated) => authenticated,
Err(error) => {
let _ = self
.registry
.record_login_failure(&scope_hash, &OffsetDateTime::now_utc())
.await;
self.record_admin_security_audit(None, "login_rejected", "rejected", "login")
.await;
return Err(error);
}
};
self.registry.reset_login_backoff(&scope_hash).await?;
self.record_admin_security_audit(
Some(&authenticated.user.id),
"login_succeeded",
"success",
"login",
)
.await;
self.create_session_for_user(authenticated.user).await
}
async fn create_session_for_user(
&self,
user: User,
) -> Result<(SessionCookie, SessionResponse), ApiError> {
let session_cookie = create_session_cookie(&self.auth_settings)?;
let csrf_token = create_csrf_token_value();
let secret_hash = hash_session_secret(
&session_cookie.session_id,
&session_cookie.value,
&self.auth_settings.session_secret,
);
let memberships = self
.registry
.list_workspaces_for_user(&authenticated.user.id)
.await?;
let csrf_hash = hash_csrf_token(
&session_cookie.session_id,
&csrf_token,
&self.auth_settings.session_secret,
);
let memberships = self.registry.list_workspaces_for_user(&user.id).await?;
let default_workspace_id = memberships
.iter()
.find(|membership| membership.workspace.id.as_str() == "ws_default")
.map(|membership| membership.workspace.id.as_str().to_owned());
let current_workspace_id = default_workspace_id.or_else(|| {
authenticated
.current_workspace_id
.as_ref()
.map(|workspace_id| workspace_id.as_str().to_owned())
.or_else(|| {
memberships
.first()
.map(|membership| membership.workspace.id.as_str().to_owned())
})
memberships
.first()
.map(|membership| membership.workspace.id.as_str().to_owned())
});
let current_workspace_ref = current_workspace_id
.as_ref()
@@ -120,9 +264,10 @@ impl AdminService {
self.registry
.create_user_session(
&session_cookie.session_id,
&authenticated.user.id,
&user.id,
current_workspace_ref.as_ref(),
&secret_hash,
Some(&csrf_hash),
&session_cookie.expires_at,
)
.await?;
@@ -130,9 +275,10 @@ impl AdminService {
Ok((
session_cookie,
SessionResponse {
user: authenticated.user,
user,
memberships,
current_workspace_id,
csrf_token,
},
))
}
@@ -158,7 +304,19 @@ impl AdminService {
.registry
.get_auth_user_by_email(&payload.email)
.await?
.ok_or_else(|| ApiError::unauthorized("invalid email or password"))?;
.ok_or_else(|| {
let _ = hash_password(&payload.password, &self.auth_settings.password_pepper);
ApiError::unauthorized("invalid email or password")
})?;
if user.user.status != UserStatus::Active {
let _ = verify_password(
&payload.password,
&self.auth_settings.password_pepper,
&user.password_hash,
);
return Err(ApiError::unauthorized("invalid email or password"));
}
if !verify_password(
&payload.password,
@@ -181,6 +339,8 @@ impl AdminService {
_session_value: &str,
) -> Result<(), ApiError> {
self.registry.revoke_user_session(session_id).await?;
self.record_admin_security_audit(None, "logout", "success", "session")
.await;
Ok(())
}
@@ -190,21 +350,23 @@ impl AdminService {
session_id: &UserSessionId,
session_value: &str,
) -> Result<Option<SessionResponse>, ApiError> {
Ok(self
.get_session(session_id, session_value)
.await?
.map(|session| SessionResponse {
user: session.user,
memberships: session.memberships,
current_workspace_id: session
.current_workspace_id
.map(|id| id.as_str().to_owned()),
}))
let Some(session) = self.get_session(session_id, session_value).await? else {
return Ok(None);
};
Ok(Some(SessionResponse {
user: session.user,
memberships: session.memberships,
current_workspace_id: session
.current_workspace_id
.map(|id| id.as_str().to_owned()),
csrf_token: String::new(),
}))
}
pub async fn update_profile(
&self,
user_id: &crank_core::UserId,
session_id: &UserSessionId,
current_workspace_id: Option<&WorkspaceId>,
payload: UpdateProfilePayload,
) -> Result<SessionResponse, ApiError> {
@@ -221,18 +383,19 @@ impl AdminService {
user,
memberships,
current_workspace_id: current_workspace_id.map(|id| id.as_str().to_owned()),
csrf_token: self.rotate_session_csrf_token(session_id).await?,
})
}
pub async fn change_password(
&self,
user_id: &crank_core::UserId,
current_session_id: &UserSessionId,
_current_session_id: &UserSessionId,
payload: ChangePasswordPayload,
) -> Result<(), ApiError> {
if payload.new_password.len() < 12 {
if payload.new_password.len() < 12 || payload.new_password.len() > 256 {
return Err(ApiError::validation(
"new password must be at least 12 characters long",
"new password must be between 12 and 256 characters long",
));
}
@@ -258,13 +421,48 @@ impl AdminService {
let password_hash =
hash_password(&payload.new_password, &self.auth_settings.password_pepper)?;
self.registry
.update_user_password_and_revoke_other_sessions(
user_id,
current_session_id,
&password_hash,
)
.update_user_password_and_revoke_all_sessions(user_id, &password_hash)
.await?;
self.record_admin_security_audit(Some(user_id), "password_rotated", "success", "password")
.await;
Ok(())
}
async fn record_admin_security_audit(
&self,
actor_user_id: Option<&UserId>,
action: &'static str,
outcome: &'static str,
source: &'static str,
) {
let (request_id, trace_id) = crank_observability::current_request_correlation();
let audit_id = new_prefixed_id("audit");
let _ = self
.registry
.record_admin_security_audit(AdminSecurityAuditRequest {
id: &audit_id,
action,
outcome,
actor_user_id,
session_id: None,
request_id: request_id.as_deref(),
trace_id: trace_id.as_deref(),
source,
})
.await;
}
pub async fn rotate_session_csrf_token(
&self,
session_id: &UserSessionId,
) -> Result<String, ApiError> {
let csrf_token = create_csrf_token_value();
let csrf_hash =
hash_csrf_token(session_id, &csrf_token, &self.auth_settings.session_secret);
self.registry
.update_user_session_csrf(session_id, &csrf_hash)
.await?;
Ok(csrf_token)
}
}
+61 -11
View File
@@ -1,9 +1,9 @@
use std::collections::BTreeMap;
use crank_core::{
AgentId, InvocationLevel, InvocationSource, InvocationStatus, MembershipRole, OperationId,
OperationSecurityLevel, PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus,
Protocol, Target, WizardState, WorkspaceId,
AgentId, AgentOperationBinding, InvocationLevel, InvocationSource, InvocationStatus,
MembershipRole, OperationId, OperationSecurityLevel, PlatformApiKeyKind, PlatformApiKeyScope,
PlatformApiKeyStatus, Protocol, Target, WizardState, WorkspaceId,
};
use crank_mapping::{JsonPathRoot, infer_mapping_from_samples};
use crank_mapping::{MappingRule, MappingSet};
@@ -87,8 +87,12 @@ impl AdminService {
async fn cleanup_legacy_demo_assets(&self, workspace_id: &WorkspaceId) -> Result<(), ApiError> {
for slug in ["revops-copilot", "support-triage"] {
if let Some(agent) = self.find_agent_by_slug(workspace_id, slug).await? {
self.delete_agent(workspace_id, &AgentId::new(agent.id.as_str().to_owned()))
.await?;
self.delete_agent(
workspace_id,
&AgentId::new(agent.id.as_str().to_owned()),
None,
)
.await?;
}
}
@@ -129,7 +133,10 @@ impl AdminService {
.await
{
Ok(()) => Ok(()),
Err(RegistryError::OperationHasPublishedAgentBindings { .. }) => {
Err(
RegistryError::OperationHasPublishedAgentBindings { .. }
| RegistryError::OperationDeleteForbidden { .. },
) => {
tracing::warn!(
name: "admin.demo_operation.cleanup_skipped",
operation_id = %operation_id.as_str(),
@@ -168,6 +175,7 @@ impl AdminService {
expires_at: None,
allowed_origins: Vec::new(),
},
None,
)
.await?
.api_key
@@ -175,7 +183,7 @@ impl AdminService {
};
if revoke && key.api_key.status != PlatformApiKeyStatus::Revoked {
self.revoke_agent_platform_api_key(workspace_id, agent_id, &key.api_key.id)
self.revoke_agent_platform_api_key(workspace_id, agent_id, &key.api_key.id, None)
.await?;
}
@@ -210,8 +218,17 @@ impl AdminService {
return Ok(());
}
self.publish_operation(workspace_id, &summary.id, summary.current_draft_version)
.await?;
self.publish_operation(
workspace_id,
&summary.id,
summary.current_draft_version,
&crank_registry::OperationStateExpectation {
current_draft_version: summary.current_draft_version,
status: summary.status,
latest_published_version: summary.latest_published_version,
},
)
.await?;
Ok(())
}
@@ -273,10 +290,15 @@ impl AdminService {
publish: bool,
) -> Result<(), ApiError> {
let summary = self.get_agent(workspace_id, agent_id).await?;
self.save_agent_bindings(workspace_id, agent_id, bindings.into())
let current = self
.get_agent_version(workspace_id, agent_id, summary.current_draft_version)
.await?;
if !demo_agent_bindings_match(&current.bindings, &bindings) {
self.save_agent_bindings(workspace_id, agent_id, bindings.into(), None)
.await?;
}
if publish && summary.latest_published_version.is_none() {
self.publish_agent(workspace_id, agent_id, summary.current_draft_version)
self.publish_agent(workspace_id, agent_id, summary.current_draft_version, None)
.await?;
}
@@ -294,11 +316,16 @@ impl AdminService {
.list_invocation_logs(ListInvocationLogsQuery {
workspace_id,
level: None,
status: None,
outcome_group: None,
search_text: None,
source: None,
operation_id: None,
agent_id: None,
created_after: None,
created_before: None,
cursor_created_at: None,
cursor_id: None,
limit: 1,
})
.await?
@@ -329,6 +356,10 @@ impl AdminService {
message: "Frankfurter returned latest exchange rate".to_owned(),
status_code: Some(200),
error_kind: None,
execution_stage: Some(crank_core::ExecutionStage::Runtime),
execution_error_code: None,
retryability: Some(crank_core::Retryability::Never),
outcome_certainty: Some(crank_core::OutcomeCertainty::Certain),
duration_ms: 124,
request_preview: json!({
"path": {},
@@ -343,6 +374,25 @@ impl AdminService {
}
}
fn demo_agent_bindings_match(
existing: &[AgentOperationBinding],
desired: &[AgentBindingPayload],
) -> bool {
if existing.len() != desired.len() {
return false;
}
let mut desired = desired.iter().collect::<Vec<_>>();
desired.sort_by(|left, right| left.tool_name.cmp(&right.tool_name));
existing.iter().zip(desired).all(|(existing, desired)| {
existing.operation_id.as_str() == desired.operation_id
&& existing.operation_version == desired.operation_version
&& existing.tool_name == desired.tool_name
&& existing.tool_title == desired.tool_title
&& existing.tool_description_override == desired.tool_description_override
&& existing.enabled == desired.enabled
})
}
fn demo_currency_agent_payload() -> AgentPayload {
AgentPayload {
slug: "currency-rates".to_owned(),
+497 -45
View File
@@ -1,16 +1,26 @@
use crank_core::{ConfigExport, WorkspaceId};
use crank_registry::RegistryOperation;
use crank_core::{OperationStatus, Target, WorkspaceId};
use crank_registry::OperationStateExpectation;
use serde::de::{self, Deserialize, Deserializer, MapAccess, SeqAccess, Visitor};
use serde_yaml::Value as YamlValue;
use std::fmt;
use tracing::{info, instrument};
use crate::{
error::ApiError,
import_guidance::import_guidance_warnings,
service::{
AdminService, ExportQuery, ImportMode, ImportQuery, ImportResponse, NewVersionPayload,
OperationPayload, YamlOperationDocument,
AdminService, ExportQuery, ImportMode, ImportQuery, ImportResponse,
LegacyYamlOperationDocument, NewVersionPayload, OperationPayload, PortableOperation,
YamlOperationDocument,
},
};
const MAX_YAML_BYTES: usize = 256 * 1024;
const MAX_YAML_LINE_OR_SCALAR_BYTES: usize = 64 * 1024;
const MAX_YAML_DEPTH: usize = 64;
const MAX_YAML_NODES: usize = 20_000;
const MAX_YAML_COLLECTION_ITEMS: usize = 4_096;
impl AdminService {
#[instrument(skip(self), fields(operation_id = %operation_id.as_str(), version = query.version.unwrap_or_default(), mode = ?query.mode))]
pub async fn export_operation(
@@ -30,19 +40,30 @@ impl AdminService {
let record = self
.get_operation_version(workspace_id, operation_id, version)
.await?;
let document = YamlOperationDocument {
format_version: "1".to_owned(),
kind: "operation".to_owned(),
operation: RegistryOperation {
config_export: Some(ConfigExport {
format_version: "1".to_owned(),
export_mode: query.mode,
let aggregate = self.get_operation(workspace_id, operation_id).await?;
let exportable = record.status == OperationStatus::Published
|| (aggregate.status != OperationStatus::Archived
&& record.status == OperationStatus::Draft
&& aggregate.current_draft_version == version);
if !exportable {
return Err(ApiError::conflict_with_context(
"only the active current Draft or a Published Version can be exported",
serde_json::json!({
"error_code": "operation_invalid_transition",
"version": version
}),
..record.snapshot
},
));
}
let operation = PortableOperation::from_registry(&record.snapshot);
reject_credential_material(&operation)?;
let document = YamlOperationDocument {
format_version: "2".to_owned(),
kind: "operation".to_owned(),
operation,
};
serde_yaml::to_string(&document).map_err(|error| ApiError::internal(error.to_string()))
serde_yaml::to_string(&document)
.map_err(|_| ApiError::internal("portable yaml serialization failed"))
}
#[instrument(skip(self, yaml_document), fields(mode = ?query.mode))]
@@ -51,29 +72,41 @@ impl AdminService {
workspace_id: &WorkspaceId,
query: ImportQuery,
yaml_document: &str,
if_match: Option<&str>,
) -> Result<ImportResponse, ApiError> {
let document: YamlOperationDocument = serde_yaml::from_str(yaml_document)
.map_err(|error| ApiError::validation(error.to_string()))?;
if document.kind != "operation" {
return Err(ApiError::validation("yaml kind must be operation"));
}
reject_yaml_anchors_and_aliases(yaml_document)?;
let parsed = parse_bounded_yaml(yaml_document)?;
let format_version = parsed
.as_mapping()
.and_then(|mapping| mapping.get(YamlValue::String("format_version".to_owned())))
.and_then(YamlValue::as_str)
.ok_or_else(yaml_invalid)?;
let payload = OperationPayload {
name: document.operation.name.clone(),
display_name: document.operation.display_name.clone(),
category: document.operation.category.clone(),
protocol: document.operation.protocol,
security_level: document.operation.security_level,
target: document.operation.target.clone(),
input_schema: document.operation.input_schema.clone(),
output_schema: document.operation.output_schema.clone(),
input_mapping: document.operation.input_mapping.clone(),
output_mapping: document.operation.output_mapping.clone(),
execution_config: document.operation.execution_config.clone(),
tool_description: document.operation.tool_description.clone(),
wizard_state: document.operation.wizard_state.clone(),
let (operation, warnings) = match format_version {
"2" => {
let document: YamlOperationDocument =
serde_yaml::from_value(parsed).map_err(|_| yaml_invalid())?;
if document.kind != "operation" || document.format_version != "2" {
return Err(yaml_unsupported());
}
(document.operation, Vec::new())
}
"1" => {
let document: LegacyYamlOperationDocument =
serde_yaml::from_value(parsed).map_err(|_| yaml_invalid())?;
if document.kind != "operation" || document.format_version != "1" {
return Err(yaml_unsupported());
}
let warnings = import_guidance_warnings(&document.operation);
(
PortableOperation::from_registry(&document.operation),
warnings,
)
}
_ => return Err(yaml_unsupported()),
};
let warnings = import_guidance_warnings(&document.operation);
reject_credential_material(&operation)?;
let payload = operation.to_payload();
match query.mode {
ImportMode::Create => {
@@ -88,9 +121,66 @@ impl AdminService {
}
ImportMode::Upsert => {
if let Some(existing) = self
.find_operation_by_name(workspace_id, &document.operation.name)
.find_operation_by_name(workspace_id, &operation.name)
.await?
{
if existing.status == OperationStatus::Archived {
return Err(crank_registry::RegistryError::OperationArchived {
operation_id: existing.id.as_str().to_owned(),
}
.into());
}
let current = self
.get_operation_version(
workspace_id,
&existing.id,
existing.current_draft_version,
)
.await?;
if operation.matches_registry(&current.snapshot) {
let expected_state = OperationStateExpectation {
current_draft_version: existing.current_draft_version,
status: existing.status,
latest_published_version: existing.latest_published_version,
};
self.registry
.verify_operation_state(workspace_id, &existing.id, &expected_state)
.await?;
return Ok(ImportResponse {
operation_id: existing.id.as_str().to_owned(),
workspace_id: workspace_id.as_str().to_owned(),
version: existing.current_draft_version,
import_mode: ImportMode::Upsert,
warnings,
});
}
let current_detail = self.get_operation(workspace_id, &existing.id).await?;
let expected_state = OperationStateExpectation {
current_draft_version: current_detail.current_draft_version,
status: current_detail.status,
latest_published_version: current_detail.latest_published_version,
};
let expected_etag = Self::operation_state_etag(&current_detail);
let provided_etag = if_match.ok_or_else(|| {
ApiError::precondition_required_with_context(
"If-Match is required when YAML upsert changes an existing Operation",
serde_json::json!({
"error_code": "operation_precondition_required",
"current_version": existing.current_draft_version,
"recovery": "reload"
}),
)
})?;
if provided_etag != expected_etag {
return Err(ApiError::conflict_with_context(
"Operation state changed; reload before retrying YAML upsert",
serde_json::json!({
"error_code": "operation_stale_version",
"current_version": existing.current_draft_version,
"recovery": "reload"
}),
));
}
let created = self
.create_version(
workspace_id,
@@ -99,9 +189,9 @@ impl AdminService {
operation: payload,
change_note: Some("yaml upsert".to_owned()),
},
&expected_state,
)
.await?;
let response = ImportResponse {
operation_id: created.operation_id,
workspace_id: created.workspace_id,
@@ -118,22 +208,384 @@ impl AdminService {
Ok(response)
} else {
let created = self.create_operation(workspace_id, payload).await?;
let response = ImportResponse {
Ok(ImportResponse {
operation_id: created.operation_id,
workspace_id: created.workspace_id,
version: created.version,
import_mode: ImportMode::Upsert,
warnings,
};
info!(
name: "admin.operation.imported",
operation_id = %response.operation_id,
version = response.version,
"operation imported by upsert"
);
Ok(response)
})
}
}
}
}
}
impl PortableOperation {
fn from_registry(operation: &crank_registry::RegistryOperation) -> Self {
Self {
name: operation.name.clone(),
display_name: operation.display_name.clone(),
category: operation.category.clone(),
protocol: operation.protocol,
security_level: operation.security_level,
target: operation.target.clone(),
input_schema: operation.input_schema.clone(),
output_schema: operation.output_schema.clone(),
input_mapping: operation.input_mapping.clone(),
output_mapping: operation.output_mapping.clone(),
execution_config: operation.execution_config.clone(),
tool_description: operation.tool_description.clone(),
}
}
fn to_payload(&self) -> OperationPayload {
OperationPayload {
name: self.name.clone(),
display_name: self.display_name.clone(),
category: self.category.clone(),
protocol: self.protocol,
security_level: self.security_level,
target: self.target.clone(),
input_schema: self.input_schema.clone(),
output_schema: self.output_schema.clone(),
input_mapping: self.input_mapping.clone(),
output_mapping: self.output_mapping.clone(),
execution_config: self.execution_config.clone(),
tool_description: self.tool_description.clone(),
wizard_state: None,
}
}
fn matches_registry(&self, operation: &crank_registry::RegistryOperation) -> bool {
self.name == operation.name
&& self.display_name == operation.display_name
&& self.category == operation.category
&& self.protocol == operation.protocol
&& self.security_level == operation.security_level
&& self.target == operation.target
&& self.input_schema == operation.input_schema
&& self.output_schema == operation.output_schema
&& self.input_mapping == operation.input_mapping
&& self.output_mapping == operation.output_mapping
&& self.execution_config == operation.execution_config
&& self.tool_description == operation.tool_description
}
}
fn parse_bounded_yaml(document: &str) -> Result<YamlValue, ApiError> {
if document.is_empty() {
return Err(yaml_invalid());
}
if document.len() > MAX_YAML_BYTES {
return Err(ApiError::payload_too_large_with_context(
"operation yaml exceeds the 256 KiB limit",
serde_json::json!({ "error_code": "operation_yaml_too_large" }),
));
}
if document
.lines()
.any(|line| line.len() > MAX_YAML_LINE_OR_SCALAR_BYTES)
{
return Err(yaml_unsupported());
}
let separators = document.lines().filter(|line| line.trim() == "---").count();
if separators > 1 || document.lines().any(|line| line.trim() == "...") {
return Err(yaml_unsupported());
}
DuplicateChecked::deserialize(serde_yaml::Deserializer::from_str(document))
.map_err(|_| yaml_invalid())?;
let parsed: YamlValue = serde_yaml::from_str(document).map_err(|_| yaml_invalid())?;
let mut nodes = 0;
validate_yaml_value(&parsed, 0, &mut nodes)?;
Ok(parsed)
}
/// A bounded preflight pass which rejects duplicate mapping keys before
/// `serde_yaml::Value` can collapse them. Values are deliberately discarded:
/// the authoritative typed parse follows only after this structural check.
struct DuplicateChecked;
impl<'de> Deserialize<'de> for DuplicateChecked {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_any(DuplicateCheckedVisitor)
}
}
struct DuplicateCheckedVisitor;
impl<'de> Visitor<'de> for DuplicateCheckedVisitor {
type Value = DuplicateChecked;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("a YAML value without duplicate mapping keys")
}
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: MapAccess<'de>,
{
let mut keys = Vec::<YamlValue>::new();
while let Some(key) = map.next_key::<YamlValue>()? {
if keys.iter().any(|existing| existing == &key) {
return Err(de::Error::custom("duplicate mapping key"));
}
keys.push(key);
map.next_value::<DuplicateChecked>()?;
}
Ok(DuplicateChecked)
}
fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
where
A: SeqAccess<'de>,
{
while sequence.next_element::<DuplicateChecked>()?.is_some() {}
Ok(DuplicateChecked)
}
fn visit_bool<E>(self, _: bool) -> Result<Self::Value, E> {
Ok(DuplicateChecked)
}
fn visit_i64<E>(self, _: i64) -> Result<Self::Value, E> {
Ok(DuplicateChecked)
}
fn visit_u64<E>(self, _: u64) -> Result<Self::Value, E> {
Ok(DuplicateChecked)
}
fn visit_f64<E>(self, _: f64) -> Result<Self::Value, E> {
Ok(DuplicateChecked)
}
fn visit_str<E>(self, _: &str) -> Result<Self::Value, E> {
Ok(DuplicateChecked)
}
fn visit_string<E>(self, _: String) -> Result<Self::Value, E> {
Ok(DuplicateChecked)
}
fn visit_none<E>(self) -> Result<Self::Value, E> {
Ok(DuplicateChecked)
}
fn visit_unit<E>(self) -> Result<Self::Value, E> {
Ok(DuplicateChecked)
}
fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
where
D: Deserializer<'de>,
{
DuplicateChecked::deserialize(deserializer)
}
fn visit_newtype_struct<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
where
D: Deserializer<'de>,
{
DuplicateChecked::deserialize(deserializer)
}
}
fn validate_yaml_value(value: &YamlValue, depth: usize, nodes: &mut usize) -> Result<(), ApiError> {
if depth > MAX_YAML_DEPTH {
return Err(yaml_unsupported());
}
*nodes = nodes.saturating_add(1);
if *nodes > MAX_YAML_NODES {
return Err(yaml_unsupported());
}
match value {
YamlValue::Sequence(values) => {
if values.len() > MAX_YAML_COLLECTION_ITEMS {
return Err(yaml_unsupported());
}
for value in values {
validate_yaml_value(value, depth + 1, nodes)?;
}
}
YamlValue::Mapping(values) => {
if values.len() > MAX_YAML_COLLECTION_ITEMS {
return Err(yaml_unsupported());
}
for (key, value) in values {
validate_yaml_value(key, depth + 1, nodes)?;
validate_yaml_value(value, depth + 1, nodes)?;
}
}
YamlValue::String(value) if value.len() > MAX_YAML_LINE_OR_SCALAR_BYTES => {
return Err(yaml_unsupported());
}
YamlValue::Tagged(_) => return Err(yaml_unsupported()),
_ => {}
}
Ok(())
}
fn reject_credential_material(operation: &PortableOperation) -> Result<(), ApiError> {
let sensitive_name = |name: &str| {
let normalized = name.to_ascii_lowercase();
let segments = normalized.split(['-', '_', '.']).collect::<Vec<_>>();
matches!(
normalized.as_str(),
"authorization" | "proxy-authorization" | "cookie" | "set-cookie"
) || segments
.iter()
.any(|segment| matches!(*segment, "auth" | "token" | "secret" | "cookie"))
|| segments.windows(2).any(|parts| parts == ["api", "key"])
};
let sensitive_value = |value: &str| {
let normalized = value.trim().to_ascii_lowercase();
normalized.starts_with("bearer ")
|| normalized.starts_with("basic ")
|| normalized.starts_with("digest ")
|| normalized.contains("api_key=")
|| normalized.contains("access_token=")
};
let target_headers = match &operation.target {
Target::Rest(target) => &target.static_headers,
};
if target_headers
.iter()
.any(|(name, value)| sensitive_name(name) || sensitive_value(value))
|| operation
.execution_config
.headers
.iter()
.any(|(name, value)| sensitive_name(name) || sensitive_value(value))
{
return Err(ApiError::unprocessable_with_context(
"portable yaml contains credential-bearing headers",
serde_json::json!({ "error_code": "operation_yaml_invalid" }),
));
}
Ok(())
}
fn reject_yaml_anchors_and_aliases(document: &str) -> Result<(), ApiError> {
let mut single_quote = false;
let mut double_quote = false;
let mut escaped = false;
let mut comment = false;
let mut previous = '\n';
let chars = document.chars().collect::<Vec<_>>();
for (index, character) in chars.iter().copied().enumerate() {
if character == '\n' {
comment = false;
previous = character;
continue;
}
if comment {
previous = character;
continue;
}
if double_quote {
if escaped {
escaped = false;
} else if character == '\\' {
escaped = true;
} else if character == '"' {
double_quote = false;
}
previous = character;
continue;
}
if single_quote {
if character == '\'' {
single_quote = false;
}
previous = character;
continue;
}
match character {
'#' => comment = true,
'"' => double_quote = true,
'\'' => single_quote = true,
'&' | '*'
if (previous.is_whitespace() || matches!(previous, ':' | '[' | '{' | ','))
&& chars.get(index + 1).is_some_and(|next| {
next.is_ascii_alphanumeric() || matches!(next, '_' | '-')
}) =>
{
return Err(yaml_unsupported());
}
_ => {}
}
previous = character;
}
Ok(())
}
fn yaml_invalid() -> ApiError {
ApiError::unprocessable_with_context(
"operation yaml is invalid",
serde_json::json!({ "error_code": "operation_yaml_invalid" }),
)
}
fn yaml_unsupported() -> ApiError {
ApiError::unprocessable_with_context(
"operation yaml uses an unsupported construct",
serde_json::json!({ "error_code": "operation_yaml_unsupported" }),
)
}
#[cfg(test)]
mod tests {
use super::parse_bounded_yaml;
use serde_json::Value;
#[test]
fn portable_v2_schema_freezes_the_closed_top_level_contract() {
let schema: Value = serde_json::from_str(include_str!(
"../../../../docs/schemas/operation-export-v2.schema.json"
))
.unwrap();
assert_eq!(schema["properties"]["format_version"]["const"], "2");
assert_eq!(schema["properties"]["kind"]["const"], "operation");
assert_eq!(schema["additionalProperties"], false);
assert_eq!(
schema["$defs"]["portable_operation"]["additionalProperties"],
false
);
assert_eq!(
schema["$defs"]["portable_operation"]["properties"]["security_level"]["enum"],
serde_json::json!(["standard"])
);
for name in [
"rest_target",
"schema",
"mapping_set",
"mapping_rule",
"execution_config",
"tool_description",
] {
assert_eq!(schema["$defs"][name]["additionalProperties"], false);
}
assert_eq!(
schema["$defs"]["portable_operation"]["properties"]["target"]["$ref"],
"#/$defs/rest_target"
);
}
#[test]
fn bounded_yaml_rejects_duplicate_keys_before_typed_parsing() {
let document =
"format_version: '2'\nkind: operation\nkind: attacker_override\noperation: {}\n";
let error = parse_bounded_yaml(document).expect_err("duplicate key must fail closed");
match error {
crate::error::ApiError::Unprocessable { context, .. } => assert_eq!(
context.and_then(|value| value["error_code"].as_str().map(str::to_owned)),
Some("operation_yaml_invalid".to_owned())
),
other => panic!("unexpected error: {other:?}"),
}
}
}
+466 -21
View File
@@ -1,28 +1,52 @@
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use crank_core::{
AgentId, ApprovalRequestId, ApprovalRequestStatus, InvocationLogId, OperationId, UsagePeriod,
WorkspaceId,
};
use crank_registry::{
ApprovalRequestRecord, ExpireApprovalRequest, InvocationLogRecord, ListApprovalRequestsQuery,
ListInvocationLogsQuery, UsageQuery, UsageRollupRecord,
ApprovalRequestRecord, DecideApprovalRequest, ExpireApprovalRequest, InvocationLogRecord,
InvocationRetentionOutcome, ListApprovalRequestsQuery, ListInvocationLogsQuery, UsageQuery,
UsageRollupRecord,
};
use serde_json::json;
use time::OffsetDateTime;
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tracing::instrument;
use crate::{
error::ApiError,
service::{
AdminService, ApprovalsQuery, LogsQuery, UsageOverviewResponse, UsageRequestQuery,
usage_window,
AdminService, ApprovalDecisionPayload, ApprovalsQuery, LogsListResponse, LogsQuery,
UsageOverviewResponse, UsageRequestQuery, usage_window,
},
};
const LOG_LIST_DEFAULT_LIMIT: u32 = 100;
const LOG_LIST_MAX_LIMIT: u32 = 200;
const LOG_CSV_MAX_ROWS: u32 = 1_000;
const MAX_EXPLICIT_USAGE_WINDOW_DAYS: i64 = 90;
#[derive(serde::Serialize, serde::Deserialize)]
struct LogsCursor {
created_at: String,
id: String,
}
fn safe_approval_record(mut record: ApprovalRequestRecord) -> ApprovalRequestRecord {
record.approval.request_payload =
crank_core::sanitize_invocation_preview(&record.approval.request_payload);
record.approval.response_payload = record
.approval
.response_payload
.as_ref()
.map(crank_core::sanitize_invocation_preview);
record
}
impl AdminService {
pub async fn cleanup_invocation_logs_before(
&self,
cutoff: OffsetDateTime,
) -> Result<u64, ApiError> {
) -> Result<InvocationRetentionOutcome, ApiError> {
self.registry
.delete_invocation_logs_before(cutoff)
.await
@@ -34,25 +58,45 @@ impl AdminService {
&self,
workspace_id: &WorkspaceId,
query: LogsQuery,
) -> Result<Vec<InvocationLogRecord>, ApiError> {
) -> Result<LogsListResponse, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let operation_id = query.operation_id.as_deref().map(OperationId::new);
let agent_id = query.agent_id.as_deref().map(AgentId::new);
let (_, created_after, _) = usage_window(query.period.unwrap_or(UsagePeriod::Last7Days))?;
let (_, created_after, created_before, _) =
resolve_usage_window(query.period.unwrap_or(UsagePeriod::Last7Days), &query)?;
let (cursor_created_at, cursor_id) = decode_logs_cursor(query.cursor.as_deref())?;
let limit = query
.limit
.unwrap_or(LOG_LIST_DEFAULT_LIMIT)
.clamp(1, LOG_LIST_MAX_LIMIT);
self.registry
let mut items = self
.registry
.list_invocation_logs(ListInvocationLogsQuery {
workspace_id,
level: query.level,
status: query.status,
outcome_group: query.outcome_group,
search_text: query.search.as_deref(),
source: query.source,
operation_id: operation_id.as_ref(),
agent_id: agent_id.as_ref(),
created_after: Some(&created_after),
limit: query.limit.unwrap_or(100),
created_before: Some(&created_before),
cursor_created_at: cursor_created_at.as_deref(),
cursor_id: cursor_id.as_ref(),
limit: limit.saturating_add(1),
})
.await
.map_err(ApiError::from)
.await?;
let next_cursor = if items.len() > limit as usize {
items.truncate(limit as usize);
items.last().map(encode_logs_cursor).transpose()?
} else {
None
};
Ok(LogsListResponse { items, next_cursor })
}
#[instrument(skip(self))]
@@ -72,6 +116,69 @@ impl AdminService {
})
}
#[instrument(skip(self))]
pub async fn export_logs_csv(
&self,
workspace_id: &WorkspaceId,
mut query: LogsQuery,
) -> Result<String, ApiError> {
query.limit = Some(
query
.limit
.unwrap_or(LOG_CSV_MAX_ROWS)
.clamp(1, LOG_CSV_MAX_ROWS),
);
query.cursor = None;
let page = self.list_logs(workspace_id, query).await?;
let mut csv = String::from(
"created_at,level,status,source,agent,operation,operation_version,duration_ms,request_id,trace_id,stage,error_code,message,request_preview,response_preview\n",
);
for record in page.items {
let log = record.log;
let row = [
log.created_at
.format(&Rfc3339)
.map_err(|error| ApiError::internal(error.to_string()))?,
serialize_csv_text(log.level)?,
serialize_csv_text(log.status)?,
serialize_csv_text(log.source)?,
record
.agent_display_name
.or(record.agent_slug)
.unwrap_or_default(),
record.operation_display_name,
log.operation_version
.map(|value| value.to_string())
.unwrap_or_default(),
log.duration_ms.to_string(),
log.request_id.unwrap_or_default(),
log.trace_id.unwrap_or_default(),
log.execution_stage
.map(serialize_csv_text)
.transpose()?
.unwrap_or_default(),
log.execution_error_code
.map(serialize_csv_text)
.transpose()?
.unwrap_or_default(),
log.message,
serde_json::to_string(&log.request_preview)
.map_err(|error| ApiError::internal(error.to_string()))?,
serde_json::to_string(&log.response_preview)
.map_err(|error| ApiError::internal(error.to_string()))?,
];
csv.push_str(&row.into_iter().map(csv_cell).collect::<Vec<_>>().join(","));
csv.push('\n');
if csv.len() > 1_048_576 {
return Err(ApiError::unprocessable_with_context(
"logs CSV export exceeded the bounded response size",
json!({ "error_code": "logs_csv_too_large" }),
));
}
}
Ok(csv)
}
#[instrument(skip(self))]
pub async fn list_approvals(
&self,
@@ -92,7 +199,7 @@ impl AdminService {
for record in records {
let record = self.normalize_approval_record(record).await?;
if query.status.is_none() || record.approval.status == query.status.unwrap() {
normalized.push(record);
normalized.push(safe_approval_record(record));
}
}
@@ -117,7 +224,114 @@ impl AdminService {
)
})?;
self.normalize_approval_record(record).await
self.normalize_approval_record(record)
.await
.map(safe_approval_record)
}
#[instrument(skip(self, payload))]
pub async fn approve_approval(
&self,
workspace_id: &WorkspaceId,
approval_id: &ApprovalRequestId,
payload: ApprovalDecisionPayload,
) -> Result<ApprovalRequestRecord, ApiError> {
if !payload.approve.eq_ignore_ascii_case("yes") {
return Err(ApiError::unprocessable_with_context(
"approve must be yes for approval confirmation",
json!({ "error_code": "invalid_decision_payload" }),
));
}
self.decide_admin_approval(
workspace_id,
approval_id,
ApprovalRequestStatus::Approved,
Some(json!({"approve": "yes"})),
payload.note.as_deref(),
)
.await
}
#[instrument(skip(self, payload))]
pub async fn deny_approval(
&self,
workspace_id: &WorkspaceId,
approval_id: &ApprovalRequestId,
payload: ApprovalDecisionPayload,
) -> Result<ApprovalRequestRecord, ApiError> {
if !payload.approve.eq_ignore_ascii_case("no") {
return Err(ApiError::unprocessable_with_context(
"approve must be no for approval denial",
json!({ "error_code": "invalid_decision_payload" }),
));
}
self.decide_admin_approval(
workspace_id,
approval_id,
ApprovalRequestStatus::Denied,
Some(json!({"approve": "no"})),
payload.note.as_deref(),
)
.await
}
async fn decide_admin_approval(
&self,
workspace_id: &WorkspaceId,
approval_id: &ApprovalRequestId,
status: ApprovalRequestStatus,
response_payload: Option<serde_json::Value>,
decision_note: Option<&str>,
) -> Result<ApprovalRequestRecord, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let current = self
.registry
.get_approval_request(workspace_id, approval_id)
.await?
.ok_or_else(|| {
ApiError::not_found_with_context(
format!("approval request {} was not found", approval_id.as_str()),
json!({ "approval_id": approval_id.as_str() }),
)
})?;
let normalized = self.normalize_approval_record(current).await?;
if normalized.approval.status != ApprovalRequestStatus::Pending {
return Err(ApiError::conflict_with_context(
"approval request is not pending",
json!({
"approval_id": approval_id.as_str(),
"status": normalized.approval.status,
"error_code": "approval_state_conflict"
}),
));
}
let decided = self
.registry
.decide_approval_request(DecideApprovalRequest {
workspace_id,
agent_id: &normalized.approval.agent_id,
approval_id,
operation_id: &normalized.approval.operation_id,
operation_version: normalized.approval.operation_version,
request_payload: &normalized.approval.request_payload,
status,
decided_at: OffsetDateTime::now_utc(),
decided_by_key_id: None,
response_payload,
decision_note,
})
.await?
.ok_or_else(|| {
ApiError::conflict_with_context(
"approval request state changed before decision was recorded",
json!({
"approval_id": approval_id.as_str(),
"error_code": "approval_state_conflict"
}),
)
})?;
Ok(safe_approval_record(decided))
}
async fn normalize_approval_record(
@@ -149,13 +363,14 @@ impl AdminService {
query: UsageRequestQuery,
) -> Result<UsageOverviewResponse, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let (period, created_after, bucket) =
usage_window(query.period.unwrap_or(UsagePeriod::Last7Days))?;
let (period, created_after, created_before, bucket) =
resolve_usage_request_window(query.period.unwrap_or(UsagePeriod::Last7Days), &query)?;
let usage_query = UsageQuery {
workspace_id,
period,
source: query.source,
created_after: &created_after,
created_before: &created_before,
bucket,
};
@@ -168,16 +383,104 @@ impl AdminService {
.registry
.list_usage_by_operation(usage_query.clone())
.await?;
let agents = self.registry.list_usage_by_agent(usage_query).await?;
let agents = self
.registry
.list_usage_by_agent(usage_query.clone())
.await?;
let outcomes = self.registry.list_usage_outcomes(usage_query).await?;
Ok(UsageOverviewResponse {
summary,
timeline,
operations,
agents,
outcomes,
})
}
#[instrument(skip(self))]
pub async fn export_usage_csv(
&self,
workspace_id: &WorkspaceId,
query: UsageRequestQuery,
) -> Result<String, ApiError> {
let usage = self.get_usage_overview(workspace_id, query).await?;
let mut csv = String::from(
"kind,name,group,error_code,calls_total,calls_error,p50_ms,p95_ms,p99_ms\n",
);
for operation in usage.operations {
csv.push_str(
&[
"operation".to_owned(),
operation.operation_display_name,
String::new(),
String::new(),
operation.calls_total.to_string(),
operation.calls_error.to_string(),
operation.p50_ms.to_string(),
operation.p95_ms.to_string(),
operation.p99_ms.to_string(),
]
.into_iter()
.map(csv_cell)
.collect::<Vec<_>>()
.join(","),
);
csv.push('\n');
}
for agent in usage.agents {
csv.push_str(
&[
"agent".to_owned(),
agent.agent_display_name,
String::new(),
String::new(),
agent.calls_total.to_string(),
agent.calls_error.to_string(),
agent.p50_ms.to_string(),
agent.p95_ms.to_string(),
agent.p99_ms.to_string(),
]
.into_iter()
.map(csv_cell)
.collect::<Vec<_>>()
.join(","),
);
csv.push('\n');
}
for outcome in usage.outcomes {
csv.push_str(
&[
"outcome".to_owned(),
String::new(),
serialize_csv_text(outcome.group)?,
outcome
.execution_error_code
.map(serialize_csv_text)
.transpose()?
.unwrap_or_default(),
outcome.calls_total.to_string(),
String::new(),
outcome.p50_ms.to_string(),
outcome.p95_ms.to_string(),
outcome.p99_ms.to_string(),
]
.into_iter()
.map(csv_cell)
.collect::<Vec<_>>()
.join(","),
);
csv.push('\n');
}
if csv.len() > 1_048_576 {
return Err(ApiError::unprocessable_with_context(
"usage CSV export exceeded the bounded response size",
json!({ "error_code": "usage_csv_too_large" }),
));
}
Ok(csv)
}
#[instrument(skip(self))]
pub async fn get_operation_usage(
&self,
@@ -186,8 +489,8 @@ impl AdminService {
query: UsageRequestQuery,
) -> Result<UsageRollupRecord, ApiError> {
self.get_operation(workspace_id, operation_id).await?;
let (period, created_after, bucket) =
usage_window(query.period.unwrap_or(UsagePeriod::Last7Days))?;
let (period, created_after, created_before, bucket) =
resolve_usage_request_window(query.period.unwrap_or(UsagePeriod::Last7Days), &query)?;
self.registry
.get_usage_for_operation(
@@ -196,6 +499,7 @@ impl AdminService {
period,
source: query.source,
created_after: &created_after,
created_before: &created_before,
bucket,
},
operation_id,
@@ -220,8 +524,8 @@ impl AdminService {
query: UsageRequestQuery,
) -> Result<UsageRollupRecord, ApiError> {
self.get_agent(workspace_id, agent_id).await?;
let (period, created_after, bucket) =
usage_window(query.period.unwrap_or(UsagePeriod::Last7Days))?;
let (period, created_after, created_before, bucket) =
resolve_usage_request_window(query.period.unwrap_or(UsagePeriod::Last7Days), &query)?;
self.registry
.get_usage_for_agent(
@@ -230,6 +534,7 @@ impl AdminService {
period,
source: query.source,
created_after: &created_after,
created_before: &created_before,
bucket,
},
agent_id,
@@ -243,3 +548,143 @@ impl AdminService {
})
}
}
fn resolve_usage_window(
period: UsagePeriod,
query: &LogsQuery,
) -> Result<(UsagePeriod, String, String, crank_registry::UsageBucket), ApiError> {
resolve_explicit_or_period_window(
period,
query.created_after.as_deref(),
query.created_before.as_deref(),
)
}
fn resolve_usage_request_window(
period: UsagePeriod,
query: &UsageRequestQuery,
) -> Result<(UsagePeriod, String, String, crank_registry::UsageBucket), ApiError> {
resolve_explicit_or_period_window(
period,
query.created_after.as_deref(),
query.created_before.as_deref(),
)
}
fn resolve_explicit_or_period_window(
period: UsagePeriod,
created_after: Option<&str>,
created_before: Option<&str>,
) -> Result<(UsagePeriod, String, String, crank_registry::UsageBucket), ApiError> {
match (created_after, created_before) {
(None, None) => usage_window(period),
(Some(_), None) | (None, Some(_)) => Err(invalid_usage_window(
"usage window requires both created_after and created_before",
)),
(Some(after), Some(before)) => {
let after = OffsetDateTime::parse(after, &Rfc3339)
.map_err(|_| invalid_usage_window("created_after must be RFC3339 UTC"))?;
let before = OffsetDateTime::parse(before, &Rfc3339)
.map_err(|_| invalid_usage_window("created_before must be RFC3339 UTC"))?;
if after >= before {
return Err(invalid_usage_window(
"created_after must be before created_before",
));
}
let duration = before - after;
if duration > time::Duration::days(MAX_EXPLICIT_USAGE_WINDOW_DAYS) {
return Err(invalid_usage_window("usage window exceeds maximum range"));
}
let bucket = if duration <= time::Duration::days(1) {
crank_registry::UsageBucket::Hour
} else if duration <= time::Duration::days(14) {
crank_registry::UsageBucket::Day
} else if duration <= time::Duration::days(45) {
crank_registry::UsageBucket::Week
} else {
crank_registry::UsageBucket::Month
};
Ok((
period,
after
.format(&Rfc3339)
.map_err(|error| ApiError::internal(error.to_string()))?,
before
.format(&Rfc3339)
.map_err(|error| ApiError::internal(error.to_string()))?,
bucket,
))
}
}
}
fn invalid_usage_window(message: &'static str) -> ApiError {
ApiError::unprocessable_with_context(message, json!({ "error_code": "usage_window_invalid" }))
}
fn decode_logs_cursor(
cursor: Option<&str>,
) -> Result<(Option<String>, Option<InvocationLogId>), ApiError> {
let Some(cursor) = cursor else {
return Ok((None, None));
};
if cursor.len() > 512 {
return Err(invalid_logs_cursor());
}
let bytes = URL_SAFE_NO_PAD
.decode(cursor)
.map_err(|_| invalid_logs_cursor())?;
let decoded: LogsCursor = serde_json::from_slice(&bytes).map_err(|_| invalid_logs_cursor())?;
OffsetDateTime::parse(&decoded.created_at, &Rfc3339).map_err(|_| invalid_logs_cursor())?;
if decoded.id.is_empty()
|| decoded.id.len() > 128
|| decoded.id.contains(';')
|| decoded.id.contains(',')
{
return Err(invalid_logs_cursor());
}
Ok((
Some(decoded.created_at),
Some(InvocationLogId::new(decoded.id)),
))
}
fn encode_logs_cursor(record: &InvocationLogRecord) -> Result<String, ApiError> {
let cursor = LogsCursor {
created_at: record
.log
.created_at
.format(&Rfc3339)
.map_err(|error| ApiError::internal(error.to_string()))?,
id: record.log.id.as_str().to_owned(),
};
let encoded =
serde_json::to_vec(&cursor).map_err(|error| ApiError::internal(error.to_string()))?;
Ok(URL_SAFE_NO_PAD.encode(encoded))
}
fn invalid_logs_cursor() -> ApiError {
ApiError::unprocessable_with_context(
"logs cursor is invalid",
json!({ "error_code": "logs_cursor_invalid" }),
)
}
fn serialize_csv_text<T: serde::Serialize>(value: T) -> Result<String, ApiError> {
serde_json::to_value(value)
.map_err(|error| ApiError::internal(error.to_string()))?
.as_str()
.map(ToOwned::to_owned)
.ok_or_else(|| ApiError::internal("failed to serialize CSV enum value"))
}
fn csv_cell(mut value: String) -> String {
if value
.chars()
.next()
.is_some_and(|ch| matches!(ch, '=' | '+' | '-' | '@' | '\t' | '\r' | '\n'))
{
value.insert(0, '\'');
}
format!("\"{}\"", value.replace('"', "\"\""))
}
+268
View File
@@ -0,0 +1,268 @@
use crank_core::{
OnboardingProjection, OnboardingStepId, ProductEventId, ProductEventKind, WorkspaceId,
};
use crank_registry::{
OnboardingPresentationMilestone, RecordOnboardingMilestoneRequest, RegistryError,
};
use serde_json::json;
use time::OffsetDateTime;
use crate::{
error::ApiError,
service::{
AdminService, OnboardingEventPayload, OnboardingEventResponse, OnboardingFirstCallEvidence,
OnboardingResponse, OnboardingStepView, ResetOnboardingSelectionResponse, format_timestamp,
new_prefixed_id,
},
};
impl AdminService {
pub async fn get_onboarding(
&self,
workspace_id: &WorkspaceId,
) -> Result<OnboardingResponse, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let projection = self
.registry
.ensure_onboarding_eligibility(workspace_id, OffsetDateTime::now_utc())
.await?;
self.map_onboarding_response(projection).await
}
pub async fn record_onboarding_event(
&self,
workspace_id: &WorkspaceId,
payload: OnboardingEventPayload,
) -> Result<OnboardingEventResponse, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
if payload.completed_steps.is_some() || payload.event == "completed" {
return Err(onboarding_event_not_allowed());
}
if payload.idempotency_key.is_empty() || payload.idempotency_key.len() > 256 {
return Err(ApiError::validation(
"idempotency_key must contain 1..256 bytes",
));
}
if payload.idempotency_key.starts_with("onboarding:") {
return Err(onboarding_event_not_allowed());
}
let (milestone, event_kind) = match payload.event.as_str() {
"started" => (
OnboardingPresentationMilestone::Started,
ProductEventKind::OnboardingStarted,
),
"resumed" => (
OnboardingPresentationMilestone::Resumed,
ProductEventKind::OnboardingResumed,
),
"dismissed" => (
OnboardingPresentationMilestone::Dismissed,
ProductEventKind::OnboardingDismissed,
),
"abandoned" => (
OnboardingPresentationMilestone::Abandoned,
ProductEventKind::OnboardingAbandoned,
),
_ => return Err(onboarding_event_not_allowed()),
};
// Presentation events must never exist outside the server-owned cohort,
// including a direct API call before the first UI snapshot GET.
let current = self
.registry
.ensure_onboarding_eligibility(workspace_id, OffsetDateTime::now_utc())
.await?;
let outcome = self
.registry
.record_onboarding_milestone(RecordOnboardingMilestoneRequest {
workspace_id,
event_id: &ProductEventId::new(new_prefixed_id("pe")),
milestone,
idempotency_key: &payload.idempotency_key,
expected_revision: payload.expected_revision,
occurred_at: OffsetDateTime::now_utc(),
eligible_since: None,
})
.await;
let outcome = match outcome {
Ok(outcome) => outcome,
Err(RegistryError::OnboardingStaleRevision) => {
let replay = self
.registry
.get_product_event_by_idempotency_key(workspace_id, &payload.idempotency_key)
.await?;
if replay
.as_ref()
.is_some_and(|record| record.event.kind == event_kind)
{
return Ok(OnboardingEventResponse {
accepted: false,
onboarding: self.map_onboarding_response(current).await?,
});
}
if replay.is_some() {
return Err(ApiError::conflict_with_context(
"idempotency key was already used for another onboarding event",
json!({"error_code":"onboarding_idempotency_conflict","recovery":"use_original_event"}),
));
}
return Err(ApiError::conflict_with_context(
"onboarding state changed; reload before retrying",
json!({"error_code":"onboarding_stale_revision","recovery":"reload"}),
));
}
Err(RegistryError::InvalidExecutionRecord {
field: "product_event.idempotency_conflict",
}) => {
return Err(ApiError::conflict_with_context(
"idempotency key was already used for another onboarding event",
json!({"error_code":"onboarding_idempotency_conflict","recovery":"use_original_event"}),
));
}
Err(other) => return Err(other.into()),
};
Ok(OnboardingEventResponse {
accepted: outcome.accepted,
onboarding: self.map_onboarding_response(outcome.projection).await?,
})
}
pub async fn reset_onboarding_selection(
&self,
workspace_id: &WorkspaceId,
expected_revision: i64,
) -> Result<ResetOnboardingSelectionResponse, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let projection = self
.registry
.reset_onboarding_selection(workspace_id, expected_revision, OffsetDateTime::now_utc())
.await
.map_err(|error| match error {
RegistryError::OnboardingStaleRevision => ApiError::conflict_with_context(
"onboarding state changed; reload before resetting the selection",
json!({"error_code":"onboarding_stale_revision","recovery":"reload"}),
),
other => ApiError::from(other),
})?;
Ok(ResetOnboardingSelectionResponse {
selection_reset: true,
onboarding: self.map_onboarding_response(projection).await?,
})
}
async fn map_onboarding_response(
&self,
projection: OnboardingProjection,
) -> Result<OnboardingResponse, ApiError> {
let endpoint = if let Some(agent_id) = projection.agent_id.as_ref() {
let workspace = self.get_workspace(&projection.workspace_id).await?;
let agent = self
.registry
.get_agent_summary(&projection.workspace_id, agent_id)
.await?
.ok_or_else(|| ApiError::internal("onboarding agent disappeared"))?;
Some(self.public_agent_mcp_endpoint(&workspace.workspace.slug, &agent.slug))
} else {
None
};
let first_call = match (
projection.first_call_log_id.as_ref(),
projection.agent_id.as_ref(),
projection.platform_api_key_id.as_ref(),
projection.operation_id.as_ref(),
projection.operation_version,
projection.first_call_tool_name.as_ref(),
projection.first_call_at,
) {
(
Some(log_id),
Some(agent_id),
Some(key_id),
Some(operation_id),
Some(operation_version),
Some(tool_name),
Some(occurred_at),
) => Some(OnboardingFirstCallEvidence {
log_id: log_id.as_str().to_owned(),
agent_id: agent_id.as_str().to_owned(),
key_id: key_id.as_str().to_owned(),
operation_id: operation_id.as_str().to_owned(),
operation_version,
tool_name: tool_name.clone(),
occurred_at: format_timestamp(occurred_at),
request_id: projection.first_call_request_id.clone(),
trace_id: projection.first_call_trace_id.clone(),
}),
_ => None,
};
let first_incomplete = projection.steps.iter().position(|step| !step.completed);
Ok(OnboardingResponse {
schema_version: 1,
workspace_id: projection.workspace_id.as_str().to_owned(),
revision: projection.revision,
status: if projection.completed {
"complete".to_owned()
} else {
"in_progress".to_owned()
},
completed: projection.completed,
eligible_since: projection.eligible_since.map(format_timestamp),
steps: projection
.steps
.iter()
.enumerate()
.map(|(index, step)| {
let status = if step.completed {
"complete"
} else if projection.was_completed {
"regressed"
} else if Some(index) == first_incomplete {
"current"
} else {
"pending"
};
let reason_code = match status {
"complete" => "authoritative_evidence_present",
"regressed" => "authoritative_evidence_regressed",
"current" => "authoritative_evidence_missing",
_ => "prerequisite_incomplete",
};
OnboardingStepView {
id: step.id,
completed: step.completed,
status: status.to_owned(),
action_code: action_code(step.id).to_owned(),
reason_code: reason_code.to_owned(),
}
})
.collect(),
operation_id: projection.operation_id.map(|id| id.as_str().to_owned()),
operation_version: projection.operation_version,
agent_id: projection.agent_id.map(|id| id.as_str().to_owned()),
catalog_revision: projection.catalog_revision,
platform_api_key_id: projection
.platform_api_key_id
.map(|id| id.as_str().to_owned()),
mcp_endpoint: endpoint,
first_call,
})
}
}
fn action_code(step: OnboardingStepId) -> &'static str {
match step {
OnboardingStepId::Operation => "create_operation",
OnboardingStepId::Test => "test_operation",
OnboardingStepId::PublishOperation => "publish_operation",
OnboardingStepId::Agent => "publish_agent",
OnboardingStepId::Key => "create_mcp_key",
OnboardingStepId::McpConnection => "connect_mcp_client",
OnboardingStepId::FirstCall => "call_tool",
}
}
fn onboarding_event_not_allowed() -> ApiError {
ApiError::unprocessable_with_context(
"client cannot complete authoritative onboarding steps",
json!({"error_code":"onboarding_event_not_allowed"}),
)
}
+247 -97
View File
@@ -1,14 +1,17 @@
use crank_core::{
ConfigExport, ExecutionMode, ExportMode, InvocationLevel, InvocationSource, InvocationStatus,
OperationId, OperationStatus, Samples, WorkspaceId,
ConfigExport, ExecutionMode, ExecutionOrigin, ExportMode, InvocationLevel, InvocationSource,
InvocationStatus, OperationAvailability, OperationId, OperationStatus, OperationVersionState,
Samples, WorkspaceId,
};
use crank_registry::{
CreateVersionRequest, OperationVersionRecord, PublishRequest, RegistryOperation,
CreateVersionRequest, OperationStateExpectation, OperationVersionRecord, PublishRequest,
RegistryOperation,
};
use crank_runtime::{
RuntimeError, RuntimeExecutionRequest, RuntimeOperation, RuntimeRequestContext,
ExecutionAuthorization, RuntimeExecutionRequest, RuntimeOperation, RuntimeRequestContext,
};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use time::OffsetDateTime;
use tracing::{info, instrument};
@@ -18,13 +21,33 @@ use crate::{
AdminService, CreatedOperationResponse, InvocationRecordRequest, NewVersionPayload,
OperationDetailView, OperationMutationResult, OperationPayload, OperationSummaryView,
PublishResponse, TestRunPayload, TestRunResult, UpdateOperationPayload, VersionRef,
agent_ref_map, build_request_preview, default_usage_summary, enrich_operation_summary,
format_timestamp, new_prefixed_id, now_string, runtime_error_code, today_start_utc,
tool_quality_mapping_set, tool_quality_schema_node, usage_map,
agent_ref_map, default_usage_summary, enrich_operation_summary, format_timestamp,
new_prefixed_id, now_string, today_start_utc, tool_quality_mapping_set,
tool_quality_schema_node, usage_map,
},
};
impl AdminService {
pub fn operation_state_etag(detail: &OperationDetailView) -> String {
let material = format!(
"operation-state-v2\0{}\0{}\0{}\0{:?}\0{:?}",
detail.workspace_id,
detail.id,
detail.current_draft_version,
detail.status,
detail.latest_published_version
);
format!("\"{:x}\"", Sha256::digest(material.as_bytes()))
}
pub fn operation_version_etag(record: &OperationVersionRecord) -> Result<String, ApiError> {
let bytes = serde_json::to_vec(&record.snapshot)
.map_err(|_| ApiError::internal("operation version serialization failed"))?;
let mut digest = Sha256::new();
digest.update(b"operation-version-v2\0");
digest.update(bytes);
Ok(format!("\"{:x}\"", digest.finalize()))
}
pub async fn list_operations(
&self,
workspace_id: &WorkspaceId,
@@ -84,6 +107,11 @@ impl AdminService {
let refs = agent_ref_map(agent_refs)
.remove(operation_id.as_str())
.unwrap_or_default();
let current_version = self
.registry
.get_operation_version(workspace_id, operation_id, summary.current_draft_version)
.await?
.ok_or_else(|| ApiError::internal("current operation version is unavailable"))?;
Ok(OperationDetailView {
id: summary.id.as_str().to_owned(),
@@ -94,6 +122,11 @@ impl AdminService {
protocol: summary.protocol,
security_level: summary.security_level,
status: summary.status,
availability: if summary.status == OperationStatus::Archived {
OperationAvailability::Archived
} else {
OperationAvailability::Active
},
current_draft_version: summary.current_draft_version,
latest_published_version: summary.latest_published_version,
created_at: format_timestamp(summary.created_at),
@@ -101,11 +134,15 @@ impl AdminService {
published_at: summary.published_at.map(format_timestamp),
draft_version_ref: VersionRef {
version: summary.current_draft_version,
status: summary.status,
status: if current_version.status == OperationStatus::Published {
OperationVersionState::Published
} else {
OperationVersionState::Draft
},
},
published_version_ref: summary.latest_published_version.map(|version| VersionRef {
version,
status: OperationStatus::Published,
status: OperationVersionState::Published,
}),
agent_refs: refs,
})
@@ -155,6 +192,8 @@ impl AdminService {
}
let snapshot = self.new_operation_snapshot(payload)?;
self.validate_registry_operation_in_workspace(workspace_id, &snapshot)
.await?;
let operation_id = snapshot.id.clone();
self.registry
@@ -240,6 +279,7 @@ impl AdminService {
workspace_id: &WorkspaceId,
operation_id: &OperationId,
payload: NewVersionPayload,
expected_state: &OperationStateExpectation,
) -> Result<CreatedOperationResponse, ApiError> {
self.validate_operation_payload(&payload.operation)?;
@@ -254,7 +294,27 @@ impl AdminService {
)
})?;
let now = OffsetDateTime::now_utc();
let version = summary.current_draft_version + 1;
if summary.current_draft_version != expected_state.current_draft_version
|| summary.status != expected_state.status
|| summary.latest_published_version != expected_state.latest_published_version
{
return Err(crank_registry::RegistryError::OperationStaleVersion {
operation_id: operation_id.as_str().to_owned(),
expected: expected_state.current_draft_version,
actual: summary.current_draft_version,
}
.into());
}
let version = summary
.current_draft_version
.checked_add(1)
.filter(|value| i32::try_from(*value).is_ok())
.ok_or_else(|| {
ApiError::conflict_with_context(
"operation revision limit reached",
json!({ "error_code": "operation_revision_exhausted" }),
)
})?;
let snapshot = RegistryOperation {
id: operation_id.clone(),
name: payload.operation.name,
@@ -283,13 +343,19 @@ impl AdminService {
published_at: None,
};
self.validate_registry_operation_in_workspace(workspace_id, &snapshot)
.await?;
self.registry
.create_version(CreateVersionRequest {
workspace_id,
snapshot: &snapshot,
change_note: payload.change_note.as_deref(),
created_by: None,
})
.create_version_cas(
CreateVersionRequest {
workspace_id,
snapshot: &snapshot,
change_note: payload.change_note.as_deref(),
created_by: None,
},
expected_state,
)
.await?;
info!(
name: "admin.operation.version_created",
@@ -313,21 +379,20 @@ impl AdminService {
workspace_id: &WorkspaceId,
operation_id: &OperationId,
payload: UpdateOperationPayload,
expected_state: &OperationStateExpectation,
) -> Result<OperationMutationResult, ApiError> {
let existing = self
.get_operation_version(
workspace_id,
operation_id,
self.get_operation(workspace_id, operation_id)
.await?
.current_draft_version,
expected_state.current_draft_version,
)
.await?;
let updated_at = OffsetDateTime::now_utc();
let snapshot = RegistryOperation {
id: operation_id.clone(),
name: existing.snapshot.name,
name: existing.snapshot.name.clone(),
display_name: payload.display_name,
category: payload.category,
protocol: existing.snapshot.protocol,
@@ -341,24 +406,39 @@ impl AdminService {
output_mapping: payload.output_mapping,
execution_config: payload.execution_config,
tool_description: payload.tool_description,
samples: existing.snapshot.samples,
generated_draft: existing.snapshot.generated_draft,
config_export: existing.snapshot.config_export,
samples: existing.snapshot.samples.clone(),
generated_draft: existing.snapshot.generated_draft.clone(),
config_export: existing.snapshot.config_export.clone(),
wizard_state: payload.wizard_state,
created_at: existing.snapshot.created_at,
updated_at,
published_at: existing.snapshot.published_at,
};
self.validate_registry_operation(&snapshot)?;
self.validate_registry_operation_in_workspace(workspace_id, &snapshot)
.await?;
if existing.snapshot.portable_semantically_eq(&snapshot)
&& existing.snapshot.wizard_state == snapshot.wizard_state
{
self.registry
.verify_operation_state(workspace_id, operation_id, expected_state)
.await?;
return Ok(OperationMutationResult {
operation_id: operation_id.as_str().to_owned(),
workspace_id: workspace_id.as_str().to_owned(),
version: existing.version,
status: existing.status,
updated_at: format_timestamp(existing.snapshot.updated_at),
});
}
self.registry
.update_operation_draft(workspace_id, &snapshot)
.update_operation_draft_cas(workspace_id, &snapshot, expected_state)
.await?;
Ok(OperationMutationResult {
operation_id: operation_id.as_str().to_owned(),
workspace_id: workspace_id.as_str().to_owned(),
version: snapshot.version,
version: snapshot.version.saturating_add(1),
status: snapshot.status,
updated_at: format_timestamp(updated_at),
})
@@ -370,16 +450,56 @@ impl AdminService {
workspace_id: &WorkspaceId,
operation_id: &OperationId,
version: u32,
expected_state: &OperationStateExpectation,
) -> Result<PublishResponse, ApiError> {
let record = self
.get_operation_version(workspace_id, operation_id, version)
.await?;
self.validate_registry_operation_in_workspace(workspace_id, &record.snapshot)
.await?;
let mut findings = crank_core::analyze_tool_identity_quality(
&record.snapshot.name,
&record.snapshot.tool_description,
)
.findings;
findings.extend(
crank_core::analyze_tool_schema_quality(
"input_schema",
&tool_quality_schema_node(&record.snapshot.input_schema),
)
.findings,
);
findings.extend(
crank_core::analyze_tool_response_projection_quality(&tool_quality_mapping_set(
&record.snapshot.output_mapping,
))
.findings,
);
let report = crank_core::ToolQualityReport::new(findings);
if report.blocking {
return Err(ApiError::unprocessable_with_context(
"operation publish is blocked by quality findings",
json!({
"error_code": "operation_publish_blocked",
"findings": report.findings
}),
));
}
let published_at = OffsetDateTime::now_utc();
self.registry
.publish_operation(PublishRequest {
workspace_id,
operation_id,
version,
published_at: &published_at,
published_by: None,
})
.publish_operation_cas(
PublishRequest {
workspace_id,
operation_id,
version,
published_at: &published_at,
published_by: None,
},
expected_state,
)
.await?;
let authoritative = self
.get_operation_version(workspace_id, operation_id, version)
.await?;
info!(
name: "admin.operation.published",
@@ -392,7 +512,11 @@ impl AdminService {
operation_id: operation_id.as_str().to_owned(),
workspace_id: workspace_id.as_str().to_owned(),
published_version: version,
published_at: format_timestamp(published_at),
published_at: authoritative
.snapshot
.published_at
.map(format_timestamp)
.unwrap_or_else(|| format_timestamp(published_at)),
})
}
@@ -401,19 +525,21 @@ impl AdminService {
&self,
workspace_id: &WorkspaceId,
operation_id: &OperationId,
expected_state: &OperationStateExpectation,
) -> Result<OperationMutationResult, ApiError> {
let summary = self.get_operation(workspace_id, operation_id).await?;
let updated_at = OffsetDateTime::now_utc();
self.registry
.archive_operation(workspace_id, operation_id, &updated_at)
.archive_operation_cas(workspace_id, operation_id, &updated_at, expected_state)
.await?;
let authoritative = self.get_operation(workspace_id, operation_id).await?;
Ok(OperationMutationResult {
operation_id: operation_id.as_str().to_owned(),
workspace_id: workspace_id.as_str().to_owned(),
version: summary.current_draft_version,
status: OperationStatus::Archived,
updated_at: format_timestamp(updated_at),
updated_at: authoritative.updated_at,
})
}
@@ -422,11 +548,12 @@ impl AdminService {
&self,
workspace_id: &WorkspaceId,
operation_id: &OperationId,
expected_state: &OperationStateExpectation,
) -> Result<OperationMutationResult, ApiError> {
let summary = self.get_operation(workspace_id, operation_id).await?;
let updated_at = now_string()?;
self.registry
.delete_operation(workspace_id, operation_id)
.delete_operation_cas(workspace_id, operation_id, expected_state)
.await?;
Ok(OperationMutationResult {
@@ -448,70 +575,77 @@ impl AdminService {
) -> Result<TestRunResult, ApiError> {
let request_id = correlation.request_id().as_str();
let trace_id = correlation.trace_id().as_str();
let runtime_request_context = RuntimeRequestContext::from_correlation(correlation)
let mut runtime_request_context = RuntimeRequestContext::from_correlation(correlation)
.with_metering_context(workspace_id.clone(), None, InvocationSource::AdminTestRun);
if let Some(token) = payload.confirmation_token.as_deref() {
runtime_request_context = runtime_request_context.with_confirmation_token(token);
}
let locale = payload
.locale
.as_deref()
.filter(|value| {
value.eq_ignore_ascii_case("ru") || value.to_ascii_lowercase().starts_with("ru-")
})
.map_or(crank_core::ExecutionLocale::En, |_| {
crank_core::ExecutionLocale::Ru
});
let record = self
.get_operation_version(workspace_id, operation_id, payload.version)
.await?;
let summary = self.get_operation(workspace_id, operation_id).await?;
if summary.status == OperationStatus::Archived
|| record.status != OperationStatus::Draft
|| summary.current_draft_version != payload.version
{
return Err(ApiError::conflict_with_context(
"only the current saved Draft can be tested",
json!({
"operation_id": operation_id.as_str(),
"current_version": summary.current_draft_version,
"tested_version": payload.version,
"error_code": "operation_invalid_transition"
}),
));
}
let runtime = RuntimeOperation::from(record.snapshot.clone());
let mode = ExecutionMode::Unary;
let preview_span = crank_trace::Stage::RuntimeArgumentsMap.span();
let preview_result = preview_span
.in_scope(|| build_request_preview(&record.snapshot.input_mapping, &payload.input));
let request_preview = match preview_result {
Ok(preview) => preview,
Err(error) => {
crank_trace::StageOutcome::Error.record(&preview_span);
crank_trace::ErrorCategory::Mapping.record(&preview_span);
drop(preview_span);
self.record_invocation(InvocationRecordRequest {
workspace_id,
agent_id: None,
operation: &record.snapshot,
request_id: Some(request_id),
trace_id: Some(trace_id),
source: InvocationSource::AdminTestRun,
level: InvocationLevel::Error,
status: InvocationStatus::Error,
message: "mapping preview failed".to_owned(),
status_code: None,
error_kind: Some("mapping".to_owned()),
duration_ms: 0,
request_preview: Value::Null,
response_preview: Value::Null,
})
.await;
return Ok(TestRunResult {
ok: false,
mode,
request_preview: Value::Null,
response_preview: Value::Null,
errors: vec![crate::error::runtime_test_failure(&RuntimeError::Mapping(
error,
))],
});
}
};
crank_trace::StageOutcome::Success.record(&preview_span);
drop(preview_span);
let resolved_auth = self
.resolve_operation_auth(workspace_id, &runtime.execution_config)
.resolve_operation_auth(workspace_id, &runtime.execution_config, None)
.await;
let started_at = std::time::Instant::now();
match match resolved_auth {
let correlation = crank_core::CorrelationContext::new(
runtime_request_context.request_id.clone(),
runtime_request_context.trace_context.clone(),
);
let execution_result = match resolved_auth {
Ok(resolved_auth) => {
self.runtime
.execute_request(
RuntimeExecutionRequest::new(&runtime, &payload.input)
.with_optional_auth(resolved_auth.as_ref())
.with_context(&runtime_request_context),
)
.await
match RuntimeExecutionRequest::try_new(
workspace_id,
ExecutionOrigin::AdminDraft,
None,
&runtime,
&payload.input,
ExecutionAuthorization::Authorized,
resolved_auth.as_ref(),
&runtime_request_context,
std::time::Instant::now()
+ std::time::Duration::from_millis(
runtime.execution_config.timeout_ms.max(1),
),
) {
Ok(request) => self.runtime.execute_outcome(request).await,
Err(_) => Err(crank_core::ExecutionFailure::new(
crank_core::ExecutionErrorCode::RuntimeInternal,
correlation.clone(),
)),
}
}
Err(error) => Err(error),
} {
Ok(response_preview) => {
Err(error) => Err(crank_runtime::normalize_runtime_error(&error, &correlation)),
};
match execution_result {
Ok(success) => {
let response_preview = crank_core::sanitize_invocation_preview(&success.output);
let request_preview = success.request_preview;
let duration_ms =
u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
self.record_invocation(InvocationRecordRequest {
@@ -526,6 +660,10 @@ impl AdminService {
message: "admin test run completed".to_owned(),
status_code: None,
error_kind: None,
execution_stage: Some(crank_core::ExecutionStage::Runtime),
execution_error_code: None,
retryability: Some(crank_core::Retryability::Never),
outcome_certainty: Some(crank_core::OutcomeCertainty::Certain),
duration_ms,
request_preview: request_preview.clone(),
response_preview: response_preview.clone(),
@@ -534,12 +672,15 @@ impl AdminService {
Ok(TestRunResult {
ok: true,
mode,
tested_version: payload.version,
request_id: request_id.to_owned(),
trace_id: trace_id.to_owned(),
request_preview,
response_preview,
errors: Vec::new(),
})
}
Err(error) => {
Err(failure) => {
let duration_ms =
u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
self.record_invocation(InvocationRecordRequest {
@@ -551,20 +692,29 @@ impl AdminService {
source: InvocationSource::AdminTestRun,
level: InvocationLevel::Error,
status: InvocationStatus::Error,
message: error.to_string(),
status_code: None,
error_kind: Some(runtime_error_code(&error).to_owned()),
message: "runtime execution failed".to_owned(),
status_code: failure.upstream_status(),
error_kind: Some(failure.error_code().as_str().to_owned()),
execution_stage: Some(failure.stage()),
execution_error_code: Some(failure.error_code()),
retryability: Some(failure.retryability()),
outcome_certainty: Some(failure.outcome_certainty()),
duration_ms,
request_preview: request_preview.clone(),
request_preview: Value::Null,
response_preview: Value::Null,
})
.await;
Ok(TestRunResult {
ok: false,
mode,
request_preview,
tested_version: payload.version,
request_id: request_id.to_owned(),
trace_id: trace_id.to_owned(),
request_preview: Value::Null,
response_preview: Value::Null,
errors: vec![crate::error::runtime_test_failure(&error)],
errors: vec![crate::error::execution_test_failure_localized(
&failure, locale,
)],
})
}
}
+542 -34
View File
@@ -12,7 +12,8 @@ use tracing::{info, instrument};
use crate::{
error::ApiError,
service::{
AdminService, AuthProfilePayload, RotateSecretPayload, SecretPayload, new_prefixed_id,
AdminAuditContext, AdminService, AuthProfilePayload, CredentialAuditRecord,
RotateSecretPayload, SecretPayload, new_prefixed_id,
},
};
@@ -57,15 +58,46 @@ impl AdminService {
})
}
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), secret_name = %payload.name))]
#[instrument(skip(self, created_by, payload, audit_context), fields(workspace_id = %workspace_id.as_str(), secret_name = %payload.name))]
pub async fn create_secret(
&self,
workspace_id: &WorkspaceId,
created_by: Option<&UserId>,
payload: SecretPayload,
audit_context: Option<&AdminAuditContext>,
) -> Result<Secret, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
validate_secret_payload(&payload)?;
if let Err(error) = self.ensure_workspace_exists(workspace_id).await {
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.secret.create_failed",
target_kind: crank_core::AuditTargetKind::Secret,
workspace_id,
target_id: "pending",
credential_type: "secret",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
if let Err(error) = validate_secret_payload(&payload) {
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.secret.create_failed",
target_kind: crank_core::AuditTargetKind::Secret,
workspace_id,
target_id: "pending",
credential_type: "secret",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
let now = OffsetDateTime::now_utc();
let secret = Secret {
@@ -82,89 +114,322 @@ impl AdminService {
let ciphertext = self
.secret_crypto
.encrypt(&payload.value)
.map_err(|error| ApiError::internal(error.to_string()))?;
.map_err(|error| ApiError::internal(error.to_string()));
let ciphertext = match ciphertext {
Ok(ciphertext) => ciphertext,
Err(error) => {
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.secret.create_failed",
target_kind: crank_core::AuditTargetKind::Secret,
workspace_id,
target_id: secret.id.as_str(),
credential_type: "secret",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
};
self.registry
if let Err(error) = self
.registry
.create_secret(CreateSecretRequest {
secret: &secret,
ciphertext: &ciphertext,
key_version: self.secret_crypto.key_version(),
master_key_epoch: self.secret_crypto.master_key_epoch(),
created_by,
})
.await?;
.await
{
let error = ApiError::from(error);
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.secret.create_failed",
target_kind: crank_core::AuditTargetKind::Secret,
workspace_id,
target_id: secret.id.as_str(),
credential_type: "secret",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
info!(
name: "admin.secret.created",
secret_id = %secret.id.as_str(),
"secret created"
);
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.secret.created",
target_kind: crank_core::AuditTargetKind::Secret,
workspace_id,
target_id: secret.id.as_str(),
credential_type: "secret",
outcome: "success",
reason: "credential_created",
},
)
.await;
Ok(secret)
}
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), secret_id = %secret_id.as_str()))]
#[instrument(skip(self, created_by, payload, audit_context), fields(workspace_id = %workspace_id.as_str(), secret_id = %secret_id.as_str()))]
pub async fn rotate_secret(
&self,
workspace_id: &WorkspaceId,
secret_id: &SecretId,
created_by: Option<&UserId>,
payload: RotateSecretPayload,
audit_context: Option<&AdminAuditContext>,
) -> Result<Secret, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
if payload.value.is_null() {
return Err(ApiError::validation("secret value must not be null"));
if let Err(error) = self.ensure_workspace_exists(workspace_id).await {
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.secret.rotate_failed",
target_kind: crank_core::AuditTargetKind::Secret,
workspace_id,
target_id: secret_id.as_str(),
credential_type: "secret",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
let existing = match self.get_secret(workspace_id, secret_id).await {
Ok(secret) => secret,
Err(error) => {
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.secret.rotate_failed",
target_kind: crank_core::AuditTargetKind::Secret,
workspace_id,
target_id: secret_id.as_str(),
credential_type: "secret",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
};
if let Err(error) = validate_secret_value(existing.kind, &payload.value) {
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.secret.rotate_failed",
target_kind: crank_core::AuditTargetKind::Secret,
workspace_id,
target_id: secret_id.as_str(),
credential_type: "secret",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
let now = OffsetDateTime::now_utc();
let ciphertext = self
.secret_crypto
.encrypt(&payload.value)
.map_err(|error| ApiError::internal(error.to_string()))?;
self.registry
.map_err(|error| ApiError::internal(error.to_string()));
let ciphertext = match ciphertext {
Ok(ciphertext) => ciphertext,
Err(error) => {
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.secret.rotate_failed",
target_kind: crank_core::AuditTargetKind::Secret,
workspace_id,
target_id: secret_id.as_str(),
credential_type: "secret",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
};
let version = match self
.registry
.rotate_secret(RotateSecretRequest {
workspace_id,
secret_id,
ciphertext: &ciphertext,
key_version: self.secret_crypto.key_version(),
master_key_epoch: self.secret_crypto.master_key_epoch(),
created_at: &now,
updated_at: &now,
created_by,
})
.await?;
.await
{
Ok(version) => version,
Err(error) => {
let error = ApiError::from(error);
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.secret.rotate_failed",
target_kind: crank_core::AuditTargetKind::Secret,
workspace_id,
target_id: secret_id.as_str(),
credential_type: "secret",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
};
info!(
name: "admin.secret.rotated",
secret_id = %secret_id.as_str(),
"secret rotated"
);
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.secret.rotated",
target_kind: crank_core::AuditTargetKind::Secret,
workspace_id,
target_id: secret_id.as_str(),
credential_type: "secret",
outcome: "success",
reason: "credential_rotated",
},
)
.await;
self.get_secret(workspace_id, secret_id).await
Ok(Secret {
current_version: version.secret_version.version,
updated_at: now,
..existing
})
}
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), secret_id = %secret_id.as_str()))]
#[instrument(skip(self, audit_context), fields(workspace_id = %workspace_id.as_str(), secret_id = %secret_id.as_str()))]
pub async fn delete_secret(
&self,
workspace_id: &WorkspaceId,
secret_id: &SecretId,
audit_context: Option<&AdminAuditContext>,
) -> Result<(), ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
if let Some(profile) = self
if let Err(error) = self.ensure_workspace_exists(workspace_id).await {
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.secret.delete_failed",
target_kind: crank_core::AuditTargetKind::Secret,
workspace_id,
target_id: secret_id.as_str(),
credential_type: "secret",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
let referencing_profiles = match self
.registry
.list_auth_profiles_referencing_secret(workspace_id, secret_id)
.await?
.into_iter()
.next()
.await
{
Ok(profiles) => profiles,
Err(error) => {
let error = ApiError::from(error);
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.secret.delete_failed",
target_kind: crank_core::AuditTargetKind::Secret,
workspace_id,
target_id: secret_id.as_str(),
credential_type: "secret",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
};
if let Some(profile) = referencing_profiles.into_iter().next() {
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.secret.delete_denied",
target_kind: crank_core::AuditTargetKind::Secret,
workspace_id,
target_id: secret_id.as_str(),
credential_type: "secret",
outcome: "denied",
reason: "secret_referenced_by_auth_profile",
},
)
.await;
return Err(RegistryError::SecretReferencedByAuthProfile {
secret_id: secret_id.as_str().to_owned(),
auth_profile_id: profile.id.as_str().to_owned(),
}
.into());
}
self.registry.delete_secret(workspace_id, secret_id).await?;
if let Err(error) = self.registry.delete_secret(workspace_id, secret_id).await {
let error = ApiError::from(error);
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.secret.delete_failed",
target_kind: crank_core::AuditTargetKind::Secret,
workspace_id,
target_id: secret_id.as_str(),
credential_type: "secret",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
info!(
name: "admin.secret.deleted",
secret_id = %secret_id.as_str(),
"secret deleted"
);
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.secret.deleted",
target_kind: crank_core::AuditTargetKind::Secret,
workspace_id,
target_id: secret_id.as_str(),
credential_type: "secret",
outcome: "success",
reason: "credential_deleted",
},
)
.await;
Ok(())
}
@@ -185,39 +450,118 @@ impl AdminService {
})
}
#[instrument(skip(self, payload), fields(auth_profile_name = %payload.name, auth_kind = ?payload.kind))]
#[instrument(skip(self, payload, audit_context), fields(auth_profile_name = %payload.name, auth_kind = ?payload.kind))]
pub async fn create_auth_profile(
&self,
workspace_id: &WorkspaceId,
payload: AuthProfilePayload,
audit_context: Option<&AdminAuditContext>,
) -> Result<AuthProfile, ApiError> {
validate_auth_profile_kind(payload.kind, &payload.config)?;
self.ensure_workspace_exists(workspace_id).await?;
self.validate_auth_profile_secret_ids(workspace_id, &payload.config)
.await?;
if let Err(error) = validate_auth_profile_payload(&payload) {
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.auth_profile.create_failed",
target_kind: crank_core::AuditTargetKind::AuthProfile,
workspace_id,
target_id: "pending",
credential_type: "auth_profile",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
if let Err(error) = self.ensure_workspace_exists(workspace_id).await {
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.auth_profile.create_failed",
target_kind: crank_core::AuditTargetKind::AuthProfile,
workspace_id,
target_id: "pending",
credential_type: "auth_profile",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
if let Err(error) = self
.validate_auth_profile_secret_ids(workspace_id, &payload.config)
.await
{
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.auth_profile.create_failed",
target_kind: crank_core::AuditTargetKind::AuthProfile,
workspace_id,
target_id: "pending",
credential_type: "auth_profile",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
let now = OffsetDateTime::now_utc();
let profile = AuthProfile {
id: AuthProfileId::new(new_prefixed_id("auth")),
workspace_id: workspace_id.clone(),
name: payload.name,
name: payload.name.trim().to_owned(),
kind: payload.kind,
config: payload.config,
created_at: now,
updated_at: now,
};
self.registry
if let Err(error) = self
.registry
.save_auth_profile(SaveAuthProfileRequest {
workspace_id,
profile: &profile,
})
.await?;
.await
{
let error = ApiError::from(error);
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.auth_profile.create_failed",
target_kind: crank_core::AuditTargetKind::AuthProfile,
workspace_id,
target_id: profile.id.as_str(),
credential_type: "auth_profile",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
info!(
name: "admin.auth_profile.created",
auth_profile_id = %profile.id.as_str(),
"auth profile created"
);
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.auth_profile.created",
target_kind: crank_core::AuditTargetKind::AuthProfile,
workspace_id,
target_id: profile.id.as_str(),
credential_type: "auth_profile",
outcome: "success",
reason: "credential_created",
},
)
.await;
Ok(profile)
}
@@ -228,13 +572,45 @@ impl AdminService {
config: &AuthConfig,
) -> Result<(), ApiError> {
for secret_id in config.secret_ids() {
self.get_secret(workspace_id, secret_id).await?;
let secret = self.get_secret(workspace_id, secret_id).await?;
if secret.status != SecretStatus::Active {
return Err(RegistryError::SecretInactive {
secret_id: secret_id.as_str().to_owned(),
}
.into());
}
}
Ok(())
}
}
fn validate_auth_profile_payload(payload: &AuthProfilePayload) -> Result<(), ApiError> {
const MAX_PROFILE_NAME_CHARS: usize = 128;
let profile_name = payload.name.trim();
if profile_name.is_empty() || profile_name.chars().count() > MAX_PROFILE_NAME_CHARS {
return Err(ApiError::validation(
"auth profile name must contain 1 to 128 characters",
));
}
if profile_name.chars().any(char::is_control) {
return Err(ApiError::validation(
"auth profile name contains unsupported characters",
));
}
validate_auth_profile_kind(payload.kind, &payload.config)?;
match &payload.config {
AuthConfig::Bearer(config) => validate_auth_header_name(&config.header_name)?,
AuthConfig::Basic(_) => {}
AuthConfig::ApiKeyHeader(config) => validate_auth_header_name(&config.header_name)?,
AuthConfig::ApiKeyQuery(config) => validate_auth_query_name(&config.param_name)?,
}
Ok(())
}
fn validate_auth_profile_kind(kind: AuthKind, config: &AuthConfig) -> Result<(), ApiError> {
let is_match = matches!(
(kind, config),
@@ -252,13 +628,145 @@ fn validate_auth_profile_kind(kind: AuthKind, config: &AuthConfig) -> Result<(),
}
fn validate_secret_payload(payload: &SecretPayload) -> Result<(), ApiError> {
if payload.name.trim().is_empty() {
return Err(ApiError::validation("secret name must not be empty"));
const MAX_SECRET_NAME_CHARS: usize = 128;
let name = payload.name.trim();
if name.is_empty() || name.chars().count() > MAX_SECRET_NAME_CHARS {
return Err(ApiError::validation(
"secret name must contain 1 to 128 characters",
));
}
if name.chars().any(char::is_control) {
return Err(ApiError::validation(
"secret name contains unsupported characters",
));
}
if payload.value.is_null() {
validate_secret_value(payload.kind, &payload.value)
}
fn validate_secret_value(
kind: crank_core::SecretKind,
value: &serde_json::Value,
) -> Result<(), ApiError> {
const MAX_SECRET_VALUE_BYTES: usize = 65_536;
const MAX_SECRET_STRING_BYTES: usize = 16_384;
if value.is_null() {
return Err(ApiError::validation("secret value must not be null"));
}
let encoded = serde_json::to_vec(value)
.map_err(|_| ApiError::validation("secret value must be valid JSON"))?;
if encoded.len() > MAX_SECRET_VALUE_BYTES {
return Err(ApiError::validation("secret value is too large"));
}
match kind {
crank_core::SecretKind::Token | crank_core::SecretKind::Header => {
if value.is_string() {
validate_non_empty_secret_string(value, "secret value", MAX_SECRET_STRING_BYTES)?;
} else {
let object = value.as_object().ok_or_else(|| {
ApiError::validation(
"token/header secret must be a string or a single token/value field",
)
})?;
let field = object
.get("token")
.or_else(|| object.get("value"))
.filter(|_| object.len() == 1)
.ok_or_else(|| {
ApiError::validation("token/header secret must contain only token or value")
})?;
validate_non_empty_secret_string(field, "secret value", MAX_SECRET_STRING_BYTES)?;
}
}
crank_core::SecretKind::UsernamePassword => {
let object = value.as_object().ok_or_else(|| {
ApiError::validation("username_password secret must contain username and password")
})?;
if object.len() != 2
|| !object.contains_key("username")
|| !object.contains_key("password")
{
return Err(ApiError::validation(
"username_password secret must contain only username and password",
));
}
validate_non_empty_secret_string(
&object["username"],
"secret username",
MAX_SECRET_STRING_BYTES,
)?;
validate_non_empty_secret_string(
&object["password"],
"secret password",
MAX_SECRET_STRING_BYTES,
)?;
}
crank_core::SecretKind::Generic => {}
}
Ok(())
}
fn validate_non_empty_secret_string(
value: &serde_json::Value,
field: &str,
max_bytes: usize,
) -> Result<(), ApiError> {
let value = value
.as_str()
.ok_or_else(|| ApiError::validation(format!("{field} must be a string")))?;
if value.is_empty() {
return Err(ApiError::validation(format!("{field} must not be empty")));
}
if value.len() > max_bytes {
return Err(ApiError::validation(format!("{field} is too large")));
}
Ok(())
}
fn validate_auth_header_name(value: &str) -> Result<(), ApiError> {
const MAX_HEADER_NAME_BYTES: usize = 128;
if value.is_empty()
|| value.len() > MAX_HEADER_NAME_BYTES
|| !value.bytes().all(|byte| {
byte.is_ascii_alphanumeric()
|| matches!(
byte,
b'!' | b'#'
| b'$'
| b'%'
| b'&'
| b'\''
| b'*'
| b'+'
| b'-'
| b'.'
| b'^'
| b'_'
| b'`'
| b'|'
| b'~'
)
})
{
return Err(ApiError::validation("auth header name is invalid"));
}
Ok(())
}
fn validate_auth_query_name(value: &str) -> Result<(), ApiError> {
const MAX_QUERY_NAME_BYTES: usize = 128;
if value.is_empty()
|| value.len() > MAX_QUERY_NAME_BYTES
|| !value.bytes().all(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b'[' | b']')
})
{
return Err(ApiError::validation("auth query parameter name is invalid"));
}
Ok(())
}
+1
View File
@@ -97,6 +97,7 @@ impl AdminService {
user,
memberships,
current_workspace_id: Some(workspace_id.as_str().to_owned()),
csrf_token: self.rotate_session_csrf_token(session_id).await?,
})
}
+5 -5
View File
@@ -1,14 +1,14 @@
use crate::service::AdminService;
use crank_runtime::RequestRateLimiter;
use std::net::IpAddr;
#[derive(Clone)]
pub struct AppState {
pub service: AdminService,
pub api_rate_limiter: RequestRateLimiter,
/// Whether to trust `X-Real-IP` / `X-Forwarded-For` for client identification.
/// Immediate peer IPs allowed to supply `X-Real-IP` / `X-Forwarded-For`.
///
/// Only enable when the service sits behind a trusted reverse proxy that
/// overwrites these headers (e.g. the bundled nginx). When disabled the
/// real TCP peer address is used, which a client cannot spoof.
pub trust_forwarded_headers: bool,
/// Only configure reverse proxies that overwrite these headers. When the
/// peer is absent or not listed the real TCP peer address is used.
pub trusted_proxy_ips: Vec<IpAddr>,
}