diff --git a/docs/api-reference/fabro-api.yaml b/docs/api-reference/fabro-api.yaml index f523b75a0..a635bbc55 100644 --- a/docs/api-reference/fabro-api.yaml +++ b/docs/api-reference/fabro-api.yaml @@ -1327,24 +1327,16 @@ paths: application/json: schema: $ref: "#/components/schemas/SecretListResponse" - - /api/v1/secrets/{name}: - put: - operationId: setSecret + post: + operationId: createSecret tags: [Secrets] summary: Store or update a secret - parameters: - - name: name - in: path - required: true - schema: - type: string requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/SetSecretRequest" + $ref: "#/components/schemas/CreateSecretRequest" responses: "200": description: Secret stored @@ -1359,20 +1351,20 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" delete: - operationId: deleteSecret + operationId: deleteSecretByName tags: [Secrets] summary: Delete a stored secret - parameters: - - name: name - in: path - required: true - schema: - type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteSecretRequest" responses: "204": description: Secret deleted "400": - description: Invalid secret name + description: Invalid secret name or request body content: application/json: schema: @@ -4329,28 +4321,61 @@ components: description: Server version string. example: "0.176.2" - SetSecretRequest: - description: Request to store a secret value. + SecretType: + description: The way a secret is consumed by the sandbox. + type: string + enum: + - environment + - file + + CreateSecretRequest: + description: Request to store or update a secret. type: object required: + - name - value + - type properties: + name: + type: string + description: Secret name or destination path for file secrets. value: type: string description: The secret value to store. + type: + $ref: "#/components/schemas/SecretType" + description: + type: string + description: Optional operator-facing description of the secret. + + DeleteSecretRequest: + description: Request to delete a secret by name. + type: object + required: + - name + properties: + name: + type: string + description: Secret name or destination path for file secrets. SecretMetadata: description: Metadata for a stored secret (value is never exposed). type: object required: - name + - type - created_at - updated_at properties: name: type: string - description: Secret key name. + description: Secret key name or destination path. example: ANTHROPIC_API_KEY + type: + $ref: "#/components/schemas/SecretType" + description: + type: string + description: Optional operator-facing description of the secret. created_at: type: string format: date-time diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index 216d0e532..e2b348c23 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -489,12 +489,22 @@ pub(crate) struct SecretRmArgs { pub(crate) key: String, } +#[derive(Clone, Copy, Debug, ValueEnum)] +pub(crate) enum SecretTypeArg { + Environment, + File, +} + #[derive(Args)] pub(crate) struct SecretSetArgs { /// Name of the secret - pub(crate) key: String, + pub(crate) key: String, /// Value to store - pub(crate) value: String, + pub(crate) value: String, + #[arg(long, value_enum, default_value = "environment")] + pub(crate) r#type: SecretTypeArg, + #[arg(long)] + pub(crate) description: Option, } #[derive(Debug, Args)] diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index e298a4449..4a0b13fb0 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -12,11 +12,11 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use dialoguer::console::Term; use dialoguer::theme::ColorfulTheme; use dialoguer::{MultiSelect, Select}; -use fabro_api::types::SetSecretRequest; +use fabro_api::types::{CreateSecretRequest, SecretType as ApiSecretType}; use fabro_config::user::SETTINGS_CONFIG_FILENAME; use fabro_config::{Storage, legacy_env}; use fabro_model::Provider; -use fabro_server::secret_store::SecretStore; +use fabro_server::secret_store::{SecretStore, SecretType}; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use rand::Rng; @@ -687,10 +687,12 @@ async fn persist_install_secrets( let client = server_client::connect_api_client(storage_dir).await?; for (name, value) in secrets { client - .set_secret() - .name(name.clone()) - .body(SetSecretRequest { - value: value.clone(), + .create_secret() + .body(CreateSecretRequest { + name: name.clone(), + value: value.clone(), + type_: ApiSecretType::Environment, + description: None, }) .send() .await?; @@ -700,7 +702,7 @@ async fn persist_install_secrets( let mut store = SecretStore::load(Storage::new(storage_dir).secrets_path())?; for (name, value) in secrets { - store.set(name, value)?; + store.set(name, value, SecretType::Environment, None)?; } Ok(()) } diff --git a/lib/crates/fabro-cli/src/commands/provider/login.rs b/lib/crates/fabro-cli/src/commands/provider/login.rs index f0ca944ae..49189bd94 100644 --- a/lib/crates/fabro-cli/src/commands/provider/login.rs +++ b/lib/crates/fabro-cli/src/commands/provider/login.rs @@ -46,9 +46,13 @@ pub(super) async fn login_command( for (name, value) in env_pairs { server .api() - .set_secret() - .name(name.clone()) - .body(types::SetSecretRequest { value }) + .create_secret() + .body(types::CreateSecretRequest { + name: name.clone(), + value, + type_: types::SecretType::Environment, + description: None, + }) .send() .await?; fabro_util::printerr!(printer, " {} Saved {}", s.green.apply_to("✔"), name); diff --git a/lib/crates/fabro-cli/src/commands/secret/list.rs b/lib/crates/fabro-cli/src/commands/secret/list.rs index 88a1d63ae..6fe367089 100644 --- a/lib/crates/fabro-cli/src/commands/secret/list.rs +++ b/lib/crates/fabro-cli/src/commands/secret/list.rs @@ -24,7 +24,13 @@ pub(super) async fn list_command( } let _ = args; for secret in secrets { - fabro_util::printout!(printer, "{}\t{}", secret.name, secret.updated_at); + fabro_util::printout!( + printer, + "{}\t{}\t{}", + secret.name, + secret.type_, + secret.updated_at + ); } Ok(()) } diff --git a/lib/crates/fabro-cli/src/commands/secret/rm.rs b/lib/crates/fabro-cli/src/commands/secret/rm.rs index 742a2e949..62a97fe73 100644 --- a/lib/crates/fabro-cli/src/commands/secret/rm.rs +++ b/lib/crates/fabro-cli/src/commands/secret/rm.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use fabro_api::Client; +use fabro_api::{Client, types}; use fabro_util::printer::Printer; use crate::args::{GlobalArgs, SecretRmArgs}; @@ -13,8 +13,10 @@ pub(super) async fn rm_command( printer: Printer, ) -> Result<()> { client - .delete_secret() - .name(args.key.clone()) + .delete_secret_by_name() + .body(types::DeleteSecretRequest { + name: args.key.clone(), + }) .send() .await .map_err(server_client::map_api_error)?; diff --git a/lib/crates/fabro-cli/src/commands/secret/set.rs b/lib/crates/fabro-cli/src/commands/secret/set.rs index bd9ec5d5a..c70b8ad2b 100644 --- a/lib/crates/fabro-cli/src/commands/secret/set.rs +++ b/lib/crates/fabro-cli/src/commands/secret/set.rs @@ -2,10 +2,17 @@ use anyhow::Result; use fabro_api::{Client, types}; use fabro_util::printer::Printer; -use crate::args::{GlobalArgs, SecretSetArgs}; +use crate::args::{GlobalArgs, SecretSetArgs, SecretTypeArg}; use crate::server_client; use crate::shared::print_json_pretty; +fn api_secret_type(secret_type: SecretTypeArg) -> types::SecretType { + match secret_type { + SecretTypeArg::Environment => types::SecretType::Environment, + SecretTypeArg::File => types::SecretType::File, + } +} + pub(super) async fn set_command( client: &Client, args: &SecretSetArgs, @@ -13,10 +20,12 @@ pub(super) async fn set_command( printer: Printer, ) -> Result<()> { let meta = client - .set_secret() - .name(args.key.clone()) - .body(types::SetSecretRequest { - value: args.value.clone(), + .create_secret() + .body(types::CreateSecretRequest { + name: args.key.clone(), + value: args.value.clone(), + type_: api_secret_type(args.r#type), + description: args.description.clone(), }) .send() .await diff --git a/lib/crates/fabro-cli/tests/it/cmd/secret.rs b/lib/crates/fabro-cli/tests/it/cmd/secret.rs index b18b19f0c..db64fb1ad 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/secret.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/secret.rs @@ -47,7 +47,7 @@ fn test_secret_lifecycle() { // 2. list -> contains FOO secret(&["list"]) .success() - .stdout(predicates::str::contains("FOO")); + .stdout(predicates::str::contains("FOO\tenvironment")); // 3. update FOO secret(&["set", "FOO", "updated"]).success(); @@ -90,7 +90,7 @@ fn test_secret_list_alias_ls() { .args(["ls"]) .assert() .success() - .stdout(predicates::str::contains("X")); + .stdout(predicates::str::contains("X\tenvironment")); } #[test] @@ -122,6 +122,36 @@ fn test_secret_value_with_equals() { .args(["list"]) .assert() .success() - .stdout(predicates::str::contains("URL")) + .stdout(predicates::str::contains("URL\tenvironment")) .stdout(predicates::str::contains("https://x.com?a=1&b=2").not()); } + +#[test] +fn test_file_secret_lifecycle() { + let context = test_context!(); + + let secret = + |args: &[&str]| -> assert_cmd::assert::Assert { context.secret().args(args).assert() }; + + secret(&[ + "set", + "/tmp/test.pem", + "pem-data", + "--type", + "file", + "--description", + "Test certificate", + ]) + .success(); + + secret(&["list"]) + .success() + .stdout(predicates::str::contains("/tmp/test.pem\tfile")) + .stdout(predicates::str::contains("pem-data").not()); + + secret(&["rm", "/tmp/test.pem"]).success(); + + secret(&["list"]) + .success() + .stdout(predicates::str::contains("/tmp/test.pem").not()); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/secret_list.rs b/lib/crates/fabro-cli/tests/it/cmd/secret_list.rs index 27dc86c5a..531ca77e3 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/secret_list.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/secret_list.rs @@ -47,6 +47,7 @@ fn secret_list_json_returns_metadata_only() { .iter() .find(|entry| entry["name"] == "ANTHROPIC_API_KEY") .expect("secret list should include the saved key"); + assert_eq!(entry["type"], "environment"); assert!(entry.get("updated_at").is_some()); assert!(entry.get("value").is_none()); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/secret_set.rs b/lib/crates/fabro-cli/tests/it/cmd/secret_set.rs index 3d1a49c6a..cc71d2691 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/secret_set.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/secret_set.rs @@ -18,12 +18,14 @@ fn help() { Value to store Options: - --json Output as JSON [env: FABRO_JSON=] - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - -h, --help Print help + --json Output as JSON [env: FABRO_JSON=] + --type [default: environment] [possible values: environment, file] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --description + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help ----- stderr ----- "); } diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index 92e5d7ebe..48f5d77e7 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -11,7 +11,7 @@ use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::response::sse::{Event, Sse}; use axum::response::{IntoResponse, Response}; -use fabro_api::types::RunArtifactListResponse; +use fabro_api::types::{CreateSecretRequest, DeleteSecretRequest, RunArtifactListResponse}; use serde_json::json; use crate::error::ApiError; @@ -316,11 +316,13 @@ pub(crate) async fn list_secrets( "data": [ { "name": "OPENAI_API_KEY", + "type": "environment", "created_at": "2026-04-05T12:00:00Z", "updated_at": "2026-04-05T12:00:00Z" }, { "name": "GITHUB_APP_PRIVATE_KEY", + "type": "environment", "created_at": "2026-04-05T12:05:00Z", "updated_at": "2026-04-05T12:05:00Z" } @@ -330,26 +332,27 @@ pub(crate) async fn list_secrets( .into_response() } -pub(crate) async fn set_secret( +pub(crate) async fn create_secret( _auth: AuthenticatedService, State(_state): State>, - Path(name): Path, + Json(body): Json, ) -> Response { - ( - StatusCode::OK, - Json(json!({ - "name": name, - "created_at": "2026-04-05T12:00:00Z", - "updated_at": "2026-04-05T12:00:00Z" - })), - ) - .into_response() + let mut payload = serde_json::Map::new(); + payload.insert("name".to_string(), json!(body.name)); + payload.insert("type".to_string(), json!(body.type_)); + if let Some(description) = body.description { + payload.insert("description".to_string(), json!(description)); + } + payload.insert("created_at".to_string(), json!("2026-04-05T12:00:00Z")); + payload.insert("updated_at".to_string(), json!("2026-04-05T12:00:00Z")); + + (StatusCode::OK, Json(serde_json::Value::Object(payload))).into_response() } -pub(crate) async fn delete_secret( +pub(crate) async fn delete_secret_by_name( _auth: AuthenticatedService, State(_state): State>, - Path(_name): Path, + Json(_body): Json, ) -> Response { StatusCode::NO_CONTENT.into_response() } diff --git a/lib/crates/fabro-server/src/secret_store.rs b/lib/crates/fabro-server/src/secret_store.rs index d87c53014..b599dac77 100644 --- a/lib/crates/fabro-server/src/secret_store.rs +++ b/lib/crates/fabro-server/src/secret_store.rs @@ -1,19 +1,35 @@ use std::collections::HashMap; use std::fmt; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SecretType { + #[default] + Environment, + File, +} #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct SecretEntry { - pub value: String, - pub created_at: String, - pub updated_at: String, + pub value: String, + #[serde(rename = "type", default)] + pub secret_type: SecretType, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + pub created_at: String, + pub updated_at: String, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct SecretMetadata { - pub name: String, - pub created_at: String, - pub updated_at: String, + pub name: String, + #[serde(rename = "type")] + pub secret_type: SecretType, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + pub created_at: String, + pub updated_at: String, } #[derive(Debug)] @@ -66,16 +82,31 @@ impl SecretStore { Ok(Self { path, entries }) } - pub fn set(&mut self, name: &str, value: &str) -> Result { - Self::validate_name(name)?; + pub fn set( + &mut self, + name: &str, + value: &str, + secret_type: SecretType, + description: Option<&str>, + ) -> Result { + Self::validate_name(name, secret_type)?; let now = chrono::Utc::now().to_rfc3339(); - let created_at = self - .entries - .get(name) - .map_or_else(|| now.clone(), |entry| entry.created_at.clone()); + let (created_at, description) = self.entries.get(name).map_or_else( + || (now.clone(), description.map(str::to_string)), + |entry| { + ( + entry.created_at.clone(), + description + .map(str::to_string) + .or_else(|| entry.description.clone()), + ) + }, + ); let entry = SecretEntry { - value: value.to_string(), + value: value.to_string(), + secret_type, + description: description.clone(), created_at: created_at.clone(), updated_at: now.clone(), }; @@ -84,13 +115,14 @@ impl SecretStore { Ok(SecretMetadata { name: name.to_string(), + secret_type, + description, created_at, updated_at: now, }) } pub fn remove(&mut self, name: &str) -> Result<(), SecretStoreError> { - Self::validate_name(name)?; if self.entries.remove(name).is_none() { return Err(SecretStoreError::NotFound(name.to_string())); } @@ -103,9 +135,11 @@ impl SecretStore { .entries .iter() .map(|(name, entry)| SecretMetadata { - name: name.clone(), - created_at: entry.created_at.clone(), - updated_at: entry.updated_at.clone(), + name: name.clone(), + secret_type: entry.secret_type, + description: entry.description.clone(), + created_at: entry.created_at.clone(), + updated_at: entry.updated_at.clone(), }) .collect::>(); data.sort_by(|a, b| a.name.cmp(&b.name)); @@ -119,11 +153,30 @@ impl SecretStore { pub fn snapshot(&self) -> HashMap { self.entries .iter() + .filter(|(_, entry)| entry.secret_type == SecretType::Environment) .map(|(name, entry)| (name.clone(), entry.value.clone())) .collect() } - pub fn validate_name(name: &str) -> Result<(), SecretStoreError> { + pub fn file_secrets(&self) -> Vec<(String, String)> { + let mut data = self + .entries + .iter() + .filter(|(_, entry)| entry.secret_type == SecretType::File) + .map(|(name, entry)| (name.clone(), entry.value.clone())) + .collect::>(); + data.sort_by(|a, b| a.0.cmp(&b.0)); + data + } + + pub fn validate_name(name: &str, secret_type: SecretType) -> Result<(), SecretStoreError> { + match secret_type { + SecretType::Environment => Self::validate_env_name(name), + SecretType::File => Self::validate_file_name(name), + } + } + + fn validate_env_name(name: &str) -> Result<(), SecretStoreError> { let mut chars = name.chars(); match chars.next() { Some(first) if first.is_ascii_alphabetic() || first == '_' => {} @@ -137,6 +190,26 @@ impl SecretStore { } } + fn validate_file_name(name: &str) -> Result<(), SecretStoreError> { + if !name.starts_with('/') || name.ends_with('/') || name.contains('\0') { + return Err(SecretStoreError::InvalidName(name.to_string())); + } + + let path = Path::new(name); + if !path.is_absolute() { + return Err(SecretStoreError::InvalidName(name.to_string())); + } + + if path + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + return Err(SecretStoreError::InvalidName(name.to_string())); + } + + Ok(()) + } + fn write_atomic(&self) -> Result<(), SecretStoreError> { let parent = self .path @@ -188,9 +261,13 @@ mod tests { let path = dir.path().join("secrets.json"); let mut store = SecretStore::load(path.clone()).unwrap(); - let meta = store.set("OPENAI_API_KEY", "secret").unwrap(); + let meta = store + .set("OPENAI_API_KEY", "secret", SecretType::Environment, None) + .unwrap(); assert_eq!(meta.name, "OPENAI_API_KEY"); + assert_eq!(meta.secret_type, SecretType::Environment); + assert_eq!(meta.description, None); assert_eq!(store.get("OPENAI_API_KEY"), Some("secret")); assert!(path.exists()); } @@ -201,8 +278,12 @@ mod tests { let path = dir.path().join("secrets.json"); let mut store = SecretStore::load(path).unwrap(); - let first = store.set("OPENAI_API_KEY", "first").unwrap(); - let second = store.set("OPENAI_API_KEY", "second").unwrap(); + let first = store + .set("OPENAI_API_KEY", "first", SecretType::Environment, None) + .unwrap(); + let second = store + .set("OPENAI_API_KEY", "second", SecretType::Environment, None) + .unwrap(); assert_eq!(first.created_at, second.created_at); assert_eq!(store.get("OPENAI_API_KEY"), Some("second")); @@ -213,7 +294,9 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("secrets.json"); let mut store = SecretStore::load(path.clone()).unwrap(); - store.set("OPENAI_API_KEY", "secret").unwrap(); + store + .set("OPENAI_API_KEY", "secret", SecretType::Environment, None) + .unwrap(); store.remove("OPENAI_API_KEY").unwrap(); @@ -234,8 +317,12 @@ mod tests { fn list_returns_sorted_metadata_without_values() { let dir = tempfile::tempdir().unwrap(); let mut store = SecretStore::load(dir.path().join("secrets.json")).unwrap(); - store.set("Z_KEY", "z").unwrap(); - store.set("A_KEY", "a").unwrap(); + store + .set("Z_KEY", "z", SecretType::Environment, None) + .unwrap(); + store + .set("A_KEY", "a", SecretType::Environment, None) + .unwrap(); let listed = store.list(); @@ -252,7 +339,177 @@ mod tests { fn invalid_names_are_rejected() { let dir = tempfile::tempdir().unwrap(); let mut store = SecretStore::load(dir.path().join("secrets.json")).unwrap(); - let error = store.set("NOT-VALID", "secret").unwrap_err(); + let error = store + .set("NOT-VALID", "secret", SecretType::Environment, None) + .unwrap_err(); assert_eq!(error.to_string(), "invalid secret name: NOT-VALID"); } + + #[test] + fn set_file_secret_stores_type() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("secrets.json"); + let mut store = SecretStore::load(path.clone()).unwrap(); + + let meta = store + .set("/root/.ssh/id_rsa", "secret", SecretType::File, None) + .unwrap(); + + assert_eq!(meta.name, "/root/.ssh/id_rsa"); + assert_eq!(meta.secret_type, SecretType::File); + + let reloaded = SecretStore::load(path).unwrap(); + let listed = reloaded.list(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].secret_type, SecretType::File); + } + + #[test] + fn set_file_secret_validates_absolute_path() { + let dir = tempfile::tempdir().unwrap(); + let mut store = SecretStore::load(dir.path().join("secrets.json")).unwrap(); + + let error = store + .set("relative/path", "secret", SecretType::File, None) + .unwrap_err(); + + assert_eq!(error.to_string(), "invalid secret name: relative/path"); + } + + #[test] + fn set_file_secret_rejects_traversal() { + let dir = tempfile::tempdir().unwrap(); + let mut store = SecretStore::load(dir.path().join("secrets.json")).unwrap(); + + let error = store + .set("/root/../id_rsa", "secret", SecretType::File, None) + .unwrap_err(); + + assert_eq!(error.to_string(), "invalid secret name: /root/../id_rsa"); + } + + #[test] + fn set_env_secret_rejects_path_names() { + let dir = tempfile::tempdir().unwrap(); + let mut store = SecretStore::load(dir.path().join("secrets.json")).unwrap(); + + let error = store + .set("/foo/bar", "secret", SecretType::Environment, None) + .unwrap_err(); + + assert_eq!(error.to_string(), "invalid secret name: /foo/bar"); + } + + #[test] + fn snapshot_excludes_file_secrets() { + let dir = tempfile::tempdir().unwrap(); + let mut store = SecretStore::load(dir.path().join("secrets.json")).unwrap(); + store + .set("OPENAI_API_KEY", "env", SecretType::Environment, None) + .unwrap(); + store + .set("/tmp/test.pem", "file", SecretType::File, None) + .unwrap(); + + let snapshot = store.snapshot(); + + assert_eq!(snapshot.get("OPENAI_API_KEY"), Some(&"env".to_string())); + assert!(!snapshot.contains_key("/tmp/test.pem")); + } + + #[test] + fn snapshot_includes_only_env_secrets() { + let dir = tempfile::tempdir().unwrap(); + let mut store = SecretStore::load(dir.path().join("secrets.json")).unwrap(); + store + .set("ANTHROPIC_API_KEY", "a", SecretType::Environment, None) + .unwrap(); + store + .set("OPENAI_API_KEY", "b", SecretType::Environment, None) + .unwrap(); + + let snapshot = store.snapshot(); + + assert_eq!(snapshot.len(), 2); + assert_eq!(snapshot.get("ANTHROPIC_API_KEY"), Some(&"a".to_string())); + assert_eq!(snapshot.get("OPENAI_API_KEY"), Some(&"b".to_string())); + } + + #[test] + fn file_secrets_returns_only_files() { + let dir = tempfile::tempdir().unwrap(); + let mut store = SecretStore::load(dir.path().join("secrets.json")).unwrap(); + store + .set("OPENAI_API_KEY", "env", SecretType::Environment, None) + .unwrap(); + store + .set("/tmp/test.pem", "file", SecretType::File, None) + .unwrap(); + + let files = store.file_secrets(); + + assert_eq!(files, vec![( + "/tmp/test.pem".to_string(), + "file".to_string() + )]); + } + + #[test] + fn description_round_trips() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("secrets.json"); + let mut store = SecretStore::load(path.clone()).unwrap(); + + let meta = store + .set( + "/tmp/test.pem", + "file", + SecretType::File, + Some("Test certificate"), + ) + .unwrap(); + + assert_eq!(meta.description.as_deref(), Some("Test certificate")); + + let reloaded = SecretStore::load(path).unwrap(); + let listed = reloaded.list(); + assert_eq!(listed[0].description.as_deref(), Some("Test certificate")); + } + + #[test] + fn legacy_json_defaults_to_environment() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("secrets.json"); + std::fs::write( + &path, + r#"{ + "OPENAI_API_KEY": { + "value": "secret", + "created_at": "2026-04-12T00:00:00Z", + "updated_at": "2026-04-12T00:00:00Z" + } +}"#, + ) + .unwrap(); + + let store = SecretStore::load(path).unwrap(); + let listed = store.list(); + + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].secret_type, SecretType::Environment); + assert_eq!(listed[0].description, None); + } + + #[test] + fn remove_allows_file_path_names() { + let dir = tempfile::tempdir().unwrap(); + let mut store = SecretStore::load(dir.path().join("secrets.json")).unwrap(); + store + .set("/tmp/test.pem", "file", SecretType::File, None) + .unwrap(); + + store.remove("/tmp/test.pem").unwrap(); + + assert!(store.list().is_empty()); + } } diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 08aee15b7..b9af91fc2 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -15,7 +15,7 @@ use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header}; use axum::middleware::{self, Next}; use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::{IntoResponse, Response}; -use axum::routing::{get, post, put}; +use axum::routing::{get, post}; use axum::{Json, Router}; use axum_extra::extract::cookie::Key; use base64::Engine as _; @@ -26,16 +26,16 @@ pub use fabro_api::types::{ ArtifactEntry, ArtifactListResponse, BilledTokenCounts as ApiBilledTokenCounts, BillingByModel, BillingStageRef, CompletionContentPart, CompletionMessage, CompletionMessageRole, CompletionResponse, CompletionToolChoiceMode, CompletionUsage, CreateCompletionRequest, - DiskUsageResponse, DiskUsageRunRow, DiskUsageSummaryRow, EventEnvelope as ApiEventEnvelope, - ModelReference, PaginatedEventList, PaginatedRunList, PaginationMeta, PreflightResponse, - PreviewUrlRequest, PreviewUrlResponse, PruneRunEntry, PruneRunsRequest, PruneRunsResponse, - QuestionType as ApiQuestionType, RenderWorkflowGraphDirection, RenderWorkflowGraphFormat, - RenderWorkflowGraphRequest, RunArtifactEntry, RunArtifactListResponse, RunBilling, - RunBillingStage, RunBillingTotals, RunControlAction as ApiRunControlAction, RunError, - RunManifest, RunStatus, RunStatusResponse, SandboxFileEntry, SandboxFileListResponse, - ServerSettings, SetSecretRequest, SshAccessRequest, SshAccessResponse, StartRunRequest, - StatusReason as ApiStatusReason, SubmitAnswerRequest, SystemInfoResponse, SystemRunCounts, - WriteBlobResponse, + CreateSecretRequest, DeleteSecretRequest, DiskUsageResponse, DiskUsageRunRow, + DiskUsageSummaryRow, EventEnvelope as ApiEventEnvelope, ModelReference, PaginatedEventList, + PaginatedRunList, PaginationMeta, PreflightResponse, PreviewUrlRequest, PreviewUrlResponse, + PruneRunEntry, PruneRunsRequest, PruneRunsResponse, QuestionType as ApiQuestionType, + RenderWorkflowGraphDirection, RenderWorkflowGraphFormat, RenderWorkflowGraphRequest, + RunArtifactEntry, RunArtifactListResponse, RunBilling, RunBillingStage, RunBillingTotals, + RunControlAction as ApiRunControlAction, RunError, RunManifest, RunStatus, RunStatusResponse, + SandboxFileEntry, SandboxFileListResponse, SecretType as ApiSecretType, ServerSettings, + SshAccessRequest, SshAccessResponse, StartRunRequest, StatusReason as ApiStatusReason, + SubmitAnswerRequest, SystemInfoResponse, SystemRunCounts, WriteBlobResponse, }; use fabro_config::{Storage, resolve_server_from_file}; use fabro_graphviz::render::GraphFormat; @@ -112,7 +112,7 @@ use crate::error::ApiError; use crate::jwt_auth::{ AuthMode, AuthenticatedService, AuthenticatedSubject, authenticate_service_parts, }; -use crate::secret_store::{SecretStore, SecretStoreError}; +use crate::secret_store::{SecretStore, SecretStoreError, SecretType as StoreSecretType}; use crate::{demo, diagnostics, run_manifest, settings_view, static_files, web_auth}; pub fn default_page_limit() -> u32 { @@ -1027,10 +1027,11 @@ fn demo_routes() -> Router> { .route("/insights/history", get(demo::list_query_history)) .route("/models", get(list_models)) .route("/models/{id}/test", post(test_model)) - .route("/secrets", get(demo::list_secrets)) .route( - "/secrets/{name}", - put(demo::set_secret).delete(demo::delete_secret), + "/secrets", + get(demo::list_secrets) + .post(demo::create_secret) + .delete(demo::delete_secret_by_name), ) .route("/repos/github/{owner}/{name}", get(demo::get_github_repo)) .route("/health/diagnostics", post(demo::run_diagnostics)) @@ -1106,8 +1107,12 @@ fn real_routes() -> Router> { .route("/insights/history", get(not_implemented)) .route("/models", get(list_models)) .route("/models/{id}/test", post(test_model)) - .route("/secrets", get(list_secrets)) - .route("/secrets/{name}", put(set_secret).delete(delete_secret)) + .route( + "/secrets", + get(list_secrets) + .post(create_secret) + .delete(delete_secret_by_name), + ) .route("/repos/github/{owner}/{name}", get(get_github_repo)) .route("/health/diagnostics", post(run_diagnostics)) .route("/completions", post(create_completion)) @@ -1621,16 +1626,26 @@ async fn list_secrets(_auth: AuthenticatedService, State(state): State StoreSecretType { + match secret_type { + ApiSecretType::Environment => StoreSecretType::Environment, + ApiSecretType::File => StoreSecretType::File, + } +} + +async fn create_secret( _auth: AuthenticatedService, State(state): State>, - Path(name): Path, - Json(body): Json, + Json(body): Json, ) -> Response { + let secret_type = secret_type_from_api(body.type_); + let name = body.name; + let value = body.value; + let description = body.description; let state_for_write = Arc::clone(&state); let result = spawn_blocking(move || { let mut store = state_for_write.secret_store.blocking_write(); - store.set(&name, &body.value) + store.set(&name, &value, secret_type, description.as_deref()) }) .await; @@ -1658,11 +1673,12 @@ async fn set_secret( } } -async fn delete_secret( +async fn delete_secret_by_name( _auth: AuthenticatedService, State(state): State>, - Path(name): Path, + Json(body): Json, ) -> Response { + let name = body.name; let state_for_write = Arc::clone(&state); let result = spawn_blocking(move || { let mut store = state_for_write.secret_store.blocking_write(); @@ -6199,6 +6215,80 @@ mod tests { format!("/api/v1{path}") } + #[tokio::test] + async fn create_secret_stores_file_secret_and_excludes_it_from_snapshot() { + let state = create_app_state(); + let app = build_router(Arc::clone(&state), AuthMode::Disabled); + + 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": "/tmp/test.pem", + "value": "pem-data", + "type": "file", + "description": "Test certificate", + })) + .unwrap(), + )) + .unwrap(); + + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let body = body_json(response.into_body()).await; + assert_eq!(body["name"], "/tmp/test.pem"); + assert_eq!(body["type"], "file"); + assert_eq!(body["description"], "Test certificate"); + + let store = state.secret_store.read().await; + assert!(!store.snapshot().contains_key("/tmp/test.pem")); + assert_eq!(store.file_secrets(), vec![( + "/tmp/test.pem".to_string(), + "pem-data".to_string() + )]); + } + + #[tokio::test] + async fn delete_secret_by_name_removes_file_secret() { + let state = create_app_state(); + let app = build_router(Arc::clone(&state), AuthMode::Disabled); + + let create_req = Request::builder() + .method("POST") + .uri(api("/secrets")) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_string(&serde_json::json!({ + "name": "/tmp/test.pem", + "value": "pem-data", + "type": "file", + })) + .unwrap(), + )) + .unwrap(); + let create_response = app.clone().oneshot(create_req).await.unwrap(); + assert_eq!(create_response.status(), StatusCode::OK); + + let delete_req = Request::builder() + .method("DELETE") + .uri(api("/secrets")) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_string(&serde_json::json!({ + "name": "/tmp/test.pem", + })) + .unwrap(), + )) + .unwrap(); + + let delete_response = app.oneshot(delete_req).await.unwrap(); + assert_eq!(delete_response.status(), StatusCode::NO_CONTENT); + assert!(state.secret_store.read().await.list().is_empty()); + } + #[tokio::test] async fn subprocess_answer_transport_cancel_run_enqueues_cancel_message() { let (control_tx, mut control_rx) = tokio::sync::mpsc::channel(1); diff --git a/lib/crates/fabro-server/src/web_auth.rs b/lib/crates/fabro-server/src/web_auth.rs index 470b073a8..1d53f1433 100644 --- a/lib/crates/fabro-server/src/web_auth.rs +++ b/lib/crates/fabro-server/src/web_auth.rs @@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use tracing::{debug, error, info, warn}; +use crate::secret_store::SecretType; use crate::server::AppState; fn github_http_client(context: &str) -> Result { @@ -653,7 +654,7 @@ async fn setup_register( { let mut store = state.secret_store.write().await; for (name, value) in secret_updates { - if let Err(err) = store.set(name, &value) { + if let Err(err) = store.set(name, &value, SecretType::Environment, None) { error!(error = %err, secret = name, "Setup register failed: could not save secret"); return json_response( StatusCode::INTERNAL_SERVER_ERROR, diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 7d9b4f276..9d0a8e976 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -42,6 +42,8 @@ models/completion-tool-choice.ts models/completion-tool-definition.ts models/completion-usage.ts models/create-completion-request.ts +models/create-secret-request.ts +models/delete-secret-request.ts models/diagnostics-check.ts models/diagnostics-detail.ts models/diagnostics-report.ts @@ -142,7 +144,7 @@ models/save-query-request.ts models/saved-query.ts models/secret-list-response.ts models/secret-metadata.ts -models/set-secret-request.ts +models/secret-type.ts models/ssh-access-request.ts models/ssh-access-response.ts models/stage-status.ts diff --git a/lib/packages/fabro-api-client/src/api/secrets-api.ts b/lib/packages/fabro-api-client/src/api/secrets-api.ts index 7513f8277..c5c7a3b21 100644 --- a/lib/packages/fabro-api-client/src/api/secrets-api.ts +++ b/lib/packages/fabro-api-client/src/api/secrets-api.ts @@ -22,13 +22,15 @@ import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObj // @ts-ignore import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base'; // @ts-ignore +import type { CreateSecretRequest } from '../models'; +// @ts-ignore +import type { DeleteSecretRequest } from '../models'; +// @ts-ignore import type { ErrorResponse } from '../models'; // @ts-ignore import type { SecretListResponse } from '../models'; // @ts-ignore import type { SecretMetadata } from '../models'; -// @ts-ignore -import type { SetSecretRequest } from '../models'; /** * SecretsApi - axios parameter creator */ @@ -36,16 +38,57 @@ export const SecretsApiAxiosParamCreator = function (configuration?: Configurati return { /** * - * @summary Delete a stored secret - * @param {string} name + * @summary Store or update a secret + * @param {CreateSecretRequest} createSecretRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ - deleteSecret: async (name: string, options: RawAxiosRequestConfig = {}): Promise => { - // verify required parameter 'name' is not null or undefined - assertParamExists('deleteSecret', 'name', name) - const localVarPath = `/api/v1/secrets/{name}` - .replace(`{${"name"}}`, encodeURIComponent(String(name))); + createSecret: async (createSecretRequest: CreateSecretRequest, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'createSecretRequest' is not null or undefined + assertParamExists('createSecret', 'createSecretRequest', createSecretRequest) + const localVarPath = `/api/v1/secrets`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication mTLS required + await setApiKeyToObject(localVarHeaderParameter, "X-mTLS-Client-CN", configuration) + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Content-Type'] = 'application/json'; + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(createSecretRequest, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Delete a stored secret + * @param {DeleteSecretRequest} deleteSecretRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteSecretByName: async (deleteSecretRequest: DeleteSecretRequest, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'deleteSecretRequest' is not null or undefined + assertParamExists('deleteSecretByName', 'deleteSecretRequest', deleteSecretRequest) + const localVarPath = `/api/v1/secrets`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); let baseOptions; @@ -64,11 +107,13 @@ export const SecretsApiAxiosParamCreator = function (configuration?: Configurati // http bearer authentication required await setBearerAuthToObject(localVarHeaderParameter, configuration) + localVarHeaderParameter['Content-Type'] = 'application/json'; localVarHeaderParameter['Accept'] = 'application/json'; setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(deleteSecretRequest, localVarRequestOptions, configuration) return { url: toPathString(localVarUrlObj), @@ -107,52 +152,6 @@ export const SecretsApiAxiosParamCreator = function (configuration?: Configurati let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * - * @summary Store or update a secret - * @param {string} name - * @param {SetSecretRequest} setSecretRequest - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - setSecret: async (name: string, setSecretRequest: SetSecretRequest, options: RawAxiosRequestConfig = {}): Promise => { - // verify required parameter 'name' is not null or undefined - assertParamExists('setSecret', 'name', name) - // verify required parameter 'setSecretRequest' is not null or undefined - assertParamExists('setSecret', 'setSecretRequest', setSecretRequest) - const localVarPath = `/api/v1/secrets/{name}` - .replace(`{${"name"}}`, encodeURIComponent(String(name))); - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication mTLS required - await setApiKeyToObject(localVarHeaderParameter, "X-mTLS-Client-CN", configuration) - - // authentication BearerAuth required - // http bearer authentication required - await setBearerAuthToObject(localVarHeaderParameter, configuration) - - localVarHeaderParameter['Content-Type'] = 'application/json'; - localVarHeaderParameter['Accept'] = 'application/json'; - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(setSecretRequest, localVarRequestOptions, configuration) - return { url: toPathString(localVarUrlObj), options: localVarRequestOptions, @@ -169,15 +168,28 @@ export const SecretsApiFp = function(configuration?: Configuration) { return { /** * - * @summary Delete a stored secret - * @param {string} name + * @summary Store or update a secret + * @param {CreateSecretRequest} createSecretRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async deleteSecret(name: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.deleteSecret(name, options); + async createSecret(createSecretRequest: CreateSecretRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.createSecret(createSecretRequest, options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; - const localVarOperationServerBasePath = operationServerMap['SecretsApi.deleteSecret']?.[localVarOperationServerIndex]?.url; + const localVarOperationServerBasePath = operationServerMap['SecretsApi.createSecret']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * + * @summary Delete a stored secret + * @param {DeleteSecretRequest} deleteSecretRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async deleteSecretByName(deleteSecretRequest: DeleteSecretRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.deleteSecretByName(deleteSecretRequest, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['SecretsApi.deleteSecretByName']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, /** @@ -192,20 +204,6 @@ export const SecretsApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['SecretsApi.listSecrets']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, - /** - * - * @summary Store or update a secret - * @param {string} name - * @param {SetSecretRequest} setSecretRequest - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async setSecret(name: string, setSecretRequest: SetSecretRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.setSecret(name, setSecretRequest, options); - const localVarOperationServerIndex = configuration?.serverIndex ?? 0; - const localVarOperationServerBasePath = operationServerMap['SecretsApi.setSecret']?.[localVarOperationServerIndex]?.url; - return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); - }, } }; @@ -217,13 +215,23 @@ export const SecretsApiFactory = function (configuration?: Configuration, basePa return { /** * - * @summary Delete a stored secret - * @param {string} name + * @summary Store or update a secret + * @param {CreateSecretRequest} createSecretRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ - deleteSecret(name: string, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.deleteSecret(name, options).then((request) => request(axios, basePath)); + createSecret(createSecretRequest: CreateSecretRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.createSecret(createSecretRequest, options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary Delete a stored secret + * @param {DeleteSecretRequest} deleteSecretRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteSecretByName(deleteSecretRequest: DeleteSecretRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.deleteSecretByName(deleteSecretRequest, options).then((request) => request(axios, basePath)); }, /** * Returns stored secret names and timestamps. Secret values are never exposed. @@ -234,17 +242,6 @@ export const SecretsApiFactory = function (configuration?: Configuration, basePa listSecrets(options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.listSecrets(options).then((request) => request(axios, basePath)); }, - /** - * - * @summary Store or update a secret - * @param {string} name - * @param {SetSecretRequest} setSecretRequest - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - setSecret(name: string, setSecretRequest: SetSecretRequest, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.setSecret(name, setSecretRequest, options).then((request) => request(axios, basePath)); - }, }; }; @@ -254,13 +251,24 @@ export const SecretsApiFactory = function (configuration?: Configuration, basePa export class SecretsApi extends BaseAPI { /** * - * @summary Delete a stored secret - * @param {string} name + * @summary Store or update a secret + * @param {CreateSecretRequest} createSecretRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ - public deleteSecret(name: string, options?: RawAxiosRequestConfig) { - return SecretsApiFp(this.configuration).deleteSecret(name, options).then((request) => request(this.axios, this.basePath)); + public createSecret(createSecretRequest: CreateSecretRequest, options?: RawAxiosRequestConfig) { + return SecretsApiFp(this.configuration).createSecret(createSecretRequest, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary Delete a stored secret + * @param {DeleteSecretRequest} deleteSecretRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public deleteSecretByName(deleteSecretRequest: DeleteSecretRequest, options?: RawAxiosRequestConfig) { + return SecretsApiFp(this.configuration).deleteSecretByName(deleteSecretRequest, options).then((request) => request(this.axios, this.basePath)); } /** @@ -272,17 +280,5 @@ export class SecretsApi extends BaseAPI { public listSecrets(options?: RawAxiosRequestConfig) { return SecretsApiFp(this.configuration).listSecrets(options).then((request) => request(this.axios, this.basePath)); } - - /** - * - * @summary Store or update a secret - * @param {string} name - * @param {SetSecretRequest} setSecretRequest - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - public setSecret(name: string, setSecretRequest: SetSecretRequest, options?: RawAxiosRequestConfig) { - return SecretsApiFp(this.configuration).setSecret(name, setSecretRequest, options).then((request) => request(this.axios, this.basePath)); - } } diff --git a/lib/packages/fabro-api-client/src/models/create-secret-request.ts b/lib/packages/fabro-api-client/src/models/create-secret-request.ts new file mode 100644 index 000000000..d338df5b6 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/create-secret-request.ts @@ -0,0 +1,40 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { SecretType } from './secret-type'; + +/** + * Request to store or update a secret. + */ +export interface CreateSecretRequest { + /** + * Secret name or destination path for file secrets. + */ + 'name': string; + /** + * The secret value to store. + */ + 'value': string; + 'type': SecretType; + /** + * Optional operator-facing description of the secret. + */ + 'description'?: string; +} + + + diff --git a/lib/packages/fabro-api-client/src/models/delete-secret-request.ts b/lib/packages/fabro-api-client/src/models/delete-secret-request.ts new file mode 100644 index 000000000..8f9d68211 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/delete-secret-request.ts @@ -0,0 +1,26 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * Request to delete a secret by name. + */ +export interface DeleteSecretRequest { + /** + * Secret name or destination path for file secrets. + */ + 'name': string; +} + diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index eade91d3c..dfcd3f3ae 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -24,6 +24,8 @@ export * from './completion-tool-choice'; export * from './completion-tool-definition'; export * from './completion-usage'; export * from './create-completion-request'; +export * from './create-secret-request'; +export * from './delete-secret-request'; export * from './diagnostics-check'; export * from './diagnostics-detail'; export * from './diagnostics-report'; @@ -123,7 +125,7 @@ export * from './save-query-request'; export * from './saved-query'; export * from './secret-list-response'; export * from './secret-metadata'; -export * from './set-secret-request'; +export * from './secret-type'; export * from './ssh-access-request'; export * from './ssh-access-response'; export * from './stage-status'; diff --git a/lib/packages/fabro-api-client/src/models/secret-metadata.ts b/lib/packages/fabro-api-client/src/models/secret-metadata.ts index 55be6bf6e..404fba427 100644 --- a/lib/packages/fabro-api-client/src/models/secret-metadata.ts +++ b/lib/packages/fabro-api-client/src/models/secret-metadata.ts @@ -13,15 +13,23 @@ */ +// May contain unused imports in some cases +// @ts-ignore +import type { SecretType } from './secret-type'; /** * Metadata for a stored secret (value is never exposed). */ export interface SecretMetadata { /** - * Secret key name. + * Secret key name or destination path. */ 'name': string; + 'type': SecretType; + /** + * Optional operator-facing description of the secret. + */ + 'description'?: string; /** * When the secret was first stored. */ @@ -32,3 +40,5 @@ export interface SecretMetadata { 'updated_at': string; } + + diff --git a/lib/packages/fabro-api-client/src/models/secret-type.ts b/lib/packages/fabro-api-client/src/models/secret-type.ts new file mode 100644 index 000000000..caa89fdf4 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/secret-type.ts @@ -0,0 +1,29 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * The way a secret is consumed by the sandbox. + */ + +export const SecretType = { + ENVIRONMENT: 'environment', + FILE: 'file' +} as const; + +export type SecretType = typeof SecretType[keyof typeof SecretType]; + + +