Skip to main content

neuron_secret/
lib.rs

1#![deny(missing_docs)]
2//! Secret resolution for neuron.
3//!
4//! This crate defines the [`SecretResolver`] trait, the [`SecretValue`] in-memory
5//! wrapper (no Serialize, no Display, no Clone — memory zeroed on drop), and the
6//! [`SecretRegistry`] for composing multiple resolvers.
7//!
8//! ## Design
9//!
10//! - Resolvers resolve a [`SecretSource`] (from layer0), not a string name.
11//!   The name->source mapping lives in `CredentialRef`.
12//! - [`SecretValue`] uses scoped exposure (`with_bytes`) to prevent accidental leaks.
13//! - [`SecretRegistry`] dispatches by [`SecretSource`] variant, following the same
14//!   composition pattern as `ToolRegistry` and `HookRegistry`.
15
16use async_trait::async_trait;
17use layer0::secret::SecretSource;
18use std::sync::Arc;
19use std::time::SystemTime;
20use thiserror::Error;
21use zeroize::Zeroizing;
22
23/// Errors from secret resolution (crate-local, not in layer0).
24#[non_exhaustive]
25#[derive(Debug, Error)]
26pub enum SecretError {
27    /// The secret was not found in the backend.
28    #[error("secret not found: {0}")]
29    NotFound(String),
30
31    /// Access denied by policy.
32    #[error("access denied: {0}")]
33    AccessDenied(String),
34
35    /// Backend communication failure (network, timeout, etc.).
36    #[error("backend error: {0}")]
37    BackendError(String),
38
39    /// The lease has expired and cannot be renewed.
40    #[error("lease expired: {0}")]
41    LeaseExpired(String),
42
43    /// No resolver registered for this source type.
44    /// The string is the source kind tag (from `SecretSource::kind()`).
45    #[error("no resolver for source: {0}")]
46    NoResolver(String),
47
48    /// Catch-all.
49    #[error("{0}")]
50    Other(#[from] Box<dyn std::error::Error + Send + Sync>),
51}
52
53/// An opaque secret value. Cannot be logged, serialized, or cloned.
54/// Memory is zeroed on drop via [`Zeroizing`].
55///
56/// The only way to access the bytes is through [`SecretValue::with_bytes`],
57/// which enforces scoped exposure — the secret is only visible inside the closure.
58pub struct SecretValue {
59    inner: Zeroizing<Vec<u8>>,
60}
61
62impl SecretValue {
63    /// Create a new secret value. The input vector is moved, not copied.
64    pub fn new(bytes: Vec<u8>) -> Self {
65        Self {
66            inner: Zeroizing::new(bytes),
67        }
68    }
69
70    /// Scoped exposure. The secret bytes are only accessible inside the closure.
71    /// This is the ONLY way to read the value.
72    pub fn with_bytes<R>(&self, f: impl FnOnce(&[u8]) -> R) -> R {
73        f(&self.inner)
74    }
75
76    /// Returns the length of the secret in bytes.
77    pub fn len(&self) -> usize {
78        self.inner.len()
79    }
80
81    /// Returns true if the secret is empty.
82    pub fn is_empty(&self) -> bool {
83        self.inner.is_empty()
84    }
85}
86
87impl std::fmt::Debug for SecretValue {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.write_str("[REDACTED]")
90    }
91}
92
93// Intentionally: no Display, no Clone, no Serialize, no PartialEq.
94
95/// A resolved secret with optional lease information.
96///
97/// Leases allow time-bounded access to secrets. When a lease expires,
98/// the secret must be re-resolved from the backend. Renewable leases
99/// can be extended without re-authentication.
100pub struct SecretLease {
101    /// The resolved secret value.
102    pub value: SecretValue,
103    /// When this lease expires (None = no expiry).
104    pub expires_at: Option<SystemTime>,
105    /// Whether this lease can be renewed.
106    pub renewable: bool,
107    /// Opaque lease ID for renewal/revocation.
108    pub lease_id: Option<String>,
109}
110
111impl SecretLease {
112    /// Create a new lease with no expiry.
113    pub fn permanent(value: SecretValue) -> Self {
114        Self {
115            value,
116            expires_at: None,
117            renewable: false,
118            lease_id: None,
119        }
120    }
121
122    /// Create a new lease with a TTL.
123    pub fn with_ttl(value: SecretValue, ttl: std::time::Duration) -> Self {
124        Self {
125            value,
126            expires_at: Some(SystemTime::now() + ttl),
127            renewable: false,
128            lease_id: None,
129        }
130    }
131
132    /// Check if this lease has expired.
133    pub fn is_expired(&self) -> bool {
134        self.expires_at
135            .map(|exp| SystemTime::now() > exp)
136            .unwrap_or(false)
137    }
138}
139
140impl std::fmt::Debug for SecretLease {
141    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142        f.debug_struct("SecretLease")
143            .field("value", &"[REDACTED]")
144            .field("expires_at", &self.expires_at)
145            .field("renewable", &self.renewable)
146            .field("lease_id", &self.lease_id)
147            .finish()
148    }
149}
150
151/// Resolve a secret from a specific backend.
152///
153/// Implementations are backend-specific: `VaultResolver` talks to Vault,
154/// `AwsResolver` talks to AWS Secrets Manager, `KeystoreResolver` talks to
155/// the OS keychain, etc.
156///
157/// Resolvers do NOT map names to sources. That mapping lives in
158/// `CredentialRef.source`. The resolver receives the source directly
159/// and knows how to fetch from that backend.
160#[async_trait]
161pub trait SecretResolver: Send + Sync {
162    /// Resolve a secret from the given source.
163    async fn resolve(&self, source: &SecretSource) -> Result<SecretLease, SecretError>;
164}
165
166/// How to match a [`SecretSource`] variant to a resolver.
167#[derive(Debug, Clone)]
168pub enum SourceMatcher {
169    /// Match all `SecretSource::Vault` variants.
170    Vault,
171    /// Match all `SecretSource::AwsSecretsManager` variants.
172    Aws,
173    /// Match all `SecretSource::GcpSecretManager` variants.
174    Gcp,
175    /// Match all `SecretSource::AzureKeyVault` variants.
176    Azure,
177    /// Match all `SecretSource::OsKeystore` variants.
178    OsKeystore,
179    /// Match all `SecretSource::Kubernetes` variants.
180    Kubernetes,
181    /// Match all `SecretSource::Hardware` variants.
182    Hardware,
183    /// Match a specific `SecretSource::Custom` provider name.
184    Custom(String),
185}
186
187impl SourceMatcher {
188    /// Check if this matcher matches the given source.
189    pub fn matches(&self, source: &SecretSource) -> bool {
190        match (self, source) {
191            (SourceMatcher::Vault, SecretSource::Vault { .. }) => true,
192            (SourceMatcher::Aws, SecretSource::AwsSecretsManager { .. }) => true,
193            (SourceMatcher::Gcp, SecretSource::GcpSecretManager { .. }) => true,
194            (SourceMatcher::Azure, SecretSource::AzureKeyVault { .. }) => true,
195            (SourceMatcher::OsKeystore, SecretSource::OsKeystore { .. }) => true,
196            (SourceMatcher::Kubernetes, SecretSource::Kubernetes { .. }) => true,
197            (SourceMatcher::Hardware, SecretSource::Hardware { .. }) => true,
198            (SourceMatcher::Custom(name), SecretSource::Custom { provider, .. }) => {
199                name == provider
200            }
201            _ => false,
202        }
203    }
204}
205
206/// Composes multiple resolvers, routing by [`SecretSource`] variant.
207///
208/// When `resolve()` is called, the registry matches the source to a registered
209/// resolver and delegates. If no resolver matches, returns `SecretError::NoResolver`.
210///
211/// Optionally emits [`SecretAccessEvent`](layer0::secret::SecretAccessEvent)s through a [`SecretEventSink`] for audit logging.
212/// Use [`resolve_named`](SecretRegistry::resolve_named) (not the trait's `resolve()`) when you have
213/// a credential name for proper audit events.
214pub struct SecretRegistry {
215    resolvers: Vec<(SourceMatcher, Arc<dyn SecretResolver>)>,
216    event_sink: Option<Arc<dyn SecretEventSink>>,
217}
218
219impl SecretRegistry {
220    /// Create a new empty registry.
221    pub fn new() -> Self {
222        Self {
223            resolvers: Vec::new(),
224            event_sink: None,
225        }
226    }
227
228    /// Register a resolver for sources matching the given pattern.
229    pub fn with_resolver(
230        mut self,
231        matcher: SourceMatcher,
232        resolver: Arc<dyn SecretResolver>,
233    ) -> Self {
234        self.resolvers.push((matcher, resolver));
235        self
236    }
237
238    /// Set the event sink for audit logging.
239    pub fn with_event_sink(mut self, sink: Arc<dyn SecretEventSink>) -> Self {
240        self.event_sink = Some(sink);
241        self
242    }
243
244    /// Add a resolver for sources matching the given pattern.
245    pub fn add(&mut self, matcher: SourceMatcher, resolver: Arc<dyn SecretResolver>) {
246        self.resolvers.push((matcher, resolver));
247    }
248}
249
250impl Default for SecretRegistry {
251    fn default() -> Self {
252        Self::new()
253    }
254}
255
256/// Optional event sink for audit logging of secret access.
257///
258/// The SecretRegistry emits [`SecretAccessEvent`](layer0::secret::SecretAccessEvent)s through this sink.
259/// Implementations can forward to an event bus, write to audit logs,
260/// or feed anomaly detection systems.
261///
262/// If no sink is provided to SecretRegistry, events are silently dropped.
263pub trait SecretEventSink: Send + Sync {
264    /// Emit a secret access event.
265    fn emit(&self, event: layer0::secret::SecretAccessEvent);
266}
267
268impl SecretRegistry {
269    /// Resolve a secret by credential name and source, emitting an audit event.
270    ///
271    /// This is the primary entry point for Environment implementations.
272    /// Use this instead of `resolve()` when you have a `CredentialRef` --
273    /// the credential name flows into the `SecretAccessEvent` for audit logging.
274    ///
275    /// ```rust,ignore
276    /// let lease = registry.resolve_named(&cred.name, &cred.source).await?;
277    /// ```
278    pub async fn resolve_named(
279        &self,
280        credential_name: &str,
281        source: &SecretSource,
282    ) -> Result<SecretLease, SecretError> {
283        let result = self.resolve(source).await;
284        // Emit audit event if sink is configured
285        if let Some(sink) = &self.event_sink {
286            use layer0::secret::{SecretAccessEvent, SecretAccessOutcome};
287            let outcome = if result.is_ok() {
288                SecretAccessOutcome::Resolved
289            } else {
290                SecretAccessOutcome::Failed
291            };
292            let event = SecretAccessEvent::new(
293                credential_name,
294                source.clone(),
295                outcome,
296                std::time::SystemTime::now()
297                    .duration_since(std::time::UNIX_EPOCH)
298                    .unwrap_or_default()
299                    .as_millis() as u64,
300            );
301            sink.emit(event);
302        }
303        result
304    }
305}
306
307#[async_trait]
308impl SecretResolver for SecretRegistry {
309    /// Route to the matching resolver. No audit event -- use `resolve_named()`
310    /// when you have a credential name for proper audit logging.
311    async fn resolve(&self, source: &SecretSource) -> Result<SecretLease, SecretError> {
312        for (matcher, resolver) in &self.resolvers {
313            if matcher.matches(source) {
314                return resolver.resolve(source).await;
315            }
316        }
317        Err(SecretError::NoResolver(source.kind().to_string()))
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn secret_value_debug_is_redacted() {
327        let secret = SecretValue::new(b"super-secret-key".to_vec());
328        let debug = format!("{:?}", secret);
329        assert_eq!(debug, "[REDACTED]");
330        assert!(!debug.contains("super-secret"));
331    }
332
333    #[test]
334    fn secret_value_with_bytes_exposes_content() {
335        let secret = SecretValue::new(b"my-api-key".to_vec());
336        secret.with_bytes(|bytes| {
337            assert_eq!(bytes, b"my-api-key");
338        });
339    }
340
341    #[test]
342    fn secret_value_len() {
343        let secret = SecretValue::new(b"12345".to_vec());
344        assert_eq!(secret.len(), 5);
345        assert!(!secret.is_empty());
346
347        let empty = SecretValue::new(vec![]);
348        assert_eq!(empty.len(), 0);
349        assert!(empty.is_empty());
350    }
351
352    #[test]
353    fn secret_lease_permanent_never_expires() {
354        let lease = SecretLease::permanent(SecretValue::new(b"key".to_vec()));
355        assert!(!lease.is_expired());
356        assert!(lease.expires_at.is_none());
357        assert!(!lease.renewable);
358    }
359
360    #[test]
361    fn secret_lease_debug_redacts_value() {
362        let lease = SecretLease::permanent(SecretValue::new(b"secret".to_vec()));
363        let debug = format!("{:?}", lease);
364        assert!(debug.contains("[REDACTED]"));
365        assert!(!debug.contains("secret"));
366    }
367
368    #[test]
369    fn source_matcher_vault() {
370        let matcher = SourceMatcher::Vault;
371        assert!(matcher.matches(&SecretSource::Vault {
372            mount: "secret".into(),
373            path: "data/key".into(),
374        }));
375        assert!(!matcher.matches(&SecretSource::OsKeystore {
376            service: "test".into(),
377        }));
378    }
379
380    #[test]
381    fn source_matcher_custom() {
382        let matcher = SourceMatcher::Custom("1password".into());
383        assert!(matcher.matches(&SecretSource::Custom {
384            provider: "1password".into(),
385            config: serde_json::json!({}),
386        }));
387        assert!(!matcher.matches(&SecretSource::Custom {
388            provider: "bitwarden".into(),
389            config: serde_json::json!({}),
390        }));
391    }
392
393    // Object safety
394    fn _assert_send_sync<T: Send + Sync>() {}
395
396    #[test]
397    fn secret_resolver_is_object_safe_send_sync() {
398        _assert_send_sync::<Box<dyn SecretResolver>>();
399        _assert_send_sync::<Arc<dyn SecretResolver>>();
400    }
401
402    #[tokio::test]
403    async fn registry_no_resolver_returns_error() {
404        let registry = SecretRegistry::new();
405        let source = SecretSource::Vault {
406            mount: "secret".into(),
407            path: "data/key".into(),
408        };
409        let result = registry.resolve(&source).await;
410        assert!(result.is_err());
411        assert!(matches!(result.unwrap_err(), SecretError::NoResolver(_)));
412    }
413
414    // Test registry dispatches to correct resolver
415    struct StubResolver {
416        value: &'static [u8],
417    }
418
419    #[async_trait]
420    impl SecretResolver for StubResolver {
421        async fn resolve(&self, _source: &SecretSource) -> Result<SecretLease, SecretError> {
422            Ok(SecretLease::permanent(SecretValue::new(
423                self.value.to_vec(),
424            )))
425        }
426    }
427
428    #[tokio::test]
429    async fn registry_dispatches_to_matching_resolver() {
430        let registry = SecretRegistry::new()
431            .with_resolver(
432                SourceMatcher::Vault,
433                Arc::new(StubResolver {
434                    value: b"vault-secret",
435                }),
436            )
437            .with_resolver(
438                SourceMatcher::OsKeystore,
439                Arc::new(StubResolver {
440                    value: b"keystore-secret",
441                }),
442            );
443
444        let vault_source = SecretSource::Vault {
445            mount: "secret".into(),
446            path: "data/key".into(),
447        };
448        let lease = registry.resolve(&vault_source).await.unwrap();
449        lease.value.with_bytes(|b| assert_eq!(b, b"vault-secret"));
450
451        let keystore_source = SecretSource::OsKeystore {
452            service: "test".into(),
453        };
454        let lease = registry.resolve(&keystore_source).await.unwrap();
455        lease
456            .value
457            .with_bytes(|b| assert_eq!(b, b"keystore-secret"));
458    }
459
460    #[test]
461    fn secret_error_display_all_variants() {
462        assert_eq!(
463            SecretError::NotFound("api-key".into()).to_string(),
464            "secret not found: api-key"
465        );
466        assert_eq!(
467            SecretError::AccessDenied("no policy".into()).to_string(),
468            "access denied: no policy"
469        );
470        assert_eq!(
471            SecretError::BackendError("timeout".into()).to_string(),
472            "backend error: timeout"
473        );
474        assert_eq!(
475            SecretError::LeaseExpired("lease-123".into()).to_string(),
476            "lease expired: lease-123"
477        );
478        assert_eq!(
479            SecretError::NoResolver("vault".into()).to_string(),
480            "no resolver for source: vault"
481        );
482    }
483}