//! Secret Management Module //! //! 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 attune_common::error::{Error, Result}; use attune_common::models::{key::Key, Action, OwnerType}; use attune_common::repositories::key::KeyRepository; use serde_json::Value as JsonValue; use sqlx::PgPool; use std::collections::HashMap; use tracing::{debug, warn}; /// 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, } impl SecretManager { /// 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) -> Result { if encryption_key.is_none() { warn!("No encryption key configured - encrypted secrets will fail to decrypt"); } Ok(Self { pool, encryption_key, }) } /// Fetch all secrets relevant to an action execution. /// /// Secrets are fetched in order of precedence: /// 1. System-level secrets (owner_type='system') /// 2. Pack-level secrets (owner_type='pack') /// 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> { debug!("Fetching secrets for action: {}", action.r#ref); let mut secrets = HashMap::new(); // 1. Fetch system-level secrets let system_secrets = self.fetch_secrets_by_owner_type(OwnerType::System).await?; for secret in system_secrets { let value = self.decrypt_if_needed(&secret)?; secrets.insert(secret.name.clone(), value); } debug!("Loaded {} system secrets", secrets.len()); // 2. Fetch pack-level secrets let pack_secrets = self.fetch_secrets_by_pack(action.pack).await?; for secret in pack_secrets { let value = self.decrypt_if_needed(&secret)?; secrets.insert(secret.name.clone(), value); } debug!("Loaded {} pack secrets", secrets.len()); // 3. Fetch action-level secrets let action_secrets = self.fetch_secrets_by_action(action.id).await?; for secret in action_secrets { let value = self.decrypt_if_needed(&secret)?; secrets.insert(secret.name.clone(), value); } debug!("Total secrets loaded: {}", secrets.len()); Ok(secrets) } /// Fetch secrets by owner type async fn fetch_secrets_by_owner_type(&self, owner_type: OwnerType) -> Result> { KeyRepository::find_by_owner_type(&self.pool, owner_type).await } /// Fetch secrets for a specific pack async fn fetch_secrets_by_pack(&self, pack_id: i64) -> Result> { sqlx::query_as::<_, Key>( "SELECT id, ref, owner_type, owner, owner_identity, owner_pack, owner_pack_ref, owner_action, owner_action_ref, owner_sensor, owner_sensor_ref, name, encrypted, encryption_key_hash, value, created, updated FROM key WHERE owner_type = $1 AND owner_pack = $2 ORDER BY name ASC", ) .bind(OwnerType::Pack) .bind(pack_id) .fetch_all(&self.pool) .await .map_err(Into::into) } /// Fetch secrets for a specific action async fn fetch_secrets_by_action(&self, action_id: i64) -> Result> { sqlx::query_as::<_, Key>( "SELECT id, ref, owner_type, owner, owner_identity, owner_pack, owner_pack_ref, owner_action, owner_action_ref, owner_sensor, owner_sensor_ref, name, encrypted, encryption_key_hash, value, created, updated FROM key WHERE owner_type = $1 AND owner_action = $2 ORDER BY name ASC", ) .bind(OwnerType::Action) .bind(action_id) .fetch_all(&self.pool) .await .map_err(Into::into) } /// 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 { if !key.encrypted { return Ok(key.value.clone()); } let encryption_key = self .encryption_key .as_ref() .ok_or_else(|| Error::Internal("No encryption key configured".to_string()))?; // Verify encryption key hash if present if let Some(expected_hash) = &key.encryption_key_hash { 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 '{}'", key.name ))); } } attune_common::crypto::decrypt_json(&key.value, encryption_key) .map_err(|e| Error::Internal(format!("Failed to decrypt key '{}': {}", key.name, e))) } /// 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 { attune_common::crypto::hash_encryption_key(key) } else { String::new() } } /// 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`. /// /// Secrets should be passed via stdin instead. This method is kept only for backward /// compatibility and will be removed in a future version. /// /// 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, ) -> HashMap { 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() } } #[cfg(test)] mod tests { use super::*; use attune_common::crypto; // ── 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_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_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(); // Different ciphertexts due to random nonces assert_ne!(encrypted1, encrypted2); // 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_fails() { let value = serde_json::json!("secret"); let encrypted = crypto::encrypt_json(&value, TEST_KEY).unwrap(); 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() { let mut secrets: HashMap = 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()), ); // Replicate the logic without constructing a full SecretManager let env: HashMap = 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(); assert_eq!(env.get("SECRET_API_KEY"), Some(&"secret123".to_string())); assert_eq!(env.get("SECRET_DB_PASSWORD"), Some(&"pass456".to_string())); assert_eq!(env.get("SECRET_OAUTH_TOKEN"), Some(&"token789".to_string())); assert_eq!(env.len(), 3); } #[test] fn test_prepare_secret_env_structured_value() { let mut secrets: HashMap = HashMap::new(); secrets.insert( "db_config".to_string(), serde_json::json!({"host": "db.example.com", "port": 5432}), ); let env: HashMap = 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(); // 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); // SHA-256 → 64 hex characters assert_eq!(hash1.len(), 64); } #[test] 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); } }