Skip to main content

cellos_broker_env/
lib.rs

1//! [`SecretBroker`] that reads `CELLOS_SECRET_<KEY>` from the process environment.
2//!
3//! Intended for CI runners and shell-level composition where the host or CI system
4//! has already injected secrets as environment variables. For workload-identity (OIDC)
5//! flows, use a dedicated OIDC broker crate instead.
6//!
7//! # Revocation
8//!
9//! `revoke_for_cell` is a documented no-op: environment variables cannot be unset from
10//! a parent process after injection. Isolation relies on the cell model's teardown
11//! semantics (cleared subprocess env, short TTLs) rather than runtime revocation.
12//!
13//! # Correlation propagation (Tranche-1 seam-freeze G1)
14//!
15//! This broker has no upstream session of its own (env vars are stamped before
16//! the supervisor starts) and therefore returns `None` from
17//! [`SecretBroker::broker_correlation_id`]. The supervisor falls back to the
18//! operator-supplied `spec.correlation.correlationId` for cross-tool
19//! correlation in that case. Future env-style brokers that observe a CI
20//! workflow run ID (e.g. `GITHUB_RUN_ID`) MAY override `broker_correlation_id`
21//! to thread that ID into every event the supervisor emits for the cell that
22//! consumed the resolved secret.
23
24use async_trait::async_trait;
25use cellos_core::ports::SecretBroker;
26use cellos_core::{CellosError, SecretView};
27
28/// Resolves secrets from environment variables using the pattern `CELLOS_SECRET_<UPPER_KEY>`.
29///
30/// # Example
31/// ```no_run
32/// // Set in environment: CELLOS_SECRET_GITHUB_TOKEN=ghp_...
33/// // Resolve with key: "GITHUB_TOKEN"
34/// ```
35pub struct EnvSecretBroker;
36
37impl EnvSecretBroker {
38    pub fn new() -> Self {
39        Self
40    }
41
42    fn env_var_name(key: &str) -> String {
43        format!("CELLOS_SECRET_{}", key.to_uppercase().replace('-', "_"))
44    }
45
46    /// Reject keys that would cause [`std::env::var`] to panic (NUL byte or
47    /// ASCII `=`). The result `CELLOS_SECRET_<key>` is never empty, so the
48    /// "empty key" panic case in `std::env::var` is unreachable here, but
49    /// NUL and `=` would propagate from the operator-supplied `key` into
50    /// the env-var name and crash the supervisor. Convert to a typed
51    /// `CellosError::SecretBroker` instead.
52    ///
53    /// Note: the error message intentionally references only the key
54    /// **name** the operator supplied, never any resolved value.
55    fn validate_key(key: &str) -> Result<(), CellosError> {
56        if key.is_empty() {
57            return Err(CellosError::SecretBroker(
58                "secret key must not be empty".into(),
59            ));
60        }
61        if key.as_bytes().contains(&0) {
62            return Err(CellosError::SecretBroker(
63                "secret key must not contain NUL byte".into(),
64            ));
65        }
66        if key.contains('=') {
67            return Err(CellosError::SecretBroker(
68                "secret key must not contain '=' character".into(),
69            ));
70        }
71        Ok(())
72    }
73}
74
75impl Default for EnvSecretBroker {
76    fn default() -> Self {
77        Self::new()
78    }
79}
80
81#[async_trait]
82impl SecretBroker for EnvSecretBroker {
83    async fn resolve(
84        &self,
85        key: &str,
86        _cell_id: &str,
87        _ttl_seconds: u64,
88    ) -> Result<SecretView, CellosError> {
89        Self::validate_key(key)?;
90        let var = Self::env_var_name(key);
91        let value = std::env::var(&var).map_err(|_| {
92            CellosError::SecretBroker(format!(
93                "env var {var} not set (required for secret key {key:?})"
94            ))
95        })?;
96        Ok(SecretView {
97            key: key.to_string(),
98            value: zeroize::Zeroizing::new(value),
99        })
100    }
101
102    /// No-op — environment variables cannot be revoked at runtime.
103    /// Isolation relies on cleared subprocess env and TTL-bound cell lifetime.
104    async fn revoke_for_cell(&self, _cell_id: &str) -> Result<(), CellosError> {
105        Ok(())
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[tokio::test]
114    async fn resolves_present_key() {
115        std::env::set_var("CELLOS_SECRET_TEST_TOKEN", "secret-value");
116        let broker = EnvSecretBroker::new();
117        let view = broker.resolve("TEST_TOKEN", "cell-1", 60).await.unwrap();
118        assert_eq!(view.key, "TEST_TOKEN");
119        assert_eq!(view.value.as_str(), "secret-value");
120    }
121
122    #[tokio::test]
123    async fn errors_on_missing_key() {
124        std::env::remove_var("CELLOS_SECRET_MISSING_KEY_XYZ");
125        let broker = EnvSecretBroker::new();
126        let err = broker
127            .resolve("MISSING_KEY_XYZ", "cell-1", 60)
128            .await
129            .unwrap_err();
130        assert!(err.to_string().contains("CELLOS_SECRET_MISSING_KEY_XYZ"));
131    }
132
133    #[tokio::test]
134    async fn revoke_is_noop() {
135        let broker = EnvSecretBroker::new();
136        broker.revoke_for_cell("any-cell").await.unwrap();
137    }
138
139    #[tokio::test]
140    async fn normalizes_hyphenated_key() {
141        std::env::set_var("CELLOS_SECRET_MY_API_KEY", "tok");
142        let broker = EnvSecretBroker::new();
143        let view = broker.resolve("my-api-key", "cell-1", 60).await.unwrap();
144        assert_eq!(view.value.as_str(), "tok");
145    }
146}