cellos-broker-env 0.5.0

Environment-variable SecretBroker for CellOS — resolves spec secretRefs from process env. Dev/CI default.
Documentation
//! [`SecretBroker`] that reads `CELLOS_SECRET_<KEY>` from the process environment.
//!
//! Intended for CI runners and shell-level composition where the host or CI system
//! has already injected secrets as environment variables. For workload-identity (OIDC)
//! flows, use a dedicated OIDC broker crate instead.
//!
//! # Revocation
//!
//! `revoke_for_cell` is a documented no-op: environment variables cannot be unset from
//! a parent process after injection. Isolation relies on the cell model's teardown
//! semantics (cleared subprocess env, short TTLs) rather than runtime revocation.
//!
//! # Correlation propagation (Tranche-1 seam-freeze G1)
//!
//! This broker has no upstream session of its own (env vars are stamped before
//! the supervisor starts) and therefore returns `None` from
//! [`SecretBroker::broker_correlation_id`]. The supervisor falls back to the
//! operator-supplied `spec.correlation.correlationId` for cross-tool
//! correlation in that case. Future env-style brokers that observe a CI
//! workflow run ID (e.g. `GITHUB_RUN_ID`) MAY override `broker_correlation_id`
//! to thread that ID into every event the supervisor emits for the cell that
//! consumed the resolved secret.

use async_trait::async_trait;
use cellos_core::ports::SecretBroker;
use cellos_core::{CellosError, SecretView};

/// Resolves secrets from environment variables using the pattern `CELLOS_SECRET_<UPPER_KEY>`.
///
/// # Example
/// ```no_run
/// // Set in environment: CELLOS_SECRET_GITHUB_TOKEN=ghp_...
/// // Resolve with key: "GITHUB_TOKEN"
/// ```
pub struct EnvSecretBroker;

impl EnvSecretBroker {
    pub fn new() -> Self {
        Self
    }

    fn env_var_name(key: &str) -> String {
        format!("CELLOS_SECRET_{}", key.to_uppercase().replace('-', "_"))
    }

    /// Reject keys that would cause [`std::env::var`] to panic (NUL byte or
    /// ASCII `=`). The result `CELLOS_SECRET_<key>` is never empty, so the
    /// "empty key" panic case in `std::env::var` is unreachable here, but
    /// NUL and `=` would propagate from the operator-supplied `key` into
    /// the env-var name and crash the supervisor. Convert to a typed
    /// `CellosError::SecretBroker` instead.
    ///
    /// Note: the error message intentionally references only the key
    /// **name** the operator supplied, never any resolved value.
    fn validate_key(key: &str) -> Result<(), CellosError> {
        if key.is_empty() {
            return Err(CellosError::SecretBroker(
                "secret key must not be empty".into(),
            ));
        }
        if key.as_bytes().contains(&0) {
            return Err(CellosError::SecretBroker(
                "secret key must not contain NUL byte".into(),
            ));
        }
        if key.contains('=') {
            return Err(CellosError::SecretBroker(
                "secret key must not contain '=' character".into(),
            ));
        }
        Ok(())
    }
}

impl Default for EnvSecretBroker {
    fn default() -> Self {
        Self::new()
    }
}

#[async_trait]
impl SecretBroker for EnvSecretBroker {
    async fn resolve(
        &self,
        key: &str,
        _cell_id: &str,
        _ttl_seconds: u64,
    ) -> Result<SecretView, CellosError> {
        Self::validate_key(key)?;
        let var = Self::env_var_name(key);
        let value = std::env::var(&var).map_err(|_| {
            CellosError::SecretBroker(format!(
                "env var {var} not set (required for secret key {key:?})"
            ))
        })?;
        Ok(SecretView {
            key: key.to_string(),
            value: zeroize::Zeroizing::new(value),
        })
    }

    /// No-op — environment variables cannot be revoked at runtime.
    /// Isolation relies on cleared subprocess env and TTL-bound cell lifetime.
    async fn revoke_for_cell(&self, _cell_id: &str) -> Result<(), CellosError> {
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn resolves_present_key() {
        std::env::set_var("CELLOS_SECRET_TEST_TOKEN", "secret-value");
        let broker = EnvSecretBroker::new();
        let view = broker.resolve("TEST_TOKEN", "cell-1", 60).await.unwrap();
        assert_eq!(view.key, "TEST_TOKEN");
        assert_eq!(view.value.as_str(), "secret-value");
    }

    #[tokio::test]
    async fn errors_on_missing_key() {
        std::env::remove_var("CELLOS_SECRET_MISSING_KEY_XYZ");
        let broker = EnvSecretBroker::new();
        let err = broker
            .resolve("MISSING_KEY_XYZ", "cell-1", 60)
            .await
            .unwrap_err();
        assert!(err.to_string().contains("CELLOS_SECRET_MISSING_KEY_XYZ"));
    }

    #[tokio::test]
    async fn revoke_is_noop() {
        let broker = EnvSecretBroker::new();
        broker.revoke_for_cell("any-cell").await.unwrap();
    }

    #[tokio::test]
    async fn normalizes_hyphenated_key() {
        std::env::set_var("CELLOS_SECRET_MY_API_KEY", "tok");
        let broker = EnvSecretBroker::new();
        let view = broker.resolve("my-api-key", "cell-1", 60).await.unwrap();
        assert_eq!(view.value.as_str(), "tok");
    }
}