mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
parent
5c60fe8182
commit
2d7c79de1e
11 changed files with 650 additions and 29 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -2117,6 +2117,7 @@ dependencies = [
|
|||
"futures",
|
||||
"git2",
|
||||
"glob",
|
||||
"httpmock",
|
||||
"rand 0.9.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
|
|||
|
|
@ -62,3 +62,4 @@ tempfile = "3"
|
|||
uuid.workspace = true
|
||||
serde_json.workspace = true
|
||||
toml.workspace = true
|
||||
httpmock = "0.8"
|
||||
|
|
|
|||
|
|
@ -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<Permissions>,
|
||||
}
|
||||
|
||||
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::<Vec<_>>()
|
||||
.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<String>,
|
||||
) -> Result<daytona_sdk::Client, daytona_sdk::DaytonaError> {
|
||||
build_daytona_client_with(api_key, None, None).await
|
||||
}
|
||||
|
||||
async fn build_daytona_client_with(
|
||||
api_key: Option<String>,
|
||||
api_url: Option<String>,
|
||||
organization_id: Option<String>,
|
||||
) -> Result<daytona_sdk::Client, daytona_sdk::DaytonaError> {
|
||||
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<DaytonaKeyCheck> {
|
||||
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<DaytonaKeyCheck> {
|
||||
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::<Vec<_>>();
|
||||
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -75,8 +75,10 @@ pub type InstallFinishHook = Arc<dyn Fn(&InstallFinishInfo) -> anyhow::Result<()
|
|||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct InstallUpstreamConfig {
|
||||
provider_base_urls: HashMap<Provider, String>,
|
||||
github_api_base_url: Option<String>,
|
||||
provider_base_urls: HashMap<Provider, String>,
|
||||
github_api_base_url: Option<String>,
|
||||
daytona_api_base_url: Option<String>,
|
||||
daytona_organization_id: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>) -> 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<String>) -> 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<daytona::DaytonaKeyCheck> {
|
||||
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<InstallAppState>,
|
||||
headers: HeaderMap,
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
(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<daytona::DaytonaKeyCheck> {
|
||||
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<String> {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue