drasi_lib/secret_store/mod.rs
1// Copyright 2026 The Drasi Authors.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Secret store providers for resolving named secrets at runtime.
16//!
17//! A [`SecretStoreProvider`] resolves named secret references (e.g., `"DB_PASSWORD"`)
18//! into their actual string values. Plugin configuration DTOs use
19//! [`ConfigValue::Secret`](drasi_plugin_sdk::ConfigValue) references which are resolved
20//! through the configured secret store during plugin initialization.
21//!
22//! # Architecture
23//!
24//! The secret store plugin system follows pure dependency inversion:
25//! - **drasi-lib** defines the [`SecretStoreProvider`] trait
26//! - **Plugin crates** (in `components/secret_stores/`) implement this trait
27//! for specific backends (file, OS keyring, Azure Key Vault, etc.)
28//! - **Applications** inject a provider into DrasiLib via the builder
29//!
30//! Secret stores are initialized **before** any source/reaction/bootstrap plugins,
31//! because those plugins need resolved secrets during their `create_*` calls.
32//!
33//! # Built-in Implementations
34//!
35//! - [`MemorySecretStoreProvider`] — In-memory store for testing
36//!
37//! # Usage
38//!
39//! ## With a file-based secret store plugin
40//! ```ignore
41//! use drasi_secret_store_file::FileSecretStoreProvider;
42//!
43//! let secret_store = FileSecretStoreProvider::new("/path/to/secrets.json").await?;
44//! let drasi = DrasiLib::builder()
45//! .with_secret_store_provider(Arc::new(secret_store))
46//! .build()
47//! .await?;
48//! ```
49//!
50//! ## With the in-memory provider (testing)
51//! ```ignore
52//! use drasi_lib::secret_store::MemorySecretStoreProvider;
53//!
54//! let store = MemorySecretStoreProvider::new()
55//! .with_secret("DB_PASSWORD", "hunter2")
56//! .with_secret("API_KEY", "abc123");
57//!
58//! let drasi = DrasiLib::builder()
59//! .with_secret_store_provider(Arc::new(store))
60//! .build()
61//! .await?;
62//! ```
63
64use anyhow::Result;
65use async_trait::async_trait;
66use std::collections::HashMap;
67use tokio::sync::RwLock;
68
69/// Trait for secret store providers that resolve named secrets at runtime.
70///
71/// Implementations fetch secret values from a backend (file, OS keyring,
72/// cloud vault, etc.) and return them as strings. The Drasi framework calls
73/// this trait during plugin initialization to resolve `ConfigValue::Secret`
74/// references in plugin configuration DTOs.
75///
76/// This is a plugin trait (Layer 3) — implementations return `anyhow::Result`
77/// and should use `.context()` for error chains. The framework wraps these
78/// into `DrasiError` at the public API boundary.
79///
80/// # Important
81///
82/// Secret store providers are initialized before any other plugins. Their own
83/// configuration must use static values or environment variables — not
84/// `ConfigValue::Secret` references (which would create a circular dependency).
85#[async_trait]
86pub trait SecretStoreProvider: Send + Sync {
87 /// Resolve a named secret to its string value.
88 ///
89 /// # Arguments
90 /// * `name` - The secret name (e.g., `"DB_PASSWORD"`, `"API_KEY"`)
91 ///
92 /// # Returns
93 /// * `Ok(value)` - The secret's string value
94 /// * `Err(e)` - The secret could not be resolved (not found, access denied, etc.)
95 async fn get_secret(&self, name: &str) -> Result<String>;
96}
97
98/// In-memory secret store for testing.
99///
100/// Stores secrets in a `HashMap` and returns them on demand. Secrets can be
101/// added at construction time via the builder pattern or at runtime via
102/// `set_secret()`.
103///
104/// # Example
105///
106/// ```
107/// use drasi_lib::secret_store::MemorySecretStoreProvider;
108///
109/// let store = MemorySecretStoreProvider::new()
110/// .with_secret("DB_PASSWORD", "hunter2")
111/// .with_secret("API_KEY", "abc123");
112/// ```
113pub struct MemorySecretStoreProvider {
114 secrets: RwLock<HashMap<String, String>>,
115}
116
117impl MemorySecretStoreProvider {
118 /// Create a new empty in-memory secret store.
119 pub fn new() -> Self {
120 Self {
121 secrets: RwLock::new(HashMap::new()),
122 }
123 }
124
125 /// Add a secret during construction (builder pattern).
126 #[must_use]
127 pub fn with_secret(self, name: impl Into<String>, value: impl Into<String>) -> Self {
128 // Use try_write since we know we're the only owner during construction
129 self.secrets
130 .try_write()
131 .expect("MemorySecretStoreProvider should not be shared during construction")
132 .insert(name.into(), value.into());
133 self
134 }
135
136 /// Add or update a secret at runtime.
137 pub async fn set_secret(&self, name: impl Into<String>, value: impl Into<String>) {
138 self.secrets.write().await.insert(name.into(), value.into());
139 }
140
141 /// Remove a secret at runtime.
142 pub async fn remove_secret(&self, name: &str) -> Option<String> {
143 self.secrets.write().await.remove(name)
144 }
145}
146
147impl Default for MemorySecretStoreProvider {
148 fn default() -> Self {
149 Self::new()
150 }
151}
152
153#[async_trait]
154impl SecretStoreProvider for MemorySecretStoreProvider {
155 async fn get_secret(&self, name: &str) -> Result<String> {
156 self.secrets
157 .read()
158 .await
159 .get(name)
160 .cloned()
161 .ok_or_else(|| anyhow::anyhow!("Secret not found: {name}"))
162 }
163}
164
165impl std::fmt::Debug for MemorySecretStoreProvider {
166 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167 f.debug_struct("MemorySecretStoreProvider")
168 .field("secrets", &"[REDACTED]")
169 .finish()
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[tokio::test]
178 async fn test_memory_store_get_secret() {
179 let store = MemorySecretStoreProvider::new()
180 .with_secret("DB_PASSWORD", "hunter2")
181 .with_secret("API_KEY", "abc123");
182
183 assert_eq!(store.get_secret("DB_PASSWORD").await.unwrap(), "hunter2");
184 assert_eq!(store.get_secret("API_KEY").await.unwrap(), "abc123");
185 }
186
187 #[tokio::test]
188 async fn test_memory_store_missing_secret() {
189 let store = MemorySecretStoreProvider::new();
190 let result = store.get_secret("NONEXISTENT").await;
191 assert!(result.is_err());
192 assert!(result.unwrap_err().to_string().contains("Secret not found"));
193 }
194
195 #[tokio::test]
196 async fn test_memory_store_set_and_remove() {
197 let store = MemorySecretStoreProvider::new();
198 store.set_secret("KEY", "value").await;
199 assert_eq!(store.get_secret("KEY").await.unwrap(), "value");
200
201 let removed = store.remove_secret("KEY").await;
202 assert_eq!(removed, Some("value".to_string()));
203 assert!(store.get_secret("KEY").await.is_err());
204 }
205
206 #[test]
207 fn test_memory_store_debug_redacts() {
208 let store = MemorySecretStoreProvider::new().with_secret("KEY", "secret_value");
209 let debug = format!("{store:?}");
210 assert!(debug.contains("[REDACTED]"));
211 assert!(!debug.contains("secret_value"));
212 }
213
214 #[test]
215 fn test_memory_store_default() {
216 let store = MemorySecretStoreProvider::default();
217 // Should be empty
218 let rt = tokio::runtime::Runtime::new().unwrap();
219 assert!(rt.block_on(store.get_secret("anything")).is_err());
220 }
221}