[wip] cli capability parity
Some checks failed
CI / Rustfmt (push) Successful in 23s
CI / Cargo Audit & Deny (push) Successful in 30s
CI / Web Blocking Checks (push) Successful in 48s
CI / Security Blocking Checks (push) Successful in 8s
CI / Clippy (push) Failing after 1m55s
CI / Web Advisory Checks (push) Successful in 35s
CI / Security Advisory Checks (push) Successful in 37s
CI / Tests (push) Successful in 8m5s
Some checks failed
CI / Rustfmt (push) Successful in 23s
CI / Cargo Audit & Deny (push) Successful in 30s
CI / Web Blocking Checks (push) Successful in 48s
CI / Security Blocking Checks (push) Successful in 8s
CI / Clippy (push) Failing after 1m55s
CI / Web Advisory Checks (push) Successful in 35s
CI / Security Advisory Checks (push) Successful in 37s
CI / Tests (push) Successful in 8m5s
This commit is contained in:
@@ -30,9 +30,7 @@ hostname = "0.4"
|
||||
regex = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
aes-gcm = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
jsonwebtoken = { workspace = true }
|
||||
libc = "0.2"
|
||||
|
||||
@@ -23,6 +23,9 @@ struct Args {
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Install HMAC-only JWT crypto provider (must be before any token operations)
|
||||
attune_common::auth::install_crypto_provider();
|
||||
|
||||
// Initialize tracing
|
||||
tracing_subscriber::fmt()
|
||||
.with_target(false)
|
||||
|
||||
@@ -105,8 +105,9 @@ pub struct ExecutionContext {
|
||||
/// Environment variables
|
||||
pub env: HashMap<String, String>,
|
||||
|
||||
/// Secrets (passed securely via stdin, not environment variables)
|
||||
pub secrets: HashMap<String, String>,
|
||||
/// Secrets (passed securely via stdin, not environment variables).
|
||||
/// Values are JSON — strings, objects, arrays, numbers, or booleans.
|
||||
pub secrets: HashMap<String, serde_json::Value>,
|
||||
|
||||
/// Execution timeout in seconds
|
||||
pub timeout: Option<u64>,
|
||||
|
||||
@@ -39,7 +39,7 @@ impl NativeRuntime {
|
||||
async fn execute_binary(
|
||||
&self,
|
||||
binary_path: PathBuf,
|
||||
secrets: &std::collections::HashMap<String, String>,
|
||||
_secrets: &std::collections::HashMap<String, serde_json::Value>,
|
||||
env: &std::collections::HashMap<String, String>,
|
||||
parameters_stdin: Option<&str>,
|
||||
timeout: Option<u64>,
|
||||
@@ -94,31 +94,17 @@ impl NativeRuntime {
|
||||
.spawn()
|
||||
.map_err(|e| RuntimeError::ExecutionFailed(format!("Failed to spawn binary: {}", e)))?;
|
||||
|
||||
// Write to stdin - parameters (if using stdin delivery) and/or secrets
|
||||
// If this fails, the process has already started, so we continue and capture output
|
||||
// Write parameters to stdin as a single JSON line.
|
||||
// Secrets are merged into the parameters map by the caller, so the
|
||||
// action reads everything with a single readline().
|
||||
let stdin_write_error = if let Some(mut stdin) = child.stdin.take() {
|
||||
let mut error = None;
|
||||
|
||||
// Write parameters first if using stdin delivery
|
||||
if let Some(params_data) = parameters_stdin {
|
||||
if let Err(e) = stdin.write_all(params_data.as_bytes()).await {
|
||||
error = Some(format!("Failed to write parameters to stdin: {}", e));
|
||||
} else if let Err(e) = stdin.write_all(b"\n---ATTUNE_PARAMS_END---\n").await {
|
||||
error = Some(format!("Failed to write parameter delimiter: {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
// Write secrets as JSON (always, for backward compatibility)
|
||||
if error.is_none() && !secrets.is_empty() {
|
||||
match serde_json::to_string(secrets) {
|
||||
Ok(secrets_json) => {
|
||||
if let Err(e) = stdin.write_all(secrets_json.as_bytes()).await {
|
||||
error = Some(format!("Failed to write secrets to stdin: {}", e));
|
||||
} else if let Err(e) = stdin.write_all(b"\n").await {
|
||||
error = Some(format!("Failed to write newline to stdin: {}", e));
|
||||
}
|
||||
}
|
||||
Err(e) => error = Some(format!("Failed to serialize secrets: {}", e)),
|
||||
} else if let Err(e) = stdin.write_all(b"\n").await {
|
||||
error = Some(format!("Failed to write newline to stdin: {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,6 +317,15 @@ impl Runtime for NativeRuntime {
|
||||
context.action_ref, context.execution_id, context.parameter_delivery, context.parameter_format
|
||||
);
|
||||
|
||||
// Merge secrets into parameters as a single JSON document.
|
||||
// Actions receive everything via one readline() on stdin.
|
||||
// Secret values are already JsonValue (string, object, array, etc.)
|
||||
// so they are inserted directly without wrapping.
|
||||
let mut merged_parameters = context.parameters.clone();
|
||||
for (key, value) in &context.secrets {
|
||||
merged_parameters.insert(key.clone(), value.clone());
|
||||
}
|
||||
|
||||
// Prepare environment and parameters according to delivery method
|
||||
let mut env = context.env.clone();
|
||||
let config = ParameterDeliveryConfig {
|
||||
@@ -339,7 +334,7 @@ impl Runtime for NativeRuntime {
|
||||
};
|
||||
|
||||
let prepared_params =
|
||||
parameter_passing::prepare_parameters(&context.parameters, &mut env, config)?;
|
||||
parameter_passing::prepare_parameters(&merged_parameters, &mut env, config)?;
|
||||
|
||||
// Get stdin content if parameters are delivered via stdin
|
||||
let parameters_stdin = prepared_params.stdin_content();
|
||||
@@ -351,7 +346,7 @@ impl Runtime for NativeRuntime {
|
||||
|
||||
self.execute_binary(
|
||||
binary_path,
|
||||
&context.secrets,
|
||||
&std::collections::HashMap::new(),
|
||||
&env,
|
||||
parameters_stdin,
|
||||
context.timeout,
|
||||
|
||||
@@ -20,6 +20,7 @@ use super::{
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use attune_common::models::runtime::{EnvironmentConfig, RuntimeExecutionConfig};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::process::Command;
|
||||
use tracing::{debug, error, info, warn};
|
||||
@@ -645,12 +646,21 @@ impl Runtime for ProcessRuntime {
|
||||
env.insert(key.clone(), resolved);
|
||||
}
|
||||
}
|
||||
// Merge secrets into parameters as a single JSON document.
|
||||
// Actions receive everything via one readline() on stdin.
|
||||
// Secret values are already JsonValue (string, object, array, etc.)
|
||||
// so they are inserted directly without wrapping.
|
||||
let mut merged_parameters = context.parameters.clone();
|
||||
for (key, value) in &context.secrets {
|
||||
merged_parameters.insert(key.clone(), value.clone());
|
||||
}
|
||||
|
||||
let param_config = ParameterDeliveryConfig {
|
||||
delivery: context.parameter_delivery,
|
||||
format: context.parameter_format,
|
||||
};
|
||||
let prepared_params =
|
||||
parameter_passing::prepare_parameters(&context.parameters, &mut env, param_config)?;
|
||||
parameter_passing::prepare_parameters(&merged_parameters, &mut env, param_config)?;
|
||||
let parameters_stdin = prepared_params.stdin_content();
|
||||
|
||||
// Determine working directory: use context override, or pack dir
|
||||
@@ -725,10 +735,11 @@ impl Runtime for ProcessRuntime {
|
||||
.unwrap_or_else(|| "<none>".to_string()),
|
||||
);
|
||||
|
||||
// Execute with streaming output capture (with optional cancellation support)
|
||||
// Execute with streaming output capture (with optional cancellation support).
|
||||
// Secrets are already merged into parameters — no separate secrets arg needed.
|
||||
process_executor::execute_streaming_cancellable(
|
||||
cmd,
|
||||
&context.secrets,
|
||||
&HashMap::new(),
|
||||
parameters_stdin,
|
||||
context.timeout,
|
||||
context.max_stdout_bytes,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Provides common subprocess execution infrastructure used by all runtime
|
||||
//! implementations. Handles streaming stdout/stderr capture, bounded log
|
||||
//! collection, timeout management, stdin parameter/secret delivery, and
|
||||
//! collection, timeout management, stdin parameter delivery, and
|
||||
//! output format parsing.
|
||||
//!
|
||||
//! ## Cancellation Support
|
||||
@@ -28,22 +28,22 @@ use tracing::{debug, info, warn};
|
||||
/// This is the core execution function used by all runtime implementations.
|
||||
/// It handles:
|
||||
/// - Spawning the process with piped I/O
|
||||
/// - Writing parameters and secrets to stdin
|
||||
/// - Writing parameters (with secrets merged in) to stdin
|
||||
/// - Streaming stdout/stderr with bounded log collection
|
||||
/// - Timeout management
|
||||
/// - Output format parsing (JSON, YAML, JSONL, text)
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `cmd` - Pre-configured `Command` (interpreter, args, env vars, working dir already set)
|
||||
/// * `secrets` - Secrets to pass via stdin (as JSON)
|
||||
/// * `parameters_stdin` - Optional parameter data to write to stdin before secrets
|
||||
/// * `secrets` - Deprecated/unused — secrets are now merged into parameters by the caller
|
||||
/// * `parameters_stdin` - Optional parameter data (including secrets) to write to stdin
|
||||
/// * `timeout_secs` - Optional execution timeout in seconds
|
||||
/// * `max_stdout_bytes` - Maximum stdout size before truncation
|
||||
/// * `max_stderr_bytes` - Maximum stderr size before truncation
|
||||
/// * `output_format` - How to parse stdout (Text, Json, Yaml, Jsonl)
|
||||
pub async fn execute_streaming(
|
||||
cmd: Command,
|
||||
secrets: &HashMap<String, String>,
|
||||
_secrets: &HashMap<String, serde_json::Value>,
|
||||
parameters_stdin: Option<&str>,
|
||||
timeout_secs: Option<u64>,
|
||||
max_stdout_bytes: usize,
|
||||
@@ -52,7 +52,7 @@ pub async fn execute_streaming(
|
||||
) -> RuntimeResult<ExecutionResult> {
|
||||
execute_streaming_cancellable(
|
||||
cmd,
|
||||
secrets,
|
||||
_secrets,
|
||||
parameters_stdin,
|
||||
timeout_secs,
|
||||
max_stdout_bytes,
|
||||
@@ -68,7 +68,7 @@ pub async fn execute_streaming(
|
||||
/// This is the core execution function used by all runtime implementations.
|
||||
/// It handles:
|
||||
/// - Spawning the process with piped I/O
|
||||
/// - Writing parameters and secrets to stdin
|
||||
/// - Writing parameters (with secrets merged in) to stdin
|
||||
/// - Streaming stdout/stderr with bounded log collection
|
||||
/// - Timeout management
|
||||
/// - Graceful cancellation via SIGINT → SIGTERM → SIGKILL escalation
|
||||
@@ -76,8 +76,8 @@ pub async fn execute_streaming(
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `cmd` - Pre-configured `Command` (interpreter, args, env vars, working dir already set)
|
||||
/// * `secrets` - Secrets to pass via stdin (as JSON)
|
||||
/// * `parameters_stdin` - Optional parameter data to write to stdin before secrets
|
||||
/// * `secrets` - Deprecated/unused — secrets are now merged into parameters by the caller
|
||||
/// * `parameters_stdin` - Optional parameter data (including secrets) to write to stdin
|
||||
/// * `timeout_secs` - Optional execution timeout in seconds
|
||||
/// * `max_stdout_bytes` - Maximum stdout size before truncation
|
||||
/// * `max_stderr_bytes` - Maximum stderr size before truncation
|
||||
@@ -86,7 +86,7 @@ pub async fn execute_streaming(
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn execute_streaming_cancellable(
|
||||
mut cmd: Command,
|
||||
secrets: &HashMap<String, String>,
|
||||
_secrets: &HashMap<String, serde_json::Value>,
|
||||
parameters_stdin: Option<&str>,
|
||||
timeout_secs: Option<u64>,
|
||||
max_stdout_bytes: usize,
|
||||
@@ -103,34 +103,19 @@ pub async fn execute_streaming_cancellable(
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()?;
|
||||
|
||||
// Write to stdin - parameters (if using stdin delivery) and/or secrets.
|
||||
// Write to stdin - parameters (with secrets already merged in by the caller).
|
||||
// If this fails, the process has already started, so we continue and capture output.
|
||||
let stdin_write_error = if let Some(mut stdin) = child.stdin.take() {
|
||||
let mut error = None;
|
||||
|
||||
// Write parameters first if using stdin delivery.
|
||||
// When the caller provides parameters_stdin (i.e. the action uses
|
||||
// stdin delivery), always write the content — even if it's "{}" —
|
||||
// because the script expects to read valid JSON from stdin.
|
||||
// Write parameters to stdin as a single JSON line.
|
||||
// Secrets are merged into the parameters map by the caller, so the
|
||||
// action reads everything with a single readline().
|
||||
if let Some(params_data) = parameters_stdin {
|
||||
if let Err(e) = stdin.write_all(params_data.as_bytes()).await {
|
||||
error = Some(format!("Failed to write parameters to stdin: {}", e));
|
||||
} else if let Err(e) = stdin.write_all(b"\n---ATTUNE_PARAMS_END---\n").await {
|
||||
error = Some(format!("Failed to write parameter delimiter: {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
// Write secrets as JSON (always, for backward compatibility)
|
||||
if error.is_none() && !secrets.is_empty() {
|
||||
match serde_json::to_string(secrets) {
|
||||
Ok(secrets_json) => {
|
||||
if let Err(e) = stdin.write_all(secrets_json.as_bytes()).await {
|
||||
error = Some(format!("Failed to write secrets to stdin: {}", e));
|
||||
} else if let Err(e) = stdin.write_all(b"\n").await {
|
||||
error = Some(format!("Failed to write newline to stdin: {}", e));
|
||||
}
|
||||
}
|
||||
Err(e) => error = Some(format!("Failed to serialize secrets: {}", e)),
|
||||
} else if let Err(e) = stdin.write_all(b"\n").await {
|
||||
error = Some(format!("Failed to write newline to stdin: {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ impl ShellRuntime {
|
||||
async fn execute_with_streaming(
|
||||
&self,
|
||||
mut cmd: Command,
|
||||
secrets: &std::collections::HashMap<String, String>,
|
||||
_secrets: &std::collections::HashMap<String, String>,
|
||||
parameters_stdin: Option<&str>,
|
||||
timeout_secs: Option<u64>,
|
||||
max_stdout_bytes: usize,
|
||||
@@ -81,39 +81,19 @@ impl ShellRuntime {
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
|
||||
// Write to stdin - parameters (if using stdin delivery) and/or secrets
|
||||
// If this fails, the process has already started, so we continue and capture output
|
||||
// Write to stdin - parameters (with secrets already merged in by the caller).
|
||||
// If this fails, the process has already started, so we continue and capture output.
|
||||
let stdin_write_error = if let Some(mut stdin) = child.stdin.take() {
|
||||
let mut error = None;
|
||||
|
||||
// Write parameters first if using stdin delivery.
|
||||
// Skip empty/trivial content ("{}","","[]") to avoid polluting stdin
|
||||
// before secrets — scripts that read secrets via readline() expect
|
||||
// the secrets JSON as the first line.
|
||||
let has_real_params = parameters_stdin
|
||||
.map(|s| !matches!(s.trim(), "" | "{}" | "[]"))
|
||||
.unwrap_or(false);
|
||||
// Write parameters to stdin as a single JSON line.
|
||||
// Secrets are merged into the parameters map by the caller, so the
|
||||
// action reads everything with a single readline().
|
||||
if let Some(params_data) = parameters_stdin {
|
||||
if has_real_params {
|
||||
if let Err(e) = stdin.write_all(params_data.as_bytes()).await {
|
||||
error = Some(format!("Failed to write parameters to stdin: {}", e));
|
||||
} else if let Err(e) = stdin.write_all(b"\n---ATTUNE_PARAMS_END---\n").await {
|
||||
error = Some(format!("Failed to write parameter delimiter: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write secrets as JSON (always, for backward compatibility)
|
||||
if error.is_none() && !secrets.is_empty() {
|
||||
match serde_json::to_string(secrets) {
|
||||
Ok(secrets_json) => {
|
||||
if let Err(e) = stdin.write_all(secrets_json.as_bytes()).await {
|
||||
error = Some(format!("Failed to write secrets to stdin: {}", e));
|
||||
} else if let Err(e) = stdin.write_all(b"\n").await {
|
||||
error = Some(format!("Failed to write newline to stdin: {}", e));
|
||||
}
|
||||
}
|
||||
Err(e) => error = Some(format!("Failed to serialize secrets: {}", e)),
|
||||
if let Err(e) = stdin.write_all(params_data.as_bytes()).await {
|
||||
error = Some(format!("Failed to write parameters to stdin: {}", e));
|
||||
} else if let Err(e) = stdin.write_all(b"\n").await {
|
||||
error = Some(format!("Failed to write newline to stdin: {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,7 +318,12 @@ impl ShellRuntime {
|
||||
script.push_str("declare -A ATTUNE_SECRETS\n");
|
||||
for (key, value) in &context.secrets {
|
||||
let escaped_key = bash_single_quote_escape(key);
|
||||
let escaped_val = bash_single_quote_escape(value);
|
||||
// Serialize structured JSON values to string for bash; plain strings used directly.
|
||||
let val_str = match value {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
let escaped_val = bash_single_quote_escape(&val_str);
|
||||
script.push_str(&format!(
|
||||
"ATTUNE_SECRETS['{}']='{}'\n",
|
||||
escaped_key, escaped_val
|
||||
@@ -388,7 +373,7 @@ impl ShellRuntime {
|
||||
async fn execute_shell_file(
|
||||
&self,
|
||||
script_path: PathBuf,
|
||||
secrets: &std::collections::HashMap<String, String>,
|
||||
_secrets: &std::collections::HashMap<String, String>,
|
||||
env: &std::collections::HashMap<String, String>,
|
||||
parameters_stdin: Option<&str>,
|
||||
timeout_secs: Option<u64>,
|
||||
@@ -396,11 +381,7 @@ impl ShellRuntime {
|
||||
max_stderr_bytes: usize,
|
||||
output_format: OutputFormat,
|
||||
) -> RuntimeResult<ExecutionResult> {
|
||||
debug!(
|
||||
"Executing shell file: {:?} with {} secrets",
|
||||
script_path,
|
||||
secrets.len()
|
||||
);
|
||||
debug!("Executing shell file: {:?}", script_path,);
|
||||
|
||||
// Build command
|
||||
let mut cmd = Command::new(&self.shell_path);
|
||||
@@ -413,7 +394,7 @@ impl ShellRuntime {
|
||||
|
||||
self.execute_with_streaming(
|
||||
cmd,
|
||||
secrets,
|
||||
&std::collections::HashMap::new(),
|
||||
parameters_stdin,
|
||||
timeout_secs,
|
||||
max_stdout_bytes,
|
||||
@@ -463,6 +444,13 @@ impl Runtime for ShellRuntime {
|
||||
context.parameters
|
||||
);
|
||||
|
||||
// Merge secrets into parameters as a single JSON document.
|
||||
// Actions receive everything via one readline() on stdin.
|
||||
let mut merged_parameters = context.parameters.clone();
|
||||
for (key, value) in &context.secrets {
|
||||
merged_parameters.insert(key.clone(), value.clone());
|
||||
}
|
||||
|
||||
// Prepare environment and parameters according to delivery method
|
||||
let mut env = context.env.clone();
|
||||
let config = ParameterDeliveryConfig {
|
||||
@@ -471,7 +459,7 @@ impl Runtime for ShellRuntime {
|
||||
};
|
||||
|
||||
let prepared_params =
|
||||
parameter_passing::prepare_parameters(&context.parameters, &mut env, config)?;
|
||||
parameter_passing::prepare_parameters(&merged_parameters, &mut env, config)?;
|
||||
|
||||
// Get stdin content if parameters are delivered via stdin
|
||||
let parameters_stdin = prepared_params.stdin_content();
|
||||
@@ -486,12 +474,13 @@ impl Runtime for ShellRuntime {
|
||||
info!("No parameters will be sent via stdin");
|
||||
}
|
||||
|
||||
// If code_path is provided, execute the file directly
|
||||
// If code_path is provided, execute the file directly.
|
||||
// Secrets are already merged into parameters — no separate secrets arg needed.
|
||||
if let Some(code_path) = &context.code_path {
|
||||
return self
|
||||
.execute_shell_file(
|
||||
code_path.clone(),
|
||||
&context.secrets,
|
||||
&HashMap::new(),
|
||||
&env,
|
||||
parameters_stdin,
|
||||
context.timeout,
|
||||
@@ -747,8 +736,11 @@ mod tests {
|
||||
env: HashMap::new(),
|
||||
secrets: {
|
||||
let mut s = HashMap::new();
|
||||
s.insert("api_key".to_string(), "secret_key_12345".to_string());
|
||||
s.insert("db_password".to_string(), "super_secret_pass".to_string());
|
||||
s.insert("api_key".to_string(), serde_json::json!("secret_key_12345"));
|
||||
s.insert(
|
||||
"db_password".to_string(),
|
||||
serde_json::json!("super_secret_pass"),
|
||||
);
|
||||
s
|
||||
},
|
||||
timeout: Some(10),
|
||||
|
||||
@@ -2,31 +2,42 @@
|
||||
//!
|
||||
//! Handles fetching, decrypting, and injecting secrets into execution environments.
|
||||
//! Secrets are stored encrypted in the database and decrypted on-demand for execution.
|
||||
//!
|
||||
//! Key values are stored as JSONB — they can be plain strings, objects, arrays,
|
||||
//! numbers, or booleans. When encrypted, the JSON value is serialised to a
|
||||
//! compact string, encrypted, and stored as a JSON string. Decryption reverses
|
||||
//! this process, recovering the original structured value.
|
||||
//!
|
||||
//! Encryption and decryption use the shared `attune_common::crypto` module
|
||||
//! (`encrypt_json` / `decrypt_json`) which stores ciphertext in the format
|
||||
//! `BASE64(nonce ++ ciphertext)`. This is the same format used by the API
|
||||
//! service, so keys encrypted by the API can be decrypted by the worker and
|
||||
//! vice versa.
|
||||
|
||||
use aes_gcm::{
|
||||
aead::{Aead, AeadCore, KeyInit, OsRng},
|
||||
Aes256Gcm, Key as AesKey, Nonce,
|
||||
};
|
||||
use attune_common::error::{Error, Result};
|
||||
use attune_common::models::{key::Key, Action, OwnerType};
|
||||
use attune_common::repositories::key::KeyRepository;
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
||||
use sha2::{Digest, Sha256};
|
||||
use serde_json::Value as JsonValue;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Secret manager for handling secret operations
|
||||
/// Secret manager for handling secret operations.
|
||||
///
|
||||
/// Holds the database connection pool and the raw encryption key string.
|
||||
/// The encryption key is passed through to `attune_common::crypto` which
|
||||
/// derives the AES-256 key internally via SHA-256.
|
||||
pub struct SecretManager {
|
||||
pool: PgPool,
|
||||
encryption_key: Option<Vec<u8>>,
|
||||
encryption_key: Option<String>,
|
||||
}
|
||||
|
||||
impl SecretManager {
|
||||
/// Create a new secret manager
|
||||
/// Create a new secret manager.
|
||||
///
|
||||
/// `encryption_key` is the raw key string (≥ 32 characters) used for
|
||||
/// AES-256-GCM encryption/decryption via `attune_common::crypto`.
|
||||
pub fn new(pool: PgPool, encryption_key: Option<String>) -> Result<Self> {
|
||||
let encryption_key = encryption_key.map(|key| Self::derive_key(&key));
|
||||
|
||||
if encryption_key.is_none() {
|
||||
warn!("No encryption key configured - encrypted secrets will fail to decrypt");
|
||||
}
|
||||
@@ -37,14 +48,7 @@ impl SecretManager {
|
||||
})
|
||||
}
|
||||
|
||||
/// Derive encryption key from password/key string
|
||||
fn derive_key(key: &str) -> Vec<u8> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(key.as_bytes());
|
||||
hasher.finalize().to_vec()
|
||||
}
|
||||
|
||||
/// Fetch all secrets relevant to an action execution
|
||||
/// Fetch all secrets relevant to an action execution.
|
||||
///
|
||||
/// Secrets are fetched in order of precedence:
|
||||
/// 1. System-level secrets (owner_type='system')
|
||||
@@ -52,10 +56,12 @@ impl SecretManager {
|
||||
/// 3. Action-level secrets (owner_type='action')
|
||||
///
|
||||
/// More specific secrets override less specific ones with the same name.
|
||||
/// Values are returned as [`JsonValue`] — they may be strings, objects,
|
||||
/// arrays, numbers, or booleans.
|
||||
pub async fn fetch_secrets_for_action(
|
||||
&self,
|
||||
action: &Action,
|
||||
) -> Result<HashMap<String, String>> {
|
||||
) -> Result<HashMap<String, JsonValue>> {
|
||||
debug!("Fetching secrets for action: {}", action.r#ref);
|
||||
|
||||
let mut secrets = HashMap::new();
|
||||
@@ -126,13 +132,17 @@ impl SecretManager {
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Decrypt a secret if it's encrypted, otherwise return the value as-is
|
||||
fn decrypt_if_needed(&self, key: &Key) -> Result<String> {
|
||||
/// Decrypt a secret if it's encrypted, otherwise return the value as-is.
|
||||
///
|
||||
/// For unencrypted keys the JSONB value is returned directly.
|
||||
/// For encrypted keys the value (a JSON string containing base64 ciphertext)
|
||||
/// is decrypted via `attune_common::crypto::decrypt_json` and parsed back
|
||||
/// into the original [`JsonValue`].
|
||||
fn decrypt_if_needed(&self, key: &Key) -> Result<JsonValue> {
|
||||
if !key.encrypted {
|
||||
return Ok(key.value.clone());
|
||||
}
|
||||
|
||||
// Encrypted secret requires encryption key
|
||||
let encryption_key = self
|
||||
.encryption_key
|
||||
.as_ref()
|
||||
@@ -140,7 +150,7 @@ impl SecretManager {
|
||||
|
||||
// Verify encryption key hash if present
|
||||
if let Some(expected_hash) = &key.encryption_key_hash {
|
||||
let actual_hash = Self::compute_key_hash_from_bytes(encryption_key);
|
||||
let actual_hash = attune_common::crypto::hash_encryption_key(encryption_key);
|
||||
if &actual_hash != expected_hash {
|
||||
return Err(Error::Internal(format!(
|
||||
"Encryption key hash mismatch for secret '{}'",
|
||||
@@ -149,100 +159,23 @@ impl SecretManager {
|
||||
}
|
||||
}
|
||||
|
||||
Self::decrypt_value(&key.value, encryption_key)
|
||||
attune_common::crypto::decrypt_json(&key.value, encryption_key)
|
||||
.map_err(|e| Error::Internal(format!("Failed to decrypt key '{}': {}", key.name, e)))
|
||||
}
|
||||
|
||||
/// Decrypt an encrypted value
|
||||
/// Compute hash of the encryption key.
|
||||
///
|
||||
/// Format: "nonce:ciphertext" (both base64-encoded)
|
||||
fn decrypt_value(encrypted_value: &str, key: &[u8]) -> Result<String> {
|
||||
// Parse format: "nonce:ciphertext"
|
||||
let parts: Vec<&str> = encrypted_value.split(':').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(Error::Internal(
|
||||
"Invalid encrypted value format. Expected 'nonce:ciphertext'".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let nonce_bytes = BASE64
|
||||
.decode(parts[0])
|
||||
.map_err(|e| Error::Internal(format!("Failed to decode nonce: {}", e)))?;
|
||||
|
||||
let ciphertext = BASE64
|
||||
.decode(parts[1])
|
||||
.map_err(|e| Error::Internal(format!("Failed to decode ciphertext: {}", e)))?;
|
||||
|
||||
// Create cipher
|
||||
let key_array: [u8; 32] = key
|
||||
.try_into()
|
||||
.map_err(|_| Error::Internal("Invalid key length".to_string()))?;
|
||||
let cipher_key = AesKey::<Aes256Gcm>::from_slice(&key_array);
|
||||
let cipher = Aes256Gcm::new(cipher_key);
|
||||
|
||||
// Create nonce
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
// Decrypt
|
||||
let plaintext = cipher
|
||||
.decrypt(nonce, ciphertext.as_ref())
|
||||
.map_err(|e| Error::Internal(format!("Decryption failed: {}", e)))?;
|
||||
|
||||
String::from_utf8(plaintext)
|
||||
.map_err(|e| Error::Internal(format!("Invalid UTF-8 in decrypted value: {}", e)))
|
||||
}
|
||||
|
||||
/// Encrypt a value (for testing and future use)
|
||||
#[allow(dead_code)]
|
||||
pub fn encrypt_value(&self, plaintext: &str) -> Result<String> {
|
||||
let encryption_key = self
|
||||
.encryption_key
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::Internal("No encryption key configured".to_string()))?;
|
||||
|
||||
Self::encrypt_value_with_key(plaintext, encryption_key)
|
||||
}
|
||||
|
||||
/// Encrypt a value with a specific key (static method)
|
||||
fn encrypt_value_with_key(plaintext: &str, encryption_key: &[u8]) -> Result<String> {
|
||||
// Create cipher
|
||||
let key_array: [u8; 32] = encryption_key
|
||||
.try_into()
|
||||
.map_err(|_| Error::Internal("Invalid key length".to_string()))?;
|
||||
let cipher_key = AesKey::<Aes256Gcm>::from_slice(&key_array);
|
||||
let cipher = Aes256Gcm::new(cipher_key);
|
||||
|
||||
// Generate random nonce
|
||||
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
||||
|
||||
// Encrypt
|
||||
let ciphertext = cipher
|
||||
.encrypt(&nonce, plaintext.as_bytes())
|
||||
.map_err(|e| Error::Internal(format!("Encryption failed: {}", e)))?;
|
||||
|
||||
// Format: "nonce:ciphertext" (both base64-encoded)
|
||||
let nonce_b64 = BASE64.encode(nonce);
|
||||
let ciphertext_b64 = BASE64.encode(&ciphertext);
|
||||
|
||||
Ok(format!("{}:{}", nonce_b64, ciphertext_b64))
|
||||
}
|
||||
|
||||
/// Compute hash of the encryption key
|
||||
/// Uses the shared `attune_common::crypto::hash_encryption_key` so the
|
||||
/// hash format is consistent with values stored by the API.
|
||||
pub fn compute_key_hash(&self) -> String {
|
||||
if let Some(key) = &self.encryption_key {
|
||||
Self::compute_key_hash_from_bytes(key)
|
||||
attune_common::crypto::hash_encryption_key(key)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute hash from key bytes (static method)
|
||||
fn compute_key_hash_from_bytes(key: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(key);
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
/// Prepare secrets as environment variables
|
||||
/// Prepare secrets as environment variables.
|
||||
///
|
||||
/// **DEPRECATED - SECURITY VULNERABILITY**: This method exposes secrets in the process
|
||||
/// environment, making them visible in process listings (`ps auxe`) and `/proc/[pid]/environ`.
|
||||
@@ -252,16 +185,26 @@ impl SecretManager {
|
||||
///
|
||||
/// Secret names are converted to uppercase and prefixed with "SECRET_"
|
||||
/// Example: "api_key" becomes "SECRET_API_KEY"
|
||||
///
|
||||
/// String values are used directly; structured values are serialised to
|
||||
/// compact JSON.
|
||||
#[deprecated(
|
||||
since = "0.2.0",
|
||||
note = "Secrets in environment variables are insecure. Pass secrets via stdin instead."
|
||||
)]
|
||||
pub fn prepare_secret_env(&self, secrets: &HashMap<String, String>) -> HashMap<String, String> {
|
||||
pub fn prepare_secret_env(
|
||||
&self,
|
||||
secrets: &HashMap<String, JsonValue>,
|
||||
) -> HashMap<String, String> {
|
||||
secrets
|
||||
.iter()
|
||||
.map(|(name, value)| {
|
||||
let env_name = format!("SECRET_{}", name.to_uppercase().replace('-', "_"));
|
||||
(env_name, value.clone())
|
||||
let env_value = match value {
|
||||
JsonValue::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
(env_name, env_value)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -270,78 +213,79 @@ impl SecretManager {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use attune_common::crypto;
|
||||
|
||||
// Helper to derive a test encryption key
|
||||
fn derive_test_key(key: &str) -> Vec<u8> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(key.as_bytes());
|
||||
hasher.finalize().to_vec()
|
||||
// ── encrypt / decrypt round-trip using shared crypto ───────────
|
||||
|
||||
const TEST_KEY: &str = "this_is_a_test_key_that_is_32_chars_long!!!!";
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_roundtrip_string() {
|
||||
let value = serde_json::json!("my-secret-value");
|
||||
let encrypted = crypto::encrypt_json(&value, TEST_KEY).unwrap();
|
||||
let decrypted = crypto::decrypt_json(&encrypted, TEST_KEY).unwrap();
|
||||
assert_eq!(value, decrypted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_roundtrip() {
|
||||
let key = derive_test_key("test-encryption-key-12345");
|
||||
let plaintext = "my-secret-value";
|
||||
let encrypted = SecretManager::encrypt_value_with_key(plaintext, &key).unwrap();
|
||||
|
||||
// Verify format
|
||||
assert!(encrypted.contains(':'));
|
||||
let parts: Vec<&str> = encrypted.split(':').collect();
|
||||
assert_eq!(parts.len(), 2);
|
||||
|
||||
// Decrypt and verify
|
||||
let decrypted = SecretManager::decrypt_value(&encrypted, &key).unwrap();
|
||||
assert_eq!(decrypted, plaintext);
|
||||
fn test_encrypt_decrypt_roundtrip_object() {
|
||||
let value = serde_json::json!({"user": "admin", "password": "s3cret"});
|
||||
let encrypted = crypto::encrypt_json(&value, TEST_KEY).unwrap();
|
||||
let decrypted = crypto::decrypt_json(&encrypted, TEST_KEY).unwrap();
|
||||
assert_eq!(value, decrypted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_different_values() {
|
||||
let key = derive_test_key("test-encryption-key-12345");
|
||||
fn test_encrypt_produces_different_ciphertext() {
|
||||
let value = serde_json::json!("my-secret-value");
|
||||
let encrypted1 = crypto::encrypt_json(&value, TEST_KEY).unwrap();
|
||||
let encrypted2 = crypto::encrypt_json(&value, TEST_KEY).unwrap();
|
||||
|
||||
let plaintext1 = "secret1";
|
||||
let plaintext2 = "secret2";
|
||||
|
||||
let encrypted1 = SecretManager::encrypt_value_with_key(plaintext1, &key).unwrap();
|
||||
let encrypted2 = SecretManager::encrypt_value_with_key(plaintext2, &key).unwrap();
|
||||
|
||||
// Encrypted values should be different (due to random nonces)
|
||||
// Different ciphertexts due to random nonces
|
||||
assert_ne!(encrypted1, encrypted2);
|
||||
|
||||
// Both should decrypt correctly
|
||||
let decrypted1 = SecretManager::decrypt_value(&encrypted1, &key).unwrap();
|
||||
let decrypted2 = SecretManager::decrypt_value(&encrypted2, &key).unwrap();
|
||||
|
||||
assert_eq!(decrypted1, plaintext1);
|
||||
assert_eq!(decrypted2, plaintext2);
|
||||
// Both decrypt to the same value
|
||||
assert_eq!(crypto::decrypt_json(&encrypted1, TEST_KEY).unwrap(), value);
|
||||
assert_eq!(crypto::decrypt_json(&encrypted2, TEST_KEY).unwrap(), value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_with_wrong_key() {
|
||||
let key1 = derive_test_key("key1");
|
||||
let key2 = derive_test_key("key2");
|
||||
fn test_decrypt_with_wrong_key_fails() {
|
||||
let value = serde_json::json!("secret");
|
||||
let encrypted = crypto::encrypt_json(&value, TEST_KEY).unwrap();
|
||||
|
||||
let plaintext = "secret";
|
||||
let encrypted = SecretManager::encrypt_value_with_key(plaintext, &key1).unwrap();
|
||||
|
||||
// Decrypting with wrong key should fail
|
||||
let result = SecretManager::decrypt_value(&encrypted, &key2);
|
||||
assert!(result.is_err());
|
||||
let wrong_key = "wrong_key_that_is_also_32_chars_long!!!";
|
||||
assert!(crypto::decrypt_json(&encrypted, wrong_key).is_err());
|
||||
}
|
||||
|
||||
// ── prepare_secret_env ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_prepare_secret_env() {
|
||||
// Test the static method directly without creating a SecretManager instance
|
||||
let mut secrets = HashMap::new();
|
||||
secrets.insert("api_key".to_string(), "secret123".to_string());
|
||||
secrets.insert("db-password".to_string(), "pass456".to_string());
|
||||
secrets.insert("oauth_token".to_string(), "token789".to_string());
|
||||
let mut secrets: HashMap<String, JsonValue> = HashMap::new();
|
||||
secrets.insert(
|
||||
"api_key".to_string(),
|
||||
JsonValue::String("secret123".to_string()),
|
||||
);
|
||||
secrets.insert(
|
||||
"db-password".to_string(),
|
||||
JsonValue::String("pass456".to_string()),
|
||||
);
|
||||
secrets.insert(
|
||||
"oauth_token".to_string(),
|
||||
JsonValue::String("token789".to_string()),
|
||||
);
|
||||
|
||||
// Call prepare_secret_env as a static-like method
|
||||
// Replicate the logic without constructing a full SecretManager
|
||||
let env: HashMap<String, String> = secrets
|
||||
.iter()
|
||||
.map(|(name, value)| {
|
||||
let env_name = format!("SECRET_{}", name.to_uppercase().replace('-', "_"));
|
||||
(env_name, value.clone())
|
||||
let env_value = match value {
|
||||
JsonValue::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
(env_name, env_value)
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -352,35 +296,47 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_key_hash() {
|
||||
let key1 = derive_test_key("test-key");
|
||||
let key2 = derive_test_key("test-key");
|
||||
let key3 = derive_test_key("different-key");
|
||||
fn test_prepare_secret_env_structured_value() {
|
||||
let mut secrets: HashMap<String, JsonValue> = HashMap::new();
|
||||
secrets.insert(
|
||||
"db_config".to_string(),
|
||||
serde_json::json!({"host": "db.example.com", "port": 5432}),
|
||||
);
|
||||
|
||||
let hash1 = SecretManager::compute_key_hash_from_bytes(&key1);
|
||||
let hash2 = SecretManager::compute_key_hash_from_bytes(&key2);
|
||||
let hash3 = SecretManager::compute_key_hash_from_bytes(&key3);
|
||||
let env: HashMap<String, String> = secrets
|
||||
.iter()
|
||||
.map(|(name, value)| {
|
||||
let env_name = format!("SECRET_{}", name.to_uppercase().replace('-', "_"));
|
||||
let env_value = match value {
|
||||
JsonValue::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
(env_name, env_value)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Same key should produce same hash
|
||||
// Structured values should be serialised to compact JSON
|
||||
let db_config = env.get("SECRET_DB_CONFIG").unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(db_config).unwrap();
|
||||
assert_eq!(parsed["host"], "db.example.com");
|
||||
assert_eq!(parsed["port"], 5432);
|
||||
}
|
||||
|
||||
// ── compute_key_hash ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_compute_key_hash_consistent() {
|
||||
let hash1 = crypto::hash_encryption_key(TEST_KEY);
|
||||
let hash2 = crypto::hash_encryption_key(TEST_KEY);
|
||||
assert_eq!(hash1, hash2);
|
||||
// Different key should produce different hash
|
||||
assert_ne!(hash1, hash3);
|
||||
// Hash should not be empty
|
||||
assert!(!hash1.is_empty());
|
||||
// SHA-256 → 64 hex characters
|
||||
assert_eq!(hash1.len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_encrypted_format() {
|
||||
let key = derive_test_key("test-key");
|
||||
|
||||
// Invalid formats should fail
|
||||
let result = SecretManager::decrypt_value("no-colon", &key);
|
||||
assert!(result.is_err());
|
||||
|
||||
let result = SecretManager::decrypt_value("too:many:colons", &key);
|
||||
assert!(result.is_err());
|
||||
|
||||
let result = SecretManager::decrypt_value("invalid-base64:also-invalid", &key);
|
||||
assert!(result.is_err());
|
||||
fn test_compute_key_hash_different_keys() {
|
||||
let hash1 = crypto::hash_encryption_key(TEST_KEY);
|
||||
let hash2 = crypto::hash_encryption_key("different_key_that_is_32_chars_long!!");
|
||||
assert_ne!(hash1, hash2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,9 +66,9 @@ print(json.dumps(result))
|
||||
let mut s = HashMap::new();
|
||||
s.insert(
|
||||
"api_key".to_string(),
|
||||
"super_secret_key_do_not_expose".to_string(),
|
||||
serde_json::json!("super_secret_key_do_not_expose"),
|
||||
);
|
||||
s.insert("password".to_string(), "secret_pass_123".to_string());
|
||||
s.insert("password".to_string(), serde_json::json!("secret_pass_123"));
|
||||
s
|
||||
},
|
||||
timeout: Some(10),
|
||||
@@ -125,9 +125,9 @@ async fn test_shell_secrets_not_in_environ() {
|
||||
let mut s = HashMap::new();
|
||||
s.insert(
|
||||
"api_key".to_string(),
|
||||
"super_secret_key_do_not_expose".to_string(),
|
||||
serde_json::json!("super_secret_key_do_not_expose"),
|
||||
);
|
||||
s.insert("password".to_string(), "secret_pass_123".to_string());
|
||||
s.insert("password".to_string(), serde_json::json!("secret_pass_123"));
|
||||
s
|
||||
},
|
||||
timeout: Some(10),
|
||||
@@ -227,7 +227,7 @@ print(json.dumps({'secret_a': secrets.get('secret_a')}))
|
||||
env: HashMap::new(),
|
||||
secrets: {
|
||||
let mut s = HashMap::new();
|
||||
s.insert("secret_a".to_string(), "value_a".to_string());
|
||||
s.insert("secret_a".to_string(), serde_json::json!("value_a"));
|
||||
s
|
||||
},
|
||||
timeout: Some(10),
|
||||
@@ -273,7 +273,7 @@ print(json.dumps({
|
||||
env: HashMap::new(),
|
||||
secrets: {
|
||||
let mut s = HashMap::new();
|
||||
s.insert("secret_b".to_string(), "value_b".to_string());
|
||||
s.insert("secret_b".to_string(), serde_json::json!("value_b"));
|
||||
s
|
||||
},
|
||||
timeout: Some(10),
|
||||
@@ -458,7 +458,10 @@ echo "PASS: No secrets in environment"
|
||||
env: HashMap::new(),
|
||||
secrets: {
|
||||
let mut s = HashMap::new();
|
||||
s.insert("db_password".to_string(), "SUPER_SECRET_VALUE".to_string());
|
||||
s.insert(
|
||||
"db_password".to_string(),
|
||||
serde_json::json!("SUPER_SECRET_VALUE"),
|
||||
);
|
||||
s
|
||||
},
|
||||
timeout: Some(10),
|
||||
@@ -535,7 +538,10 @@ print(json.dumps({"leaked": leaked}))
|
||||
env: HashMap::new(),
|
||||
secrets: {
|
||||
let mut s = HashMap::new();
|
||||
s.insert("api_key".to_string(), "TOP_SECRET_API_KEY".to_string());
|
||||
s.insert(
|
||||
"api_key".to_string(),
|
||||
serde_json::json!("TOP_SECRET_API_KEY"),
|
||||
);
|
||||
s
|
||||
},
|
||||
timeout: Some(10),
|
||||
|
||||
Reference in New Issue
Block a user