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}