From da94d308def8f99a51f14261311eeb065305c164 Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Tue, 7 Apr 2026 01:00:24 +0300 Subject: [PATCH] feat: resolve upstream auth at runtime --- Cargo.lock | 4 +- TASKS.md | 12 +- apps/admin-api/Cargo.toml | 1 - apps/admin-api/src/app.rs | 2 +- apps/admin-api/src/error.rs | 6 + apps/admin-api/src/main.rs | 3 +- apps/admin-api/src/service.rs | 105 +++++++- apps/mcp-server/src/app.rs | 158 ++++++++++-- apps/mcp-server/src/main.rs | 50 ++-- crates/crank-registry/src/postgres.rs | 65 +++++ crates/crank-runtime/Cargo.toml | 3 + crates/crank-runtime/src/auth.rs | 240 ++++++++++++++++++ crates/crank-runtime/src/error.rs | 10 + crates/crank-runtime/src/executor.rs | 47 +++- crates/crank-runtime/src/lib.rs | 4 + .../crank-runtime}/src/secret_crypto.rs | 57 +++-- docs/secrets-auth-plan.md | 13 +- 17 files changed, 702 insertions(+), 78 deletions(-) create mode 100644 crates/crank-runtime/src/auth.rs rename {apps/admin-api => crates/crank-runtime}/src/secret_crypto.rs (54%) diff --git a/Cargo.lock b/Cargo.lock index 9b9a369..ce820c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6,7 +6,6 @@ version = 4 name = "admin-api" version = "0.1.0" dependencies = [ - "aes-gcm", "argon2", "axum", "axum-extra", @@ -473,7 +472,9 @@ dependencies = [ name = "crank-runtime" version = "0.1.0" dependencies = [ + "aes-gcm", "axum", + "base64", "crank-adapter-graphql", "crank-adapter-grpc", "crank-adapter-rest", @@ -485,6 +486,7 @@ dependencies = [ "futures-util", "serde", "serde_json", + "sha2", "thiserror", "tokio", "tokio-tungstenite", diff --git a/TASKS.md b/TASKS.md index c802cd5..905abda 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,19 +2,19 @@ ## Current -### `feat/auth-profile-secret-resolution` +### `feat/runtime-upstream-auth` Status: completed DoD: -- `AuthConfig` references real `SecretId` values instead of string placeholders -- auth profile create flow validates all referenced secrets inside the workspace -- secret delete is rejected while an auth profile still references the secret -- tests cover missing-secret validation and reference-protected secret deletion +- runtime resolves `auth_profile_ref` into decrypted secret-backed auth before upstream execution +- `admin-api` test-runs and `mcp-server` tool calls use the same auth-aware runtime path +- secret usage updates `last_used_at` when auth-backed execution succeeds at resolution time +- tests and clippy pass for `crank-runtime`, `crank-registry`, `admin-api`, and `mcp-server` ## Next -- `feat/runtime-upstream-auth` +- `feat/secrets-ui` ## Backlog diff --git a/apps/admin-api/Cargo.toml b/apps/admin-api/Cargo.toml index 1ae9a23..77d5621 100644 --- a/apps/admin-api/Cargo.toml +++ b/apps/admin-api/Cargo.toml @@ -6,7 +6,6 @@ rust-version.workspace = true version.workspace = true [dependencies] -aes-gcm.workspace = true argon2.workspace = true axum.workspace = true axum-extra.workspace = true diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index c5f0d64..f3b2711 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -227,6 +227,7 @@ mod tests { }; use crank_mapping::{MappingRule, MappingSet}; use crank_registry::PostgresRegistry; + use crank_runtime::SecretCrypto; use crank_schema::{Schema, SchemaKind}; use serde_json::{Value, json}; use serial_test::serial; @@ -236,7 +237,6 @@ mod tests { use crate::{ app::build_app, auth::{AuthSettings, BootstrapAdminConfig, hash_password}, - secret_crypto::SecretCrypto, service::{AdminService, OperationPayload}, state::AppState, }; diff --git a/apps/admin-api/src/error.rs b/apps/admin-api/src/error.rs index 2ffe509..4647661 100644 --- a/apps/admin-api/src/error.rs +++ b/apps/admin-api/src/error.rs @@ -251,5 +251,11 @@ fn runtime_test_failure_code(error: &RuntimeError) -> &'static str { RuntimeError::MissingStreamingConfig { .. } => "runtime_streaming_config_error", RuntimeError::UnsupportedExecutionMode { .. } => "runtime_streaming_mode_error", RuntimeError::InvalidStreamingPayload { .. } => "runtime_streaming_payload_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", } } diff --git a/apps/admin-api/src/main.rs b/apps/admin-api/src/main.rs index 1f81762..5a48f89 100644 --- a/apps/admin-api/src/main.rs +++ b/apps/admin-api/src/main.rs @@ -2,7 +2,6 @@ mod app; mod auth; mod error; mod routes; -mod secret_crypto; mod service; mod state; mod storage; @@ -16,10 +15,10 @@ use tracing::info; use crate::{ app::build_app, auth::{AuthSettings, BootstrapAdminConfig}, - secret_crypto::SecretCrypto, service::AdminService, state::AppState, }; +use crank_runtime::SecretCrypto; #[tokio::main] async fn main() -> Result<(), Box> { diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index 0a2d8cc..1b1e429 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -31,7 +31,9 @@ use crank_registry::{ UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery, UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord, }; -use crank_runtime::{PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeOperation}; +use crank_runtime::{ + PreparedRequest, ResolvedAuth, RuntimeError, RuntimeExecutor, RuntimeOperation, SecretCrypto, +}; use crank_schema::Schema; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -46,7 +48,6 @@ use crate::{ hash_session_secret, verify_password, }, error::ApiError, - secret_crypto::SecretCrypto, storage::LocalArtifactStorage, }; @@ -1918,8 +1919,18 @@ impl AdminService { } }; + let resolved_auth = self + .resolve_operation_auth(workspace_id, &runtime.execution_config) + .await; let started_at = std::time::Instant::now(); - match self.runtime.execute(&runtime, &payload.input).await { + match match resolved_auth { + Ok(resolved_auth) => { + self.runtime + .execute_with_auth(&runtime, &payload.input, resolved_auth.as_ref()) + .await + } + Err(error) => Err(error), + } { Ok(output) => { let duration_ms = u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX); @@ -1973,6 +1984,78 @@ impl AdminService { } } + async fn resolve_operation_auth( + &self, + workspace_id: &WorkspaceId, + execution_config: &crank_core::ExecutionConfig, + ) -> Result, RuntimeError> { + let Some(auth_profile_id) = execution_config.auth_profile_ref.as_ref() else { + return Ok(None); + }; + + let auth_profile = self + .registry + .get_auth_profile(workspace_id, auth_profile_id) + .await + .map_err(|error| RuntimeError::SecretCrypto { + details: error.to_string(), + })? + .ok_or_else(|| RuntimeError::MissingAuthProfile { + auth_profile_id: auth_profile_id.as_str().to_owned(), + })?; + + self.resolve_auth_profile(workspace_id, &auth_profile) + .await + .map(Some) + } + + async fn resolve_auth_profile( + &self, + workspace_id: &WorkspaceId, + auth_profile: &AuthProfile, + ) -> Result { + let mut secrets = BTreeMap::new(); + let used_at = now_string().map_err(|error| RuntimeError::SecretCrypto { + details: error.to_string(), + })?; + + for secret_id in auth_profile.config.secret_ids() { + let secret = self + .registry + .get_secret(workspace_id, secret_id) + .await + .map_err(|error| RuntimeError::SecretCrypto { + details: error.to_string(), + })? + .ok_or_else(|| RuntimeError::MissingSecret { + secret_id: secret_id.as_str().to_owned(), + })?; + let version = self + .registry + .get_current_secret_version(workspace_id, secret_id) + .await + .map_err(|error| RuntimeError::SecretCrypto { + 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(&version.secret_version.ciphertext)?; + self.registry + .touch_secret(workspace_id, secret_id, &used_at) + .await + .map_err(|error| RuntimeError::SecretCrypto { + details: error.to_string(), + })?; + secrets.insert(secret_id.clone(), plaintext); + } + + ResolvedAuth::from_profile(auth_profile, &secrets) + } + #[instrument(skip(self))] pub async fn list_auth_profiles( &self, @@ -2032,7 +2115,10 @@ impl AdminService { updated_at: now, last_used_at: None, }; - let ciphertext = self.secret_crypto.encrypt(&payload.value)?; + let ciphertext = self + .secret_crypto + .encrypt(&payload.value) + .map_err(|error| ApiError::internal(error.to_string()))?; self.registry .create_secret(CreateSecretRequest { @@ -2061,7 +2147,10 @@ impl AdminService { } let now = now_string()?; - let ciphertext = self.secret_crypto.encrypt(&payload.value)?; + let ciphertext = self + .secret_crypto + .encrypt(&payload.value) + .map_err(|error| ApiError::internal(error.to_string()))?; self.registry .rotate_secret(RotateSecretRequest { workspace_id, @@ -4200,6 +4289,12 @@ fn runtime_error_code(error: &RuntimeError) -> &'static str { RuntimeError::MissingStreamingConfig { .. } => "streaming_config_error", RuntimeError::UnsupportedExecutionMode { .. } => "streaming_mode_error", RuntimeError::InvalidStreamingPayload { .. } => "streaming_payload_error", + RuntimeError::MissingAuthProfile { .. } => "auth_profile_not_found", + RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => { + "secret_not_found" + } + RuntimeError::InvalidAuthSecretValue { .. } => "secret_value_error", + RuntimeError::SecretCrypto { .. } => "secret_crypto_error", } } diff --git a/apps/mcp-server/src/app.rs b/apps/mcp-server/src/app.rs index 51346a0..e2a8e57 100644 --- a/apps/mcp-server/src/app.rs +++ b/apps/mcp-server/src/app.rs @@ -1,4 +1,5 @@ use std::{ + collections::BTreeMap, convert::Infallible, sync::Arc, time::{Duration, Instant}, @@ -19,15 +20,16 @@ use axum::{ }; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use crank_core::{ - AsyncJobHandle, AsyncJobId, InvocationLevel, InvocationLog, InvocationLogId, InvocationSource, - InvocationStatus, JobStatus, PlatformApiKeyScope, StreamSession, StreamSessionId, StreamStatus, + AsyncJobHandle, AsyncJobId, AuthProfile, InvocationLevel, InvocationLog, InvocationLogId, + InvocationSource, InvocationStatus, JobStatus, PlatformApiKeyScope, SecretId, StreamSession, + StreamSessionId, StreamStatus, }; use crank_registry::{ CreateAsyncJobRequest, CreateInvocationLogRequest, CreateStreamSessionRequest, PostgresRegistry, PublishedAgentTool, UpdateAsyncJobStatusRequest, UpdateStreamSessionStateRequest, }; -use crank_runtime::{RuntimeError, RuntimeExecutor, RuntimeOperation}; +use crank_runtime::{ResolvedAuth, RuntimeError, RuntimeExecutor, RuntimeOperation, SecretCrypto}; use futures_util::stream; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -58,6 +60,7 @@ pub struct AppState { registry: PostgresRegistry, catalog: PublishedToolCatalog, runtime: RuntimeExecutor, + secret_crypto: SecretCrypto, sessions: SessionStore, allowed_origins: AllowedOrigins, } @@ -118,11 +121,13 @@ pub fn build_app( registry: PostgresRegistry, refresh_interval: Duration, public_base_url: Option, + secret_crypto: SecretCrypto, ) -> Router { let state = Arc::new(AppState { registry: registry.clone(), catalog: PublishedToolCatalog::new(registry, refresh_interval), runtime: RuntimeExecutor::new(), + secret_crypto, sessions: SessionStore::new(), allowed_origins: AllowedOrigins::new(public_base_url), }); @@ -509,6 +514,87 @@ struct StoredSessionState { batch_size: usize, } +async fn resolve_operation_auth( + state: &Arc, + workspace_id: &crank_core::WorkspaceId, + execution_config: &crank_core::ExecutionConfig, +) -> Result, RuntimeError> { + resolve_runtime_auth_for_task( + &state.registry, + &state.secret_crypto, + workspace_id, + execution_config, + ) + .await +} + +async fn resolve_runtime_auth_for_task( + registry: &PostgresRegistry, + secret_crypto: &SecretCrypto, + workspace_id: &crank_core::WorkspaceId, + execution_config: &crank_core::ExecutionConfig, +) -> Result, RuntimeError> { + let Some(auth_profile_id) = execution_config.auth_profile_ref.as_ref() else { + return Ok(None); + }; + + let auth_profile = registry + .get_auth_profile(workspace_id, auth_profile_id) + .await + .map_err(|error| RuntimeError::SecretCrypto { + details: error.to_string(), + })? + .ok_or_else(|| RuntimeError::MissingAuthProfile { + auth_profile_id: auth_profile_id.as_str().to_owned(), + })?; + + resolve_auth_profile(registry, secret_crypto, workspace_id, &auth_profile) + .await + .map(Some) +} + +async fn resolve_auth_profile( + registry: &PostgresRegistry, + secret_crypto: &SecretCrypto, + workspace_id: &crank_core::WorkspaceId, + auth_profile: &AuthProfile, +) -> Result { + let mut secrets = BTreeMap::new(); + let used_at = now_rfc3339(); + + for secret_id in auth_profile.config.secret_ids() { + let secret = registry + .get_secret(workspace_id, secret_id) + .await + .map_err(|error| RuntimeError::SecretCrypto { + details: error.to_string(), + })? + .ok_or_else(|| RuntimeError::MissingSecret { + secret_id: secret_id.as_str().to_owned(), + })?; + let version = registry + .get_current_secret_version(workspace_id, secret_id) + .await + .map_err(|error| RuntimeError::SecretCrypto { + details: error.to_string(), + })? + .ok_or_else(|| RuntimeError::MissingSecretVersion { + secret_id: secret_id.as_str().to_owned(), + version: secret.secret.current_version, + })?; + let plaintext = secret_crypto.decrypt(&version.secret_version.ciphertext)?; + registry + .touch_secret(workspace_id, secret_id, &used_at) + .await + .map_err(|error| RuntimeError::SecretCrypto { + details: error.to_string(), + })?; + secrets.insert(SecretId::new(secret_id.as_str()), plaintext); + } + + ResolvedAuth::from_profile(auth_profile, &secrets) +} + async fn handle_base_tool_call( state: Arc, session: &SessionState, @@ -528,11 +614,13 @@ async fn handle_base_tool_call( .map(|streaming| streaming.mode), Some(crank_core::ExecutionMode::Window) ); + let resolved_auth = + resolve_operation_auth(&state, &tool.workspace_id, &operation.execution_config).await; - let result = if is_window_mode { - state + let result = match resolved_auth { + Ok(resolved_auth) if is_window_mode => state .runtime - .execute_window(&operation, &arguments) + .execute_window_with_auth(&operation, &arguments, resolved_auth.as_ref()) .await .map(|output| { json!({ @@ -543,9 +631,14 @@ async fn handle_base_tool_call( "truncated": output.truncated, "has_more": output.has_more, }) - }) - } else { - state.runtime.execute(&operation, &arguments).await + }), + Ok(resolved_auth) => { + state + .runtime + .execute_with_auth(&operation, &arguments, resolved_auth.as_ref()) + .await + } + Err(error) => Err(error), }; match result { @@ -619,11 +712,25 @@ async fn handle_session_start_call( ); }; - match state - .runtime - .execute_session_seed(&runtime_operation, &arguments) - .await - { + let resolved_auth = resolve_operation_auth( + &state, + &tool.workspace_id, + &runtime_operation.execution_config, + ) + .await; + match match resolved_auth { + Ok(resolved_auth) => { + state + .runtime + .execute_session_seed_with_auth( + &runtime_operation, + &arguments, + resolved_auth.as_ref(), + ) + .await + } + Err(error) => Err(error), + } { Ok(seed) => { let batch_size = streaming.max_items.unwrap_or(10).max(1) as usize; let preview_count = seed.items.len().min(batch_size); @@ -983,13 +1090,28 @@ async fn handle_async_job_start_call( } let registry = state.registry.clone(); + let secret_crypto = state.secret_crypto.clone(); let tool_for_task = tool.clone(); let arguments_for_task = arguments.clone(); let job_id = job.id.clone(); tokio::spawn(async move { let runtime = RuntimeExecutor::new(); let task_operation = runtime_operation(&tool_for_task); - let result = runtime.execute(&task_operation, &arguments_for_task).await; + let resolved_auth = resolve_runtime_auth_for_task( + ®istry, + &secret_crypto, + &tool_for_task.workspace_id, + &task_operation.execution_config, + ) + .await; + let result = match resolved_auth { + Ok(resolved_auth) => { + runtime + .execute_with_auth(&task_operation, &arguments_for_task, resolved_auth.as_ref()) + .await + } + Err(error) => Err(error), + }; let finished_at = now_rfc3339(); let update_result = match result { @@ -1732,6 +1854,12 @@ fn runtime_error_code(error: &RuntimeError) -> &'static str { RuntimeError::UnsupportedExecutionMode { .. } => "streaming_mode_error", RuntimeError::InvalidPreparedRequest { .. } => "runtime_error", RuntimeError::InvalidStreamingPayload { .. } => "streaming_payload_error", + RuntimeError::MissingAuthProfile { .. } => "auth_profile_not_found", + RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => { + "secret_not_found" + } + RuntimeError::InvalidAuthSecretValue { .. } => "secret_value_error", + RuntimeError::SecretCrypto { .. } => "secret_crypto_error", } } diff --git a/apps/mcp-server/src/main.rs b/apps/mcp-server/src/main.rs index 39b6c6b..75b8048 100644 --- a/apps/mcp-server/src/main.rs +++ b/apps/mcp-server/src/main.rs @@ -6,6 +6,7 @@ mod session; use std::{env, net::SocketAddr, time::Duration}; use crank_registry::PostgresRegistry; +use crank_runtime::SecretCrypto; use tokio::net::TcpListener; use tracing::info; @@ -30,7 +31,8 @@ async fn main() -> Result<(), Box> { .unwrap_or_else(|| Duration::from_secs(5)); let socket_addr: SocketAddr = bind_addr.parse()?; let registry = PostgresRegistry::connect(&database_url).await?; - let app = build_app(registry, refresh_interval, public_base_url); + let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?; + let app = build_app(registry, refresh_interval, public_base_url, secret_crypto); let listener = TcpListener::bind(socket_addr).await?; info!("mcp-server listening on {}", socket_addr); @@ -68,6 +70,7 @@ mod tests { CreateAgentRequest, CreatePlatformApiKeyRequest, ListInvocationLogsQuery, PostgresRegistry, PublishAgentRequest, PublishRequest, }; + use crank_runtime::SecretCrypto; use crank_schema::{Schema, SchemaKind}; use futures_util::stream; use serde_json::{Value, json}; @@ -86,6 +89,19 @@ mod tests { "default" } + fn build_test_app( + registry: PostgresRegistry, + refresh_interval: Duration, + public_base_url: Option, + ) -> axum::Router { + build_app( + registry, + refresh_interval, + public_base_url, + SecretCrypto::new("test-master-key").unwrap(), + ) + } + #[tokio::test] async fn initializes_lists_and_calls_published_tool_via_mcp() { let registry = test_registry().await; @@ -114,7 +130,7 @@ mod tests { ) .await; - let base_url = spawn_mcp_server(build_app( + let base_url = spawn_mcp_server(build_test_app( registry.clone(), Duration::from_millis(0), Some("https://crank.example.com".to_owned()), @@ -220,7 +236,7 @@ mod tests { ) .await; - let base_url = spawn_mcp_server(build_app( + let base_url = spawn_mcp_server(build_test_app( registry, Duration::from_millis(0), Some("https://crank.example.com".to_owned()), @@ -284,7 +300,7 @@ mod tests { ) .await; - let base_url = spawn_mcp_server(build_app( + let base_url = spawn_mcp_server(build_test_app( registry, Duration::from_millis(0), Some("https://crank.example.com".to_owned()), @@ -326,7 +342,7 @@ mod tests { publish_agent_with_bindings(®istry, "sales-init", vec![]).await; let api_key = create_platform_api_key(®istry, "mcp-init", &[PlatformApiKeyScope::Read]).await; - let base_url = spawn_mcp_server(build_app( + let base_url = spawn_mcp_server(build_test_app( registry, Duration::from_millis(0), Some("https://crank.example.com".to_owned()), @@ -388,7 +404,7 @@ mod tests { &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], ) .await; - let base_url = spawn_mcp_server(build_app( + let base_url = spawn_mcp_server(build_test_app( registry, Duration::from_millis(0), Some("https://crank.example.com".to_owned()), @@ -438,7 +454,7 @@ mod tests { &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], ) .await; - let base_url = spawn_mcp_server(build_app( + let base_url = spawn_mcp_server(build_test_app( registry, Duration::from_millis(0), Some("https://crank.example.com".to_owned()), @@ -489,7 +505,7 @@ mod tests { &[PlatformApiKeyScope::Read], ) .await; - let base_url = spawn_mcp_server(build_app( + let base_url = spawn_mcp_server(build_test_app( registry, Duration::from_millis(0), Some("https://crank.example.com".to_owned()), @@ -518,7 +534,7 @@ mod tests { &[PlatformApiKeyScope::Read], ) .await; - let base_url = spawn_mcp_server(build_app( + let base_url = spawn_mcp_server(build_test_app( registry, Duration::from_millis(0), Some("https://crank.example.com".to_owned()), @@ -562,7 +578,7 @@ mod tests { &[PlatformApiKeyScope::Read], ) .await; - let base_url = spawn_mcp_server(build_app( + let base_url = spawn_mcp_server(build_test_app( registry, Duration::from_millis(0), Some("https://crank.example.com".to_owned()), @@ -589,7 +605,7 @@ mod tests { async fn refreshes_published_tools_without_restart() { let registry = test_registry().await; let upstream_base_url = spawn_upstream_server().await; - let base_url = spawn_mcp_server(build_app( + let base_url = spawn_mcp_server(build_test_app( registry.clone(), Duration::from_millis(0), Some("https://crank.example.com".to_owned()), @@ -700,7 +716,7 @@ mod tests { ) .await; - let base_url = spawn_mcp_server(build_app( + let base_url = spawn_mcp_server(build_test_app( registry, Duration::from_millis(0), Some("https://crank.example.com".to_owned()), @@ -747,7 +763,7 @@ mod tests { async fn rejects_initialize_without_platform_api_key() { let registry = test_registry().await; publish_agent_with_bindings(®istry, "sales-auth", vec![]).await; - let base_url = spawn_mcp_server(build_app( + let base_url = spawn_mcp_server(build_test_app( registry, Duration::from_millis(0), Some("https://crank.example.com".to_owned()), @@ -796,7 +812,7 @@ mod tests { let api_key = create_platform_api_key(®istry, "mcp-read", &[PlatformApiKeyScope::Read]).await; - let base_url = spawn_mcp_server(build_app( + let base_url = spawn_mcp_server(build_test_app( registry, Duration::from_millis(0), Some("https://crank.example.com".to_owned()), @@ -857,7 +873,7 @@ mod tests { ) .await; - let base_url = spawn_mcp_server(build_app( + let base_url = spawn_mcp_server(build_test_app( registry.clone(), Duration::from_millis(0), Some("https://crank.example.com".to_owned()), @@ -1001,7 +1017,7 @@ mod tests { ) .await; - let base_url = spawn_mcp_server(build_app( + let base_url = spawn_mcp_server(build_test_app( registry.clone(), Duration::from_millis(0), Some("https://crank.example.com".to_owned()), @@ -1148,7 +1164,7 @@ mod tests { ) .await; - let base_url = spawn_mcp_server(build_app( + let base_url = spawn_mcp_server(build_test_app( registry, Duration::from_millis(0), Some("https://crank.example.com".to_owned()), diff --git a/crates/crank-registry/src/postgres.rs b/crates/crank-registry/src/postgres.rs index cade8f0..8cbe1ca 100644 --- a/crates/crank-registry/src/postgres.rs +++ b/crates/crank-registry/src/postgres.rs @@ -710,6 +710,32 @@ impl PostgresRegistry { row.as_ref().map(map_secret_record).transpose() } + pub async fn get_current_secret_version( + &self, + workspace_id: &WorkspaceId, + secret_id: &SecretId, + ) -> Result, RegistryError> { + let row = sqlx::query( + "select + sv.secret_id, + sv.version, + sv.ciphertext, + sv.key_version, + to_char(sv.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at, + sv.created_by + from secrets s + join secret_versions sv + on sv.secret_id = s.id and sv.version = s.current_version + where s.workspace_id = $1 and s.id = $2", + ) + .bind(workspace_id.as_str()) + .bind(secret_id.as_str()) + .fetch_optional(&self.pool) + .await?; + + row.as_ref().map(map_secret_version_record).transpose() + } + pub async fn create_secret( &self, request: CreateSecretRequest<'_>, @@ -863,6 +889,32 @@ impl PostgresRegistry { Ok(()) } + pub async fn touch_secret( + &self, + workspace_id: &WorkspaceId, + secret_id: &SecretId, + used_at: &str, + ) -> Result<(), RegistryError> { + let result = sqlx::query( + "update secrets + set last_used_at = $3::timestamptz + where workspace_id = $1 and id = $2", + ) + .bind(workspace_id.as_str()) + .bind(secret_id.as_str()) + .bind(used_at) + .execute(&self.pool) + .await?; + + if result.rows_affected() == 0 { + return Err(RegistryError::SecretNotFound { + secret_id: secret_id.as_str().to_owned(), + }); + } + + Ok(()) + } + pub async fn create_stream_session( &self, request: CreateStreamSessionRequest<'_>, @@ -3812,6 +3864,19 @@ fn map_secret_record(row: &PgRow) -> Result { }) } +fn map_secret_version_record(row: &PgRow) -> Result { + Ok(SecretVersionRecord { + secret_version: SecretVersion { + secret_id: SecretId::new(row.try_get::("secret_id")?), + version: from_db_version(row.try_get::("version")?, "version")?, + ciphertext: row.try_get("ciphertext")?, + key_version: row.try_get("key_version")?, + created_at: row.try_get("created_at")?, + created_by: row.try_get::, _>("created_by")?.map(UserId::new), + }, + }) +} + fn map_stream_session(row: &PgRow) -> Result { Ok(StreamSession { id: StreamSessionId::new(row.try_get::("id")?), diff --git a/crates/crank-runtime/Cargo.toml b/crates/crank-runtime/Cargo.toml index d89fc8b..2a7374b 100644 --- a/crates/crank-runtime/Cargo.toml +++ b/crates/crank-runtime/Cargo.toml @@ -6,6 +6,8 @@ rust-version.workspace = true version.workspace = true [dependencies] +aes-gcm.workspace = true +base64.workspace = true crank-adapter-graphql = { path = "../crank-adapter-graphql" } crank-adapter-grpc = { path = "../crank-adapter-grpc" } crank-adapter-rest = { path = "../crank-adapter-rest" } @@ -16,6 +18,7 @@ crank-mapping = { path = "../crank-mapping" } crank-schema = { path = "../crank-schema" } serde.workspace = true serde_json.workspace = true +sha2.workspace = true thiserror.workspace = true [dev-dependencies] diff --git a/crates/crank-runtime/src/auth.rs b/crates/crank-runtime/src/auth.rs new file mode 100644 index 0000000..5dffbfa --- /dev/null +++ b/crates/crank-runtime/src/auth.rs @@ -0,0 +1,240 @@ +use std::collections::BTreeMap; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use crank_core::{AuthConfig, AuthProfile, SecretId}; +use serde_json::Value; + +use crate::{PreparedRequest, RuntimeError}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ResolvedAuth { + Bearer { header_name: String, token: String }, + Basic { username: String, password: String }, + ApiKeyHeader { header_name: String, value: String }, + ApiKeyQuery { param_name: String, value: String }, +} + +impl ResolvedAuth { + pub fn from_profile( + profile: &AuthProfile, + secrets: &BTreeMap, + ) -> Result { + match &profile.config { + AuthConfig::Bearer(config) => Ok(Self::Bearer { + header_name: config.header_name.clone(), + token: extract_secret_string(secrets, &config.secret_id, &["token", "value"])?, + }), + AuthConfig::Basic(config) => Ok(Self::Basic { + username: extract_secret_string( + secrets, + &config.username_secret_id, + &["username", "value"], + )?, + password: extract_secret_string( + secrets, + &config.password_secret_id, + &["password", "value"], + )?, + }), + AuthConfig::ApiKeyHeader(config) => Ok(Self::ApiKeyHeader { + header_name: config.header_name.clone(), + value: extract_secret_string(secrets, &config.secret_id, &["token", "value"])?, + }), + AuthConfig::ApiKeyQuery(config) => Ok(Self::ApiKeyQuery { + param_name: config.param_name.clone(), + value: extract_secret_string(secrets, &config.secret_id, &["token", "value"])?, + }), + } + } + + pub fn apply(&self, prepared_request: &mut PreparedRequest) { + match self { + Self::Bearer { header_name, token } => { + prepared_request + .headers + .insert(header_name.clone(), format!("Bearer {token}")); + } + Self::Basic { username, password } => { + let credentials = STANDARD.encode(format!("{username}:{password}")); + prepared_request + .headers + .insert("Authorization".to_owned(), format!("Basic {credentials}")); + } + Self::ApiKeyHeader { header_name, value } => { + prepared_request + .headers + .insert(header_name.clone(), value.clone()); + } + Self::ApiKeyQuery { param_name, value } => { + prepared_request + .query_params + .insert(param_name.clone(), value.clone()); + } + } + } +} + +fn extract_secret_string( + secrets: &BTreeMap, + secret_id: &SecretId, + preferred_fields: &[&str], +) -> Result { + let Some(value) = secrets.get(secret_id) else { + return Err(RuntimeError::MissingSecret { + secret_id: secret_id.as_str().to_owned(), + }); + }; + + if let Some(text) = value.as_str() { + return Ok(text.to_owned()); + } + + let Some(object) = value.as_object() else { + return Err(RuntimeError::InvalidAuthSecretValue { + secret_id: secret_id.as_str().to_owned(), + details: "secret payload must be a string or object".to_owned(), + }); + }; + + for field in preferred_fields { + if let Some(text) = object.get(*field).and_then(Value::as_str) { + return Ok(text.to_owned()); + } + } + + Err(RuntimeError::InvalidAuthSecretValue { + secret_id: secret_id.as_str().to_owned(), + details: format!( + "secret payload must contain one of: {}", + preferred_fields.join(", ") + ), + }) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use crank_core::{ + ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthKind, AuthProfile, + BasicAuthConfig, BearerAuthConfig, SecretId, WorkspaceId, + }; + use serde_json::json; + + use crate::{PreparedRequest, auth::ResolvedAuth}; + + #[test] + fn applies_bearer_auth_to_headers() { + let auth = ResolvedAuth::from_profile( + &AuthProfile { + id: "auth_01".into(), + workspace_id: WorkspaceId::new("ws_default"), + name: "bearer".to_owned(), + kind: AuthKind::Bearer, + config: AuthConfig::Bearer(BearerAuthConfig { + header_name: "Authorization".to_owned(), + secret_id: SecretId::new("secret_token"), + }), + created_at: "2026-04-07T00:00:00Z".to_owned(), + updated_at: "2026-04-07T00:00:00Z".to_owned(), + }, + &BTreeMap::from([(SecretId::new("secret_token"), json!({"token":"abc"}))]), + ) + .unwrap(); + let mut request = PreparedRequest::default(); + + auth.apply(&mut request); + + assert_eq!( + request.headers.get("Authorization").map(String::as_str), + Some("Bearer abc") + ); + } + + #[test] + fn applies_basic_auth_to_headers() { + let auth = ResolvedAuth::from_profile( + &AuthProfile { + id: "auth_01".into(), + workspace_id: WorkspaceId::new("ws_default"), + name: "basic".to_owned(), + kind: AuthKind::Basic, + config: AuthConfig::Basic(BasicAuthConfig { + username_secret_id: SecretId::new("secret_user"), + password_secret_id: SecretId::new("secret_pass"), + }), + created_at: "2026-04-07T00:00:00Z".to_owned(), + updated_at: "2026-04-07T00:00:00Z".to_owned(), + }, + &BTreeMap::from([ + (SecretId::new("secret_user"), json!({"username":"demo"})), + (SecretId::new("secret_pass"), json!({"password":"s3cr3t"})), + ]), + ) + .unwrap(); + let mut request = PreparedRequest::default(); + + auth.apply(&mut request); + + assert_eq!( + request.headers.get("Authorization").map(String::as_str), + Some("Basic ZGVtbzpzM2NyM3Q=") + ); + } + + #[test] + fn applies_api_key_header_auth() { + let auth = ResolvedAuth::from_profile( + &AuthProfile { + id: "auth_01".into(), + workspace_id: WorkspaceId::new("ws_default"), + name: "api-header".to_owned(), + kind: AuthKind::ApiKeyHeader, + config: AuthConfig::ApiKeyHeader(ApiKeyHeaderAuthConfig { + header_name: "X-Api-Key".to_owned(), + secret_id: SecretId::new("secret_api_key"), + }), + created_at: "2026-04-07T00:00:00Z".to_owned(), + updated_at: "2026-04-07T00:00:00Z".to_owned(), + }, + &BTreeMap::from([(SecretId::new("secret_api_key"), json!("key-123"))]), + ) + .unwrap(); + let mut request = PreparedRequest::default(); + + auth.apply(&mut request); + + assert_eq!( + request.headers.get("X-Api-Key").map(String::as_str), + Some("key-123") + ); + } + + #[test] + fn applies_api_key_query_auth() { + let auth = ResolvedAuth::from_profile( + &AuthProfile { + id: "auth_01".into(), + workspace_id: WorkspaceId::new("ws_default"), + name: "api-query".to_owned(), + kind: AuthKind::ApiKeyQuery, + config: AuthConfig::ApiKeyQuery(ApiKeyQueryAuthConfig { + param_name: "api_key".to_owned(), + secret_id: SecretId::new("secret_api_key"), + }), + created_at: "2026-04-07T00:00:00Z".to_owned(), + updated_at: "2026-04-07T00:00:00Z".to_owned(), + }, + &BTreeMap::from([(SecretId::new("secret_api_key"), json!({"value":"key-123"}))]), + ) + .unwrap(); + let mut request = PreparedRequest::default(); + + auth.apply(&mut request); + + assert_eq!( + request.query_params.get("api_key").map(String::as_str), + Some("key-123") + ); + } +} diff --git a/crates/crank-runtime/src/error.rs b/crates/crank-runtime/src/error.rs index 6c0d022..7ff625a 100644 --- a/crates/crank-runtime/src/error.rs +++ b/crates/crank-runtime/src/error.rs @@ -37,4 +37,14 @@ pub enum RuntimeError { InvalidPreparedRequest { details: String }, #[error("invalid streaming payload: {details}")] InvalidStreamingPayload { details: String }, + #[error("auth profile {auth_profile_id} was not found")] + MissingAuthProfile { auth_profile_id: String }, + #[error("secret {secret_id} was not found")] + MissingSecret { secret_id: String }, + #[error("secret {secret_id} does not have current version {version}")] + MissingSecretVersion { secret_id: String, version: u32 }, + #[error("invalid secret payload for {secret_id}: {details}")] + InvalidAuthSecretValue { secret_id: String, details: String }, + #[error("secret crypto error: {details}")] + SecretCrypto { details: String }, } diff --git a/crates/crank-runtime/src/executor.rs b/crates/crank-runtime/src/executor.rs index 5e355ad..d18ef8e 100644 --- a/crates/crank-runtime/src/executor.rs +++ b/crates/crank-runtime/src/executor.rs @@ -9,7 +9,8 @@ use crank_core::{ExecutionMode, Target, TransportBehavior}; use serde_json::{Map, Value, json}; use crate::{ - AdapterResponse, PreparedRequest, RuntimeError, RuntimeOperation, WindowExecutionResult, + AdapterResponse, PreparedRequest, ResolvedAuth, RuntimeError, RuntimeOperation, + WindowExecutionResult, }; #[derive(Clone, Debug)] @@ -42,8 +43,18 @@ impl RuntimeExecutor { &self, operation: &RuntimeOperation, input: &Value, + ) -> Result { + self.execute_with_auth(operation, input, None).await + } + + pub async fn execute_with_auth( + &self, + operation: &RuntimeOperation, + input: &Value, + resolved_auth: Option<&ResolvedAuth>, ) -> Result { let prepared_request = self.prepare_request(operation, input)?; + let prepared_request = apply_resolved_auth(prepared_request, resolved_auth); self.execute_prepared(operation, prepared_request).await } @@ -51,6 +62,15 @@ impl RuntimeExecutor { &self, operation: &RuntimeOperation, input: &Value, + ) -> Result { + self.execute_window_with_auth(operation, input, None).await + } + + pub async fn execute_window_with_auth( + &self, + operation: &RuntimeOperation, + input: &Value, + resolved_auth: Option<&ResolvedAuth>, ) -> Result { let Some(streaming) = operation.execution_config.streaming.as_ref() else { return Err(RuntimeError::MissingStreamingConfig { @@ -66,6 +86,7 @@ impl RuntimeExecutor { } let prepared_request = self.prepare_request(operation, input)?; + let prepared_request = apply_resolved_auth(prepared_request, resolved_auth); let adapter_response = if matches!( streaming.transport_behavior, TransportBehavior::ServerStream @@ -86,6 +107,16 @@ impl RuntimeExecutor { &self, operation: &RuntimeOperation, input: &Value, + ) -> Result { + self.execute_session_seed_with_auth(operation, input, None) + .await + } + + pub async fn execute_session_seed_with_auth( + &self, + operation: &RuntimeOperation, + input: &Value, + resolved_auth: Option<&ResolvedAuth>, ) -> Result { let Some(streaming) = operation.execution_config.streaming.as_ref() else { return Err(RuntimeError::MissingStreamingConfig { @@ -113,7 +144,8 @@ impl RuntimeExecutor { config.max_items = Some(seed_limit); } - self.execute_window(&seeded_operation, input).await + self.execute_window_with_auth(&seeded_operation, input, resolved_auth) + .await } pub fn prepare_request( @@ -396,6 +428,17 @@ fn finalize_output( .unwrap_or_else(|| Value::Object(Map::new()))) } +fn apply_resolved_auth( + mut prepared_request: PreparedRequest, + resolved_auth: Option<&ResolvedAuth>, +) -> PreparedRequest { + if let Some(resolved_auth) = resolved_auth { + resolved_auth.apply(&mut prepared_request); + } + + prepared_request +} + fn merge_headers( static_headers: &BTreeMap, execution_headers: &BTreeMap, diff --git a/crates/crank-runtime/src/lib.rs b/crates/crank-runtime/src/lib.rs index 40dd27c..46f6e94 100644 --- a/crates/crank-runtime/src/lib.rs +++ b/crates/crank-runtime/src/lib.rs @@ -1,11 +1,15 @@ mod aggregation; +mod auth; mod error; mod executor; mod model; mod redaction; +mod secret_crypto; mod streaming; +pub use auth::ResolvedAuth; pub use error::RuntimeError; pub use executor::RuntimeExecutor; pub use model::{AdapterResponse, PreparedRequest, RuntimeOperation}; +pub use secret_crypto::SecretCrypto; pub use streaming::WindowExecutionResult; diff --git a/apps/admin-api/src/secret_crypto.rs b/crates/crank-runtime/src/secret_crypto.rs similarity index 54% rename from apps/admin-api/src/secret_crypto.rs rename to crates/crank-runtime/src/secret_crypto.rs index 0316d68..9bb3a40 100644 --- a/apps/admin-api/src/secret_crypto.rs +++ b/crates/crank-runtime/src/secret_crypto.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use sha2::{Digest, Sha256}; -use crate::error::ApiError; +use crate::RuntimeError; #[derive(Clone)] pub struct SecretCrypto { @@ -22,15 +22,19 @@ struct CipherEnvelope { } impl SecretCrypto { - pub fn new(master_key: &str) -> Result { + pub fn new(master_key: &str) -> Result { let trimmed = master_key.trim(); if trimmed.is_empty() { - return Err(ApiError::internal("CRANK_MASTER_KEY must not be empty")); + return Err(RuntimeError::SecretCrypto { + details: "CRANK_MASTER_KEY must not be empty".to_owned(), + }); } let digest = Sha256::digest(trimmed.as_bytes()); let cipher = Aes256Gcm::new_from_slice(digest.as_slice()).map_err(|error| { - ApiError::internal(format!("failed to initialize secret crypto: {error}")) + RuntimeError::SecretCrypto { + details: format!("failed to initialize secret crypto: {error}"), + } })?; Ok(Self { @@ -43,9 +47,9 @@ impl SecretCrypto { &self.key_version } - pub fn encrypt(&self, value: &Value) -> Result { - let plaintext = serde_json::to_vec(value).map_err(|error| { - ApiError::internal(format!("failed to serialize secret value: {error}")) + pub fn encrypt(&self, value: &Value) -> Result { + let plaintext = serde_json::to_vec(value).map_err(|error| RuntimeError::SecretCrypto { + details: format!("failed to serialize secret value: {error}"), })?; let mut nonce_bytes = [0_u8; 12]; OsRng.fill_bytes(&mut nonce_bytes); @@ -53,39 +57,44 @@ impl SecretCrypto { let ciphertext = self .cipher .encrypt(nonce, plaintext.as_ref()) - .map_err(|error| { - ApiError::internal(format!("failed to encrypt secret value: {error}")) + .map_err(|error| RuntimeError::SecretCrypto { + details: format!("failed to encrypt secret value: {error}"), })?; let envelope = CipherEnvelope { nonce_b64: STANDARD.encode(nonce_bytes), ciphertext_b64: STANDARD.encode(ciphertext), }; - serde_json::to_string(&envelope).map_err(|error| { - ApiError::internal(format!("failed to encode secret ciphertext: {error}")) + serde_json::to_string(&envelope).map_err(|error| RuntimeError::SecretCrypto { + details: format!("failed to encode secret ciphertext: {error}"), }) } - #[cfg(test)] - pub fn decrypt(&self, ciphertext: &str) -> Result { - let envelope: CipherEnvelope = serde_json::from_str(ciphertext).map_err(|error| { - ApiError::internal(format!("failed to decode secret envelope: {error}")) - })?; - let nonce_bytes = STANDARD.decode(envelope.nonce_b64).map_err(|error| { - ApiError::internal(format!("failed to decode secret nonce: {error}")) - })?; + pub fn decrypt(&self, ciphertext: &str) -> Result { + let envelope: CipherEnvelope = + serde_json::from_str(ciphertext).map_err(|error| RuntimeError::SecretCrypto { + details: format!("failed to decode secret envelope: {error}"), + })?; + let nonce_bytes = + STANDARD + .decode(envelope.nonce_b64) + .map_err(|error| RuntimeError::SecretCrypto { + details: format!("failed to decode secret nonce: {error}"), + })?; let ciphertext_bytes = STANDARD.decode(envelope.ciphertext_b64).map_err(|error| { - ApiError::internal(format!("failed to decode secret payload: {error}")) + RuntimeError::SecretCrypto { + details: format!("failed to decode secret payload: {error}"), + } })?; let plaintext = self .cipher .decrypt(Nonce::from_slice(&nonce_bytes), ciphertext_bytes.as_ref()) - .map_err(|error| { - ApiError::internal(format!("failed to decrypt secret value: {error}")) + .map_err(|error| RuntimeError::SecretCrypto { + details: format!("failed to decrypt secret value: {error}"), })?; - serde_json::from_slice(&plaintext).map_err(|error| { - ApiError::internal(format!("failed to deserialize secret value: {error}")) + serde_json::from_slice(&plaintext).map_err(|error| RuntimeError::SecretCrypto { + details: format!("failed to deserialize secret value: {error}"), }) } } diff --git a/docs/secrets-auth-plan.md b/docs/secrets-auth-plan.md index 8e5ede4..75e7ce8 100644 --- a/docs/secrets-auth-plan.md +++ b/docs/secrets-auth-plan.md @@ -18,12 +18,18 @@ Что еще не работает end-to-end: -- runtime не резолвит `auth_profile_ref` при выполнении operation; - `secret_ref` не подтягивает фактическое значение секрета; - wizard хранит auth как raw JSON headers; - `${secrets.*}` в UI является только текстовым placeholder; - отдельной страницы `Secrets` нет. +Что уже работает end-to-end: + +- runtime резолвит `auth_profile_ref` при выполнении operation; +- `admin-api` test-runs используют тот же auth-aware execution path, что и `mcp-server`; +- `REST`, `GraphQL`, `gRPC`, `SOAP` и streaming execution paths получают bearer/basic/api-key auth до adapter layer; +- `last_used_at` у secret обновляется при успешном auth resolution. + ## 3. Целевая модель ### 3.1. `Secret` @@ -238,8 +244,7 @@ DoD: 1. `feat/secret-store-foundation` 2. `feat/auth-profile-secret-resolution` -3. `feat/runtime-upstream-auth` -4. `feat/secrets-ui` -5. `feat/wizard-auth-selector` +3. `feat/secrets-ui` +4. `feat/wizard-auth-selector` Только после этого upstream auth можно считать реально реализованным.