From 2d7c79de1e45026b266d5aba6fad718c0ffcaa2f Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 3 May 2026 17:09:01 -0400 Subject: [PATCH] feat(server): validate Daytona API key scopes Probe the Daytona API at install, `fabro secret set DAYTONA_API_KEY`, and `fabro doctor` time to confirm the configured key carries the snapshot/sandbox scopes Fabro needs. Operators now see a precise scope error against the control plane instead of a generic sandbox-create failure at first run. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 1 + lib/crates/fabro-sandbox/Cargo.toml | 1 + lib/crates/fabro-sandbox/src/daytona/mod.rs | 283 +++++++++++++++++- lib/crates/fabro-sandbox/src/sandbox_spec.rs | 2 +- lib/crates/fabro-server/src/diagnostics.rs | 52 +++- lib/crates/fabro-server/src/install.rs | 42 ++- lib/crates/fabro-server/src/server.rs | 19 +- .../src/server/handler/secrets.rs | 18 ++ lib/crates/fabro-server/src/server/tests.rs | 171 ++++++++++- .../fabro-server/tests/it/api/install.rs | 88 ++++++ lib/crates/fabro-static/src/env_vars.rs | 2 + 11 files changed, 650 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 13730794c..ff698ec11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2117,6 +2117,7 @@ dependencies = [ "futures", "git2", "glob", + "httpmock", "rand 0.9.4", "serde", "serde_json", diff --git a/lib/crates/fabro-sandbox/Cargo.toml b/lib/crates/fabro-sandbox/Cargo.toml index 8ad0add49..1f0498bf5 100644 --- a/lib/crates/fabro-sandbox/Cargo.toml +++ b/lib/crates/fabro-sandbox/Cargo.toml @@ -62,3 +62,4 @@ tempfile = "3" uuid.workspace = true serde_json.workspace = true toml.workspace = true +httpmock = "0.8" diff --git a/lib/crates/fabro-sandbox/src/daytona/mod.rs b/lib/crates/fabro-sandbox/src/daytona/mod.rs index 2bdd480f1..1bdb6f13a 100644 --- a/lib/crates/fabro-sandbox/src/daytona/mod.rs +++ b/lib/crates/fabro-sandbox/src/daytona/mod.rs @@ -5,11 +5,16 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; +use anyhow::Context as _; use async_trait::async_trait; +use daytona_api_client::apis::api_keys_api; +use daytona_api_client::apis::configuration::Configuration; +use daytona_api_client::models::api_key_list::Permissions; use daytona_sdk::api_types::SignedPortPreviewUrl; use daytona_sdk::toolbox_types::Command as SessionCommandResult; use daytona_sdk::{DaytonaError, SessionCommandLogsResult}; use fabro_github::GitHubCredentials; +use fabro_static::EnvVars; use fabro_types::{CommandOutputStream, CommandTermination, RunId}; use fabro_util::time::elapsed_ms; use rand::Rng; @@ -29,33 +34,158 @@ use crate::{ const WORKING_DIRECTORY: &str = "/home/daytona/workspace"; const DEFAULT_SNAPSHOT: &str = "daytona-medium"; +pub const DEFAULT_DAYTONA_API_URL: &str = "https://app.daytona.io/api"; +const FABRO_SANDBOX_USER_AGENT: &str = concat!("fabro-sandbox/", env!("CARGO_PKG_VERSION")); +const DAYTONA_PROBE_TIMEOUT: Duration = Duration::from_secs(20); + +/// Permissions a Daytona API key needs for Fabro's snapshot and sandbox flow. +pub const REQUIRED_DAYTONA_PERMISSIONS: &[Permissions] = &[ + Permissions::WriteColonSnapshots, + Permissions::DeleteColonSnapshots, + Permissions::WriteColonSandboxes, + Permissions::DeleteColonSandboxes, +]; pub use crate::config::{ DaytonaNetwork, DaytonaSettings as DaytonaConfig, DaytonaSnapshotSettings as DaytonaSnapshotConfig, DockerfileSource, }; +#[derive(Debug)] +pub struct DaytonaKeyCheck { + pub key_name: String, + pub missing: Vec, +} + +impl DaytonaKeyCheck { + pub fn ok(&self) -> bool { + self.missing.is_empty() + } + + pub fn missing_display(&self) -> String { + join_perms(&self.missing) + } + + pub fn missing_message(&self) -> String { + format!( + "API key '{}' is missing required Daytona scopes: {}. \ + Regenerate the key with all snapshot and sandbox scopes.", + self.key_name, + self.missing_display() + ) + } +} + +pub fn required_perms_display() -> String { + join_perms(REQUIRED_DAYTONA_PERMISSIONS) +} + +fn join_perms(perms: &[Permissions]) -> String { + perms + .iter() + .copied() + .map(perm_wire_str) + .collect::>() + .join(", ") +} + +fn perm_wire_str(permission: Permissions) -> &'static str { + match permission { + Permissions::WriteColonSnapshots => "write:snapshots", + Permissions::DeleteColonSnapshots => "delete:snapshots", + Permissions::WriteColonSandboxes => "write:sandboxes", + Permissions::DeleteColonSandboxes => "delete:sandboxes", + _ => "unknown", + } +} + /// Build a [`daytona_sdk::Client`], forwarding an optional API key from the /// vault so the SDK doesn't have to rely on `DAYTONA_API_KEY` being in the /// process environment. async fn build_daytona_client( api_key: Option, +) -> Result { + build_daytona_client_with(api_key, None, None).await +} + +async fn build_daytona_client_with( + api_key: Option, + api_url: Option, + organization_id: Option, ) -> Result { let sdk_config = daytona_sdk::DaytonaConfig { api_key, + api_url, + organization_id, ..Default::default() }; daytona_sdk::Client::new_with_config(sdk_config).await } -/// Validate a Daytona API key by performing a single authenticated call. Used -/// at install time to confirm the operator-provided credential is accepted by -/// the Daytona control plane before persisting it. The call requests a single -/// sandbox listing entry to keep latency and quota impact minimal. -pub async fn validate_daytona_api_key(api_key: String) -> Result<(), daytona_sdk::DaytonaError> { - let client = build_daytona_client(Some(api_key)).await?; - client.list(None, Some(1), Some(1)).await?; - Ok(()) +#[expect( + clippy::disallowed_methods, + reason = "This is the production env-resolving Daytona credential probe facade." +)] +pub async fn check_daytona_api_key(api_key: String) -> anyhow::Result { + let base_url = std::env::var(EnvVars::DAYTONA_API_URL) + .or_else(|_| std::env::var(EnvVars::DAYTONA_SERVER_URL)) + .unwrap_or_else(|_| DEFAULT_DAYTONA_API_URL.to_string()); + let org_id = std::env::var(EnvVars::DAYTONA_ORGANIZATION_ID).ok(); + check_daytona_api_key_with(&base_url, org_id.as_deref(), api_key).await +} + +pub async fn check_daytona_api_key_with( + base_url: &str, + org_id: Option<&str>, + api_key: String, +) -> anyhow::Result { + let work = async { + let client = build_daytona_client_with( + Some(api_key.clone()), + Some(base_url.to_string()), + org_id.map(str::to_string), + ) + .await + .map_err(anyhow::Error::new) + .context("failed to construct Daytona client")?; + client + .list(None, Some(1), Some(1)) + .await + .map_err(anyhow::Error::new) + .context("failed to authenticate with Daytona")?; + + let api_config = build_api_keys_configuration(base_url, &api_key); + let info = api_keys_api::get_current_api_key(&api_config, org_id) + .await + .map_err(anyhow::Error::new) + .context("failed to read current Daytona API key")?; + let missing = REQUIRED_DAYTONA_PERMISSIONS + .iter() + .copied() + .filter(|permission| !info.permissions.contains(permission)) + .collect(); + + Ok::<_, anyhow::Error>(DaytonaKeyCheck { + key_name: info.name, + missing, + }) + }; + + match time::timeout(DAYTONA_PROBE_TIMEOUT, work).await { + Ok(result) => result, + Err(_) => Err(anyhow::anyhow!( + "Daytona credential probe timed out after {}s", + DAYTONA_PROBE_TIMEOUT.as_secs() + )), + } +} + +fn build_api_keys_configuration(base_url: &str, api_key: &str) -> Configuration { + let mut cfg = Configuration::new(); + cfg.base_path = base_url.to_string(); + cfg.bearer_access_token = Some(api_key.to_string()); + cfg.user_agent = Some(FABRO_SANDBOX_USER_AGENT.to_string()); + cfg } fn command_kind(command: &str) -> &'static str { @@ -1738,8 +1868,59 @@ fn wrap_bash_command(command: &str) -> String { #[cfg(test)] mod tests { + use daytona_api_client::models::api_key_list::Permissions; + use httpmock::Method::GET; + use httpmock::MockServer; + use super::*; + fn api_key_body(permissions: &[&str]) -> serde_json::Value { + serde_json::json!({ + "name": "delete-only", + "value": "dtn_****", + "createdAt": "2026-05-01T00:00:00Z", + "permissions": permissions, + "lastUsedAt": null, + "expiresAt": null, + "userId": "user_123" + }) + } + + async fn mock_auth_probe(server: &MockServer, status: usize) -> httpmock::Mock<'_> { + server + .mock_async(move |when, then| { + when.method(GET) + .path("/sandbox/paginated") + .query_param("page", "1") + .query_param("limit", "1"); + then.status(status) + .header("content-type", "application/json") + .json_body(serde_json::json!({ + "items": [], + "total": 0, + "page": 1, + "totalPages": 0 + })); + }) + .await + } + + async fn mock_current_key<'a>( + server: &'a MockServer, + permissions: Vec<&'static str>, + ) -> httpmock::Mock<'a> { + server + .mock_async(move |when, then| { + when.method(GET) + .path("/api-keys/current") + .header("authorization", "Bearer dtn_test"); + then.status(200) + .header("content-type", "application/json") + .json_body(api_key_body(&permissions)); + }) + .await + } + #[test] fn daytona_config_defaults() { let config = DaytonaConfig::default(); @@ -1766,6 +1947,92 @@ mod tests { ); } + #[test] + fn missing_display_uses_daytona_wire_scope_names() { + let check = DaytonaKeyCheck { + key_name: "delete-only".to_string(), + missing: vec![ + Permissions::WriteColonSnapshots, + Permissions::WriteColonSandboxes, + ], + }; + + assert_eq!(check.missing_display(), "write:snapshots, write:sandboxes"); + assert_eq!( + check.missing_message(), + "API key 'delete-only' is missing required Daytona scopes: \ + write:snapshots, write:sandboxes. Regenerate the key with all \ + snapshot and sandbox scopes." + ); + assert_eq!( + required_perms_display(), + "write:snapshots, delete:snapshots, write:sandboxes, delete:sandboxes" + ); + } + + #[tokio::test] + async fn check_daytona_api_key_with_reports_missing_scopes() { + let server = MockServer::start_async().await; + let auth = mock_auth_probe(&server, 200).await; + let current_key = mock_current_key(&server, vec![ + "delete:snapshots", + "delete:sandboxes", + "delete:volumes", + ]) + .await; + + let check = check_daytona_api_key_with(&server.base_url(), None, "dtn_test".to_string()) + .await + .expect("probe should succeed"); + + assert!(!check.ok()); + assert_eq!(check.key_name, "delete-only"); + assert_eq!(check.missing_display(), "write:snapshots, write:sandboxes"); + auth.assert_async().await; + current_key.assert_async().await; + } + + #[tokio::test] + async fn check_daytona_api_key_with_accepts_full_scopes() { + let server = MockServer::start_async().await; + let auth = mock_auth_probe(&server, 200).await; + let current_key = mock_current_key(&server, vec![ + "write:snapshots", + "delete:snapshots", + "write:sandboxes", + "delete:sandboxes", + ]) + .await; + + let check = check_daytona_api_key_with(&server.base_url(), None, "dtn_test".to_string()) + .await + .expect("probe should succeed"); + + assert!(check.ok()); + assert!(check.missing.is_empty()); + auth.assert_async().await; + current_key.assert_async().await; + } + + #[tokio::test] + async fn check_daytona_api_key_with_preserves_auth_failure_context() { + let server = MockServer::start_async().await; + let auth = mock_auth_probe(&server, 401).await; + + let err = check_daytona_api_key_with(&server.base_url(), None, "dtn_test".to_string()) + .await + .expect_err("auth probe should fail"); + let chain = err.chain().map(ToString::to_string).collect::>(); + + assert!( + chain + .iter() + .any(|cause| cause == "failed to authenticate with Daytona"), + "expected auth context in chain, got {chain:#?}" + ); + auth.assert_async().await; + } + #[test] fn wrap_bash_uses_base64_encoding() { let wrapped = wrap_bash_command("echo hello"); diff --git a/lib/crates/fabro-sandbox/src/sandbox_spec.rs b/lib/crates/fabro-sandbox/src/sandbox_spec.rs index e97e157db..6a54c7bf1 100644 --- a/lib/crates/fabro-sandbox/src/sandbox_spec.rs +++ b/lib/crates/fabro-sandbox/src/sandbox_spec.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use std::sync::Arc; -#[cfg(any(feature = "docker", feature = "daytona"))] +#[cfg(feature = "docker")] use anyhow::Context as _; #[cfg(any(feature = "docker", feature = "daytona"))] use fabro_github::GitHubCredentials; diff --git a/lib/crates/fabro-server/src/diagnostics.rs b/lib/crates/fabro-server/src/diagnostics.rs index 60d9027e8..aa3cddc08 100644 --- a/lib/crates/fabro-server/src/diagnostics.rs +++ b/lib/crates/fabro-server/src/diagnostics.rs @@ -6,6 +6,7 @@ use fabro_auth::auth_issue_message; use fabro_llm::client::Client as LlmClient; use fabro_llm::types::{Message, Request}; use fabro_model::{Catalog, Provider}; +use fabro_sandbox::daytona; use fabro_static::EnvVars; use fabro_types::settings::server::GithubIntegrationStrategy; use fabro_types::settings::{InterpString, ServerAuthMethod}; @@ -52,12 +53,12 @@ fn validate_session_secret(value: &str) -> Result<(), String> { } pub async fn run_all(state: &AppState) -> DiagnosticsReport { - let (llm, github, brave) = tokio::join!( + let (llm, github, sandbox, brave) = tokio::join!( check_llm_providers(state), check_github_app(state), + check_sandbox(state), check_brave_search(state), ); - let sandbox = check_sandbox(state); let crypto = check_crypto(state); DiagnosticsReport { @@ -381,17 +382,9 @@ async fn check_github_app(state: &AppState) -> CheckResult { } } -fn check_sandbox(state: &AppState) -> CheckResult { - if state.vault_or_env(EnvVars::DAYTONA_API_KEY).is_some() { - CheckResult { - name: "Sandbox".to_string(), - status: CheckStatus::Pass, - summary: "Daytona configured".to_string(), - details: Vec::new(), - remediation: None, - } - } else { - CheckResult { +async fn check_sandbox(state: &AppState) -> CheckResult { + let Some(api_key) = state.vault_or_env(EnvVars::DAYTONA_API_KEY) else { + return CheckResult { name: "Sandbox".to_string(), status: CheckStatus::Warning, summary: "recommended, not configured".to_string(), @@ -400,7 +393,38 @@ fn check_sandbox(state: &AppState) -> CheckResult { "Run `fabro secret set DAYTONA_API_KEY` to enable cloud sandbox execution" .to_string(), ), - } + }; + }; + + match state.check_daytona_api_key(api_key).await { + Ok(check) if check.ok() => CheckResult { + name: "Sandbox".to_string(), + status: CheckStatus::Pass, + summary: format!("Daytona configured ({})", check.key_name), + details: Vec::new(), + remediation: None, + }, + Ok(check) => CheckResult { + name: "Sandbox".to_string(), + status: CheckStatus::Error, + summary: "Daytona API key is missing required scopes".to_string(), + details: vec![CheckDetail::new(format!( + "missing: {}", + check.missing_display() + ))], + remediation: Some(format!( + "Regenerate the Daytona API key with scopes: {}, then \ + `fabro secret set DAYTONA_API_KEY`.", + daytona::required_perms_display() + )), + }, + Err(err) => CheckResult { + name: "Sandbox".to_string(), + status: CheckStatus::Error, + summary: "Daytona credential rejected".to_string(), + details: vec![CheckDetail::new(format!("{err:#}"))], + remediation: Some("Verify DAYTONA_API_KEY value and Daytona reachability".to_string()), + }, } } diff --git a/lib/crates/fabro-server/src/install.rs b/lib/crates/fabro-server/src/install.rs index aeae833c9..e044f9cb4 100644 --- a/lib/crates/fabro-server/src/install.rs +++ b/lib/crates/fabro-server/src/install.rs @@ -75,8 +75,10 @@ pub type InstallFinishHook = Arc anyhow::Result<() #[derive(Clone, Debug, Default)] struct InstallUpstreamConfig { - provider_base_urls: HashMap, - github_api_base_url: Option, + provider_base_urls: HashMap, + github_api_base_url: Option, + daytona_api_base_url: Option, + daytona_organization_id: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -203,6 +205,18 @@ impl InstallAppState { self } + #[must_use] + pub fn with_daytona_api_base_url(mut self, base_url: impl Into) -> Self { + self.upstreams.daytona_api_base_url = Some(base_url.into()); + self + } + + #[must_use] + pub fn with_daytona_organization_id(mut self, organization_id: impl Into) -> Self { + self.upstreams.daytona_organization_id = Some(organization_id.into()); + self + } + fn set_install_bind(&self, bind: &Bind) { *lock_unpoisoned(&self.install_listen, "install listen") = install_listen_config(bind); } @@ -928,8 +942,15 @@ async fn post_install_sandbox_test( } }; - match daytona::validate_daytona_api_key(api_key).await { - Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(), + match check_install_daytona_api_key(&state, api_key).await { + Ok(check) if check.ok() => Json(serde_json::json!({ "ok": true })).into_response(), + Ok(check) => { + warn!( + missing = %check.missing_display(), + "install sandbox scopes insufficient" + ); + install_error_response(StatusCode::UNPROCESSABLE_ENTITY, check.missing_message()) + } Err(err) => { warn!(error = %err, "install sandbox validation failed"); install_error_response( @@ -940,6 +961,19 @@ async fn post_install_sandbox_test( } } +async fn check_install_daytona_api_key( + state: &InstallAppState, + api_key: String, +) -> anyhow::Result { + let base_url = state + .upstreams + .daytona_api_base_url + .as_deref() + .unwrap_or(daytona::DEFAULT_DAYTONA_API_URL); + let organization_id = state.upstreams.daytona_organization_id.as_deref(); + daytona::check_daytona_api_key_with(base_url, organization_id, api_key).await +} + async fn put_install_sandbox( State(state): State, headers: HeaderMap, diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 268c0b534..421e8d6e5 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -56,7 +56,7 @@ use fabro_llm::types::{ }; use fabro_model::{BilledModelUsage, BilledTokenCounts, Catalog, ModelTestMode, Provider}; use fabro_redact::redact_jsonl_line; -use fabro_sandbox::daytona::DaytonaSandbox; +use fabro_sandbox::daytona::{self, DaytonaSandbox}; use fabro_sandbox::reconnect::reconnect; use fabro_sandbox::{Sandbox, SandboxProvider}; use fabro_slack::client::{PostedMessage as SlackPostedMessage, SlackClient}; @@ -603,6 +603,23 @@ impl AppState { }) } + fn env_lookup_or_vault_or_env(&self, name: &str) -> Option { + (self.env_lookup)(name).or_else(|| self.vault_or_env(name)) + } + + pub(crate) async fn check_daytona_api_key( + &self, + api_key: String, + ) -> anyhow::Result { + let base_url = self + .env_lookup_or_vault_or_env(EnvVars::DAYTONA_API_URL) + .or_else(|| self.env_lookup_or_vault_or_env(EnvVars::DAYTONA_SERVER_URL)) + .unwrap_or_else(|| daytona::DEFAULT_DAYTONA_API_URL.to_string()); + let org_id = self.env_lookup_or_vault_or_env(EnvVars::DAYTONA_ORGANIZATION_ID); + + daytona::check_daytona_api_key_with(&base_url, org_id.as_deref(), api_key).await + } + /// Public accessor used by `run_files` — mirrors `vault_or_env` without /// changing its visibility semantics. pub(crate) fn vault_or_env_pub(&self, name: &str) -> Option { diff --git a/lib/crates/fabro-server/src/server/handler/secrets.rs b/lib/crates/fabro-server/src/server/handler/secrets.rs index cbbeecc29..ae87e4c9c 100644 --- a/lib/crates/fabro-server/src/server/handler/secrets.rs +++ b/lib/crates/fabro-server/src/server/handler/secrets.rs @@ -1,5 +1,7 @@ use std::sync::Arc; +use fabro_static::EnvVars; + use super::super::{ ApiError, AppState, CreateSecretRequest, DeleteSecretRequest, IntoResponse, Json, RequiredUser, Response, Router, SecretType, State, StatusCode, VaultError, get, parse_credential_secret, @@ -34,6 +36,22 @@ async fn create_secret( return ApiError::bad_request(err).into_response(); } } + if secret_type == SecretType::Environment && name == EnvVars::DAYTONA_API_KEY { + match state.check_daytona_api_key(value.clone()).await { + Ok(check) if check.ok() => {} + Ok(check) => { + return ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, check.missing_message()) + .into_response(); + } + Err(err) => { + return ApiError::new( + StatusCode::UNPROCESSABLE_ENTITY, + format!("daytona credential validation failed: {err}"), + ) + .into_response(); + } + } + } let state_for_write = Arc::clone(&state); let result = spawn_blocking(move || { let mut vault = state_for_write.vault.blocking_write(); diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs index cdb62e657..f128688b4 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/crates/fabro-server/src/server/tests.rs @@ -21,7 +21,8 @@ use fabro_types::{ InterviewQuestionRecord, Outcome, QuestionType, RunBlobId, RunId, RunSpec, StageOutcome, SystemActorKind, fixtures, }; -use httpmock::Method::POST; +use fabro_util::check_report::CheckStatus; +use httpmock::Method::{GET, POST}; use httpmock::MockServer; use serde_json::json; use tokio_stream::StreamExt as _; @@ -121,6 +122,49 @@ async fn body_json(body: Body) -> serde_json::Value { serde_json::from_slice(&bytes).unwrap() } +async fn mock_daytona_auth_probe(server: &MockServer) -> httpmock::Mock<'_> { + server + .mock_async(|when, then| { + when.method(GET) + .path("/sandbox/paginated") + .query_param("page", "1") + .query_param("limit", "1"); + then.status(200) + .header("content-type", "application/json") + .json_body(json!({ + "items": [], + "total": 0, + "page": 1, + "totalPages": 0 + })); + }) + .await +} + +async fn mock_daytona_current_key<'a>( + server: &'a MockServer, + permissions: Vec<&'static str>, +) -> httpmock::Mock<'a> { + server + .mock_async(move |when, then| { + when.method(GET) + .path("/api-keys/current") + .header("authorization", "Bearer dtn_test"); + then.status(200) + .header("content-type", "application/json") + .json_body(json!({ + "name": "delete-only", + "value": "dtn_****", + "createdAt": "2026-05-01T00:00:00Z", + "permissions": permissions, + "lastUsedAt": null, + "expiresAt": null, + "userId": "user_123" + })); + }) + .await +} + fn openai_api_key_credential(key: &str) -> AuthCredential { AuthCredential { provider: Provider::OpenAi, @@ -1001,6 +1045,131 @@ async fn create_secret_stores_valid_credential_entries() { assert!(state.vault.read().await.get("openai_codex").is_some()); } +#[tokio::test] +async fn create_secret_rejects_under_scoped_daytona_api_key_and_leaves_vault_unchanged() { + let server = MockServer::start_async().await; + let auth = mock_daytona_auth_probe(&server).await; + let current_key = mock_daytona_current_key(&server, vec![ + "delete:snapshots", + "delete:sandboxes", + "delete:volumes", + ]) + .await; + let base_url = server.base_url(); + let state = test_app_state_with_env_lookup( + default_test_server_settings(), + fabro_config::RunLayer::default(), + 5, + move |name| match name { + EnvVars::DAYTONA_API_URL => Some(base_url.clone()), + _ => None, + }, + ); + state + .vault + .write() + .await + .set( + EnvVars::DAYTONA_API_KEY, + "existing", + SecretType::Environment, + None, + ) + .unwrap(); + let app = crate::test_support::build_test_router(Arc::clone(&state)); + + let req = Request::builder() + .method("POST") + .uri(api("/secrets")) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_string(&serde_json::json!({ + "name": EnvVars::DAYTONA_API_KEY, + "value": "dtn_test", + "type": "environment" + })) + .unwrap(), + )) + .unwrap(); + + let response = app.oneshot(req).await.unwrap(); + let body = response_json!(response, StatusCode::UNPROCESSABLE_ENTITY).await; + + assert_eq!( + body["errors"][0]["detail"], + "API key 'delete-only' is missing required Daytona scopes: \ + write:snapshots, write:sandboxes. Regenerate the key with all \ + snapshot and sandbox scopes." + ); + assert_eq!( + state.vault.read().await.get(EnvVars::DAYTONA_API_KEY), + Some("existing") + ); + auth.assert_async().await; + current_key.assert_async().await; +} + +#[tokio::test] +async fn diagnostics_reports_under_scoped_daytona_api_key() { + let server = MockServer::start_async().await; + let auth = mock_daytona_auth_probe(&server).await; + let current_key = mock_daytona_current_key(&server, vec![ + "delete:snapshots", + "delete:sandboxes", + "delete:volumes", + ]) + .await; + let base_url = server.base_url(); + let state = test_app_state_with_env_lookup( + default_test_server_settings(), + fabro_config::RunLayer::default(), + 5, + move |name| match name { + EnvVars::DAYTONA_API_URL => Some(base_url.clone()), + _ => None, + }, + ); + state + .vault + .write() + .await + .set( + EnvVars::DAYTONA_API_KEY, + "dtn_test", + SecretType::Environment, + None, + ) + .unwrap(); + + let report = crate::diagnostics::run_all(&state).await; + let sandbox = report + .sections + .iter() + .flat_map(|section| §ion.checks) + .find(|check| check.name == "Sandbox") + .expect("sandbox check should be present"); + + assert_eq!(sandbox.status, CheckStatus::Error); + assert_eq!( + sandbox.summary, + "Daytona API key is missing required scopes" + ); + assert_eq!( + sandbox.details[0].text, + "missing: write:snapshots, write:sandboxes" + ); + assert_eq!( + sandbox.remediation.as_deref(), + Some( + "Regenerate the Daytona API key with scopes: write:snapshots, \ + delete:snapshots, write:sandboxes, delete:sandboxes, then \ + `fabro secret set DAYTONA_API_KEY`." + ) + ); + auth.assert_async().await; + current_key.assert_async().await; +} + #[tokio::test] async fn resolve_llm_client_reads_openai_codex_credential_from_vault() { let state = test_app_state_with_env_lookup( diff --git a/lib/crates/fabro-server/tests/it/api/install.rs b/lib/crates/fabro-server/tests/it/api/install.rs index 22943520e..08433daac 100644 --- a/lib/crates/fabro-server/tests/it/api/install.rs +++ b/lib/crates/fabro-server/tests/it/api/install.rs @@ -19,6 +19,7 @@ use fabro_server::install::{ }; use fabro_util::{Home, dev_token}; use fabro_vault::Vault; +use httpmock::Method::GET; use httpmock::MockServer; use tokio::time::sleep; use tower::ServiceExt; @@ -33,6 +34,49 @@ fn spa_fixture_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/spa") } +async fn mock_daytona_auth_probe(server: &MockServer) -> httpmock::Mock<'_> { + server + .mock_async(|when, then| { + when.method(GET) + .path("/sandbox/paginated") + .query_param("page", "1") + .query_param("limit", "1"); + then.status(200) + .header("content-type", "application/json") + .json_body(serde_json::json!({ + "items": [], + "total": 0, + "page": 1, + "totalPages": 0 + })); + }) + .await +} + +async fn mock_daytona_current_key<'a>( + server: &'a MockServer, + permissions: Vec<&'static str>, +) -> httpmock::Mock<'a> { + server + .mock_async(move |when, then| { + when.method(GET) + .path("/api-keys/current") + .header("authorization", "Bearer dtn_test"); + then.status(200) + .header("content-type", "application/json") + .json_body(serde_json::json!({ + "name": "delete-only", + "value": "dtn_****", + "createdAt": "2026-05-01T00:00:00Z", + "permissions": permissions, + "lastUsedAt": null, + "expiresAt": null, + "userId": "user_123" + })); + }) + .await +} + #[derive(Default)] struct EventCapture { fields: Vec<(String, String)>, @@ -2434,6 +2478,50 @@ async fn daytona_install_finish_writes_settings_and_vault_secret() { assert_eq!(vault.get("DAYTONA_API_KEY"), Some(api_key)); } +#[tokio::test] +async fn sandbox_daytona_test_endpoint_rejects_under_scoped_api_key() { + let server = MockServer::start_async().await; + let auth = mock_daytona_auth_probe(&server).await; + let current_key = mock_daytona_current_key(&server, vec![ + "delete:snapshots", + "delete:sandboxes", + "delete:volumes", + ]) + .await; + let app = build_install_router( + InstallAppState::for_test("test-install-token") + .with_daytona_api_base_url(server.base_url()), + ); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/install/sandbox/test") + .header("authorization", "Bearer test-install-token") + .header("content-type", "application/json") + .body(Body::from(r#"{"provider":"daytona","api_key":"dtn_test"}"#)) + .unwrap(), + ) + .await + .unwrap(); + let body = response_json( + response, + StatusCode::UNPROCESSABLE_ENTITY, + "POST /install/sandbox/test under-scoped daytona", + ) + .await; + + assert_eq!( + body["errors"][0]["detail"], + "API key 'delete-only' is missing required Daytona scopes: \ + write:snapshots, write:sandboxes. Regenerate the key with all \ + snapshot and sandbox scopes." + ); + auth.assert_async().await; + current_key.assert_async().await; +} + #[fabro_macros::e2e_test(live("DAYTONA_API_KEY"))] async fn sandbox_daytona_test_endpoint_validates_real_api_key() { let api_key = std::env::var(fabro_static::EnvVars::DAYTONA_API_KEY) diff --git a/lib/crates/fabro-static/src/env_vars.rs b/lib/crates/fabro-static/src/env_vars.rs index 9568418d7..483010c9d 100644 --- a/lib/crates/fabro-static/src/env_vars.rs +++ b/lib/crates/fabro-static/src/env_vars.rs @@ -93,6 +93,7 @@ impl EnvVars { pub const AWS_WEB_IDENTITY_TOKEN_FILE: &'static str = "AWS_WEB_IDENTITY_TOKEN_FILE"; pub const DAYTONA_API_KEY: &'static str = "DAYTONA_API_KEY"; pub const DAYTONA_API_URL: &'static str = "DAYTONA_API_URL"; + pub const DAYTONA_ORGANIZATION_ID: &'static str = "DAYTONA_ORGANIZATION_ID"; pub const DAYTONA_SERVER_URL: &'static str = "DAYTONA_SERVER_URL"; pub const SESSION_SECRET: &'static str = "SESSION_SECRET"; @@ -221,6 +222,7 @@ mod tests { EnvVars::AWS_WEB_IDENTITY_TOKEN_FILE, EnvVars::DAYTONA_API_KEY, EnvVars::DAYTONA_API_URL, + EnvVars::DAYTONA_ORGANIZATION_ID, EnvVars::DAYTONA_SERVER_URL, EnvVars::SESSION_SECRET, EnvVars::CARGO_BIN_EXE_FABRO,