yeti_types/secrets.rs
1//! Pluggable secrets backend interface — ADR-014 §5.
2//!
3//! The host owns the secrets interface; customers reference secrets by name
4//! (`vault://prod/tls/kafka`, `file://kafka-prod`). The secrets backend
5//! resolves references to opaque handles consumed by `transport.tls`.
6
7/// Zeroizing secret string — debug output is redacted.
8#[derive(Clone)]
9pub struct SecretString(String);
10
11impl SecretString {
12 /// Create from a plain string value.
13 #[must_use]
14 pub fn new(value: impl Into<String>) -> Self {
15 Self(value.into())
16 }
17
18 /// Read the secret value (use minimally; zero the variable afterward).
19 #[must_use]
20 pub fn expose(&self) -> &str {
21 &self.0
22 }
23}
24
25impl std::fmt::Debug for SecretString {
26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27 write!(f, "SecretString([REDACTED])")
28 }
29}
30
31// TODO(YTC-430): use the `zeroize` crate to clear secret bytes on drop instead
32// of relying on Rust's default dealloc (which may leave copies in freed pages).
33// Blocked on adding `zeroize = { workspace = true }` to the dep tree.
34// Standard Drop for String is used until then — safe for a dev secrets backend,
35// but should be hardened before production secrets handling lands.
36
37/// Rotation notification emitted when a secret is rotated.
38#[derive(Debug, Clone)]
39pub struct RotationEvent {
40 /// Secret name.
41 pub name: String,
42 /// Tenant scope (empty = global).
43 pub tenant: String,
44 /// Unix timestamp of the rotation.
45 pub rotated_at: u64,
46}
47
48/// Opaque TLS material handle — consumed by `transport.tls::connect_tls`.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
50pub struct TlsHandle(pub u64);
51
52/// Opaque credential handle — consumed by auth providers.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
54pub struct CredentialHandle(pub u64);
55
56/// Pluggable secrets backend trait — ADR-014 §5.
57///
58/// Implemented by `plugin-secrets-vault` and `plugin-secrets-file`.
59/// The host registers one backend at startup; it resolves all secret
60/// references throughout the deployment.
61///
62/// `#[async_trait]` is used to make the trait dyn-compatible.
63#[async_trait::async_trait]
64pub trait SecretsBackend: Send + Sync + 'static {
65 /// Resolve a named TLS profile to an opaque handle.
66 /// `name` uses scheme-routing: `vault://prod/tls/kafka`, `file://kafka-prod`.
67 async fn resolve_tls_profile(
68 &self,
69 name: &str,
70 tenant: &str,
71 ) -> crate::error::Result<TlsHandle>;
72
73 /// Resolve a named credential.
74 async fn get_credential(
75 &self,
76 name: &str,
77 tenant: &str,
78 ) -> crate::error::Result<CredentialHandle>;
79
80 /// Read a named secret as a zeroizing string.
81 async fn get_string(&self, name: &str, tenant: &str) -> crate::error::Result<SecretString>;
82
83 /// Subscribe to rotation events. Implementations that don't support
84 /// rotation return an empty stream.
85 fn subscribe(
86 &self,
87 name: &str,
88 tenant: &str,
89 ) -> std::pin::Pin<Box<dyn futures::Stream<Item = RotationEvent> + Send>>;
90}
91
92/// No-op secrets backend used when no real backend is configured.
93///
94/// Returns `Err("no secrets backend configured")` for every call.
95/// Suitable for deployments that don't use raw transport or managed credentials.
96#[derive(Debug, Default)]
97pub struct NullSecretsBackend;
98
99#[async_trait::async_trait]
100impl SecretsBackend for NullSecretsBackend {
101 async fn resolve_tls_profile(
102 &self,
103 name: &str,
104 _tenant: &str,
105 ) -> crate::error::Result<TlsHandle> {
106 Err(crate::error::YetiError::Internal(format!(
107 "no secrets backend configured (requested: {name})"
108 )))
109 }
110
111 async fn get_credential(
112 &self,
113 name: &str,
114 _tenant: &str,
115 ) -> crate::error::Result<CredentialHandle> {
116 Err(crate::error::YetiError::Internal(format!(
117 "no secrets backend configured (requested: {name})"
118 )))
119 }
120
121 async fn get_string(&self, name: &str, _tenant: &str) -> crate::error::Result<SecretString> {
122 Err(crate::error::YetiError::Internal(format!(
123 "no secrets backend configured (requested: {name})"
124 )))
125 }
126
127 fn subscribe(
128 &self,
129 _name: &str,
130 _tenant: &str,
131 ) -> std::pin::Pin<Box<dyn futures::Stream<Item = RotationEvent> + Send>> {
132 Box::pin(futures::stream::empty())
133 }
134}