Skip to main content

flow_security/
credentials.rs

1//! Credential storage abstraction.
2//!
3//! Two implementations:
4//! - [`KeyringCredentialStore`] - production-grade, OS-native (macOS Keychain,
5//!   Windows Credential Manager, libsecret on Linux). Uses the `keyring` crate.
6//! - [`InMemoryCredentialStore`] - for unit tests; thread-safe via `parking_lot`.
7//!
8//! The [`CredentialStore`] trait is the seam. [`CredentialResolver`] composes a
9//! store with environment-variable fallback so existing env-var users keep
10//! working during the keyring migration.
11
12use parking_lot::RwLock;
13use std::collections::{BTreeSet, HashMap};
14use std::path::PathBuf;
15use std::sync::Arc;
16use thiserror::Error;
17
18/// Service name registered with the OS keyring. All Flow Studio credentials
19/// live under this service so `security find-generic-password` (macOS) or
20/// equivalents on other platforms can list them in one place.
21pub const SERVICE_NAME: &str = "flow-studio";
22
23#[derive(Debug, Error)]
24pub enum CredentialError {
25    #[error("credential not found for service '{service}' / account '{account}'")]
26    NotFound { service: String, account: String },
27    #[error("keyring error: {0}")]
28    Keyring(String),
29    #[error("invalid credential identifier: {0}")]
30    Invalid(String),
31}
32
33/// Discriminator for the kinds of credentials Flow Studio stores. Used as the
34/// account-name prefix so different secret kinds don't collide on the same
35/// service entry.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum CredentialKind {
38    /// Cloud-AI provider API key.
39    CloudAiKey,
40    /// Zowe profile credentials (future M3.3b).
41    ZoweProfile,
42    /// Service-node connection secret (API key, token, basic `user:pass`, or an
43    /// OAuth2 token bundle serialized as JSON). Keyed by the service catalog slug.
44    Service,
45}
46
47impl CredentialKind {
48    pub fn account_for(self, key: &str) -> String {
49        match self {
50            CredentialKind::CloudAiKey => format!("cloud-ai:{key}"),
51            CredentialKind::ZoweProfile => format!("zowe:{key}"),
52            CredentialKind::Service => format!("service:{key}"),
53        }
54    }
55}
56
57/// The seam between Flow code and the OS keyring (or a test double).
58pub trait CredentialStore: Send + Sync {
59    fn set(&self, account: &str, secret: &str) -> Result<(), CredentialError>;
60    fn get(&self, account: &str) -> Result<String, CredentialError>;
61    fn delete(&self, account: &str) -> Result<(), CredentialError>;
62    /// Returns true if a credential exists for this account, without revealing
63    /// the value. Useful for UI status displays.
64    fn exists(&self, account: &str) -> bool {
65        self.get(account).is_ok()
66    }
67}
68
69/// Sidecar index of which credential accounts have been written to the OS
70/// keyring. Stores account *names only*, never secrets - used to answer
71/// "does this credential exist?" without triggering a keychain prompt on
72/// macOS (each `get_password` call on an unsigned dev build re-prompts the
73/// user even after "Always Allow"). The index file lives next to the app's
74/// other on-disk state in `~/flow-studio/`.
75///
76/// Losing the file is non-catastrophic: it just means the UI shows
77/// "credential missing" until the user re-saves the value. The keychain
78/// item itself is still there.
79struct CredentialIndex {
80    path: Option<PathBuf>,
81    cache: RwLock<BTreeSet<String>>,
82}
83
84impl CredentialIndex {
85    fn open(path: Option<PathBuf>) -> Self {
86        let cache = match path.as_ref() {
87            Some(p) if p.exists() => std::fs::read_to_string(p)
88                .ok()
89                .and_then(|s| serde_json::from_str::<BTreeSet<String>>(&s).ok())
90                .unwrap_or_default(),
91            _ => BTreeSet::new(),
92        };
93        Self {
94            path,
95            cache: RwLock::new(cache),
96        }
97    }
98
99    fn contains(&self, account: &str) -> bool {
100        self.cache.read().contains(account)
101    }
102
103    fn insert(&self, account: &str) {
104        let mut guard = self.cache.write();
105        if guard.insert(account.to_string()) {
106            self.persist(&guard);
107        }
108    }
109
110    fn remove(&self, account: &str) {
111        let mut guard = self.cache.write();
112        if guard.remove(account) {
113            self.persist(&guard);
114        }
115    }
116
117    fn persist(&self, contents: &BTreeSet<String>) {
118        let Some(path) = self.path.as_ref() else {
119            return;
120        };
121        if let Some(parent) = path.parent() {
122            let _ = std::fs::create_dir_all(parent);
123        }
124        if let Ok(body) = serde_json::to_string_pretty(contents) {
125            let _ = std::fs::write(path, body);
126        }
127    }
128}
129
130/// Production credential store backed by the OS keyring, with an
131/// auxiliary sidecar index that answers `exists()` queries without
132/// touching the keychain.
133pub struct KeyringCredentialStore {
134    service: String,
135    index: CredentialIndex,
136}
137
138impl KeyringCredentialStore {
139    pub fn new() -> Self {
140        Self {
141            service: SERVICE_NAME.to_string(),
142            index: CredentialIndex::open(None),
143        }
144    }
145
146    /// Construct with the sidecar index persisted at `index_path`. Pass
147    /// `~/flow-studio/credential_index.json` in production.
148    pub fn with_index(index_path: PathBuf) -> Self {
149        Self {
150            service: SERVICE_NAME.to_string(),
151            index: CredentialIndex::open(Some(index_path)),
152        }
153    }
154
155    pub fn with_service(service: impl Into<String>) -> Self {
156        Self {
157            service: service.into(),
158            index: CredentialIndex::open(None),
159        }
160    }
161
162    fn entry(&self, account: &str) -> Result<keyring::Entry, CredentialError> {
163        keyring::Entry::new(&self.service, account)
164            .map_err(|e| CredentialError::Keyring(e.to_string()))
165    }
166}
167
168impl Default for KeyringCredentialStore {
169    fn default() -> Self {
170        Self::new()
171    }
172}
173
174impl CredentialStore for KeyringCredentialStore {
175    fn set(&self, account: &str, secret: &str) -> Result<(), CredentialError> {
176        if account.is_empty() {
177            return Err(CredentialError::Invalid("account is empty".into()));
178        }
179        self.entry(account)?
180            .set_password(secret)
181            .map_err(|e| CredentialError::Keyring(e.to_string()))?;
182        self.index.insert(account);
183        Ok(())
184    }
185
186    fn get(&self, account: &str) -> Result<String, CredentialError> {
187        self.entry(account)?.get_password().map_err(|e| match e {
188            keyring::Error::NoEntry => {
189                // The sidecar may be stale - drop the entry so subsequent
190                // `exists()` calls answer correctly.
191                self.index.remove(account);
192                CredentialError::NotFound {
193                    service: self.service.clone(),
194                    account: account.to_string(),
195                }
196            }
197            other => CredentialError::Keyring(other.to_string()),
198        })
199    }
200
201    fn delete(&self, account: &str) -> Result<(), CredentialError> {
202        let result = self.entry(account)?.delete_credential().map_err(|e| match e {
203            keyring::Error::NoEntry => CredentialError::NotFound {
204                service: self.service.clone(),
205                account: account.to_string(),
206            },
207            other => CredentialError::Keyring(other.to_string()),
208        });
209        // Remove from the sidecar regardless of whether the keychain knew
210        // about it - if it's gone, the index should reflect that too.
211        self.index.remove(account);
212        result
213    }
214
215    /// Answers from the sidecar index without touching the keychain. The
216    /// trait's default would call `get()` which on macOS triggers a
217    /// keychain prompt per call - and the prompt fires every startup on
218    /// unsigned dev builds because "Always Allow" is tied to the binary's
219    /// code signature, which changes on every rebuild.
220    fn exists(&self, account: &str) -> bool {
221        self.index.contains(account)
222    }
223}
224
225/// Thread-safe in-memory store for tests.
226#[derive(Default)]
227pub struct InMemoryCredentialStore {
228    inner: RwLock<HashMap<String, String>>,
229}
230
231impl InMemoryCredentialStore {
232    pub fn new() -> Self {
233        Self::default()
234    }
235}
236
237impl CredentialStore for InMemoryCredentialStore {
238    fn set(&self, account: &str, secret: &str) -> Result<(), CredentialError> {
239        if account.is_empty() {
240            return Err(CredentialError::Invalid("account is empty".into()));
241        }
242        self.inner
243            .write()
244            .insert(account.to_string(), secret.to_string());
245        Ok(())
246    }
247
248    fn get(&self, account: &str) -> Result<String, CredentialError> {
249        self.inner
250            .read()
251            .get(account)
252            .cloned()
253            .ok_or_else(|| CredentialError::NotFound {
254                service: SERVICE_NAME.into(),
255                account: account.to_string(),
256            })
257    }
258
259    fn delete(&self, account: &str) -> Result<(), CredentialError> {
260        if self.inner.write().remove(account).is_some() {
261            Ok(())
262        } else {
263            Err(CredentialError::NotFound {
264                service: SERVICE_NAME.into(),
265                account: account.to_string(),
266            })
267        }
268    }
269}
270
271/// Resolves a credential by trying the configured store first, then falling
272/// back to an environment variable. This preserves the v1 cloud-AI experience
273/// (env-var-only keys) while letting users migrate to the keyring at their own
274/// pace.
275pub trait CredentialResolver: Send + Sync {
276    /// Resolve the API key for a cloud-AI provider, given the canonical
277    /// provider name and the env-var name to fall back to.
278    fn resolve_cloud_ai_key(
279        &self,
280        provider: &str,
281        env_var: &str,
282    ) -> Result<String, CredentialError>;
283}
284
285pub struct EnvFallbackResolver {
286    pub store: Arc<dyn CredentialStore>,
287}
288
289impl EnvFallbackResolver {
290    pub fn new(store: Arc<dyn CredentialStore>) -> Self {
291        Self { store }
292    }
293}
294
295impl CredentialResolver for EnvFallbackResolver {
296    fn resolve_cloud_ai_key(
297        &self,
298        provider: &str,
299        env_var: &str,
300    ) -> Result<String, CredentialError> {
301        let account = CredentialKind::CloudAiKey.account_for(provider);
302        match self.store.get(&account) {
303            Ok(secret) => Ok(secret),
304            Err(CredentialError::NotFound { .. }) => match std::env::var(env_var) {
305                Ok(v) if !v.is_empty() => Ok(v),
306                _ => Err(CredentialError::NotFound {
307                    service: SERVICE_NAME.into(),
308                    account,
309                }),
310            },
311            Err(other) => Err(other),
312        }
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn account_naming_is_kind_prefixed() {
322        assert_eq!(
323            CredentialKind::CloudAiKey.account_for("claude"),
324            "cloud-ai:claude"
325        );
326        assert_eq!(
327            CredentialKind::ZoweProfile.account_for("PRD01"),
328            "zowe:PRD01"
329        );
330    }
331
332    #[test]
333    fn in_memory_set_get_round_trip() {
334        let store = InMemoryCredentialStore::new();
335        store.set("cloud-ai:claude", "sk-test").unwrap();
336        assert_eq!(store.get("cloud-ai:claude").unwrap(), "sk-test");
337        assert!(store.exists("cloud-ai:claude"));
338    }
339
340    #[test]
341    fn in_memory_get_missing_is_not_found() {
342        let store = InMemoryCredentialStore::new();
343        assert!(matches!(
344            store.get("cloud-ai:claude"),
345            Err(CredentialError::NotFound { .. })
346        ));
347    }
348
349    #[test]
350    fn in_memory_delete_removes() {
351        let store = InMemoryCredentialStore::new();
352        store.set("cloud-ai:openai", "sk-test").unwrap();
353        store.delete("cloud-ai:openai").unwrap();
354        assert!(!store.exists("cloud-ai:openai"));
355    }
356
357    #[test]
358    fn in_memory_set_empty_account_is_invalid() {
359        let store = InMemoryCredentialStore::new();
360        assert!(matches!(
361            store.set("", "x"),
362            Err(CredentialError::Invalid(_))
363        ));
364    }
365
366    #[test]
367    fn resolver_prefers_keyring_over_env() {
368        let store: Arc<dyn CredentialStore> = Arc::new(InMemoryCredentialStore::new());
369        store.set("cloud-ai:claude", "from-keyring").unwrap();
370        // Set env var to something else; the resolver should still pick keyring.
371        std::env::set_var("FLOW_TEST_RESOLVER_ENV", "from-env");
372        let resolver = EnvFallbackResolver::new(store);
373        let key = resolver
374            .resolve_cloud_ai_key("claude", "FLOW_TEST_RESOLVER_ENV")
375            .unwrap();
376        assert_eq!(key, "from-keyring");
377        std::env::remove_var("FLOW_TEST_RESOLVER_ENV");
378    }
379
380    #[test]
381    fn resolver_falls_back_to_env_when_keyring_empty() {
382        let store: Arc<dyn CredentialStore> = Arc::new(InMemoryCredentialStore::new());
383        std::env::set_var("FLOW_TEST_RESOLVER_FALLBACK", "from-env");
384        let resolver = EnvFallbackResolver::new(store);
385        let key = resolver
386            .resolve_cloud_ai_key("openai", "FLOW_TEST_RESOLVER_FALLBACK")
387            .unwrap();
388        assert_eq!(key, "from-env");
389        std::env::remove_var("FLOW_TEST_RESOLVER_FALLBACK");
390    }
391
392    #[test]
393    fn resolver_returns_not_found_when_neither_present() {
394        let store: Arc<dyn CredentialStore> = Arc::new(InMemoryCredentialStore::new());
395        let resolver = EnvFallbackResolver::new(store);
396        assert!(matches!(
397            resolver.resolve_cloud_ai_key("gemini", "FLOW_TEST_RESOLVER_MISSING_ENV"),
398            Err(CredentialError::NotFound { .. })
399        ));
400    }
401}