Skip to main content

zlayer_secrets/
registry_credentials.rs

1//! Typed credential store for Docker/OCI registry authentication.
2//!
3//! Built on top of any [`SecretsStore`] implementation, this module provides
4//! structured storage for registry credentials. Metadata (registry, username,
5//! auth type) is stored as JSON in the `registry_credentials_meta` scope, while
6//! the actual password/token is stored as a secret in the `registry_credentials`
7//! scope. Both are keyed by a UUID identifier.
8//!
9//! # Example
10//!
11//! ```rust,ignore
12//! use zlayer_secrets::{EncryptionKey, PersistentSecretsStore};
13//! use zlayer_secrets::registry_credentials::{RegistryCredentialStore, RegistryAuthType};
14//!
15//! # async fn example() -> zlayer_secrets::Result<()> {
16//! let key = EncryptionKey::generate();
17//! let secrets_dir = zlayer_paths::ZLayerDirs::system_default().secrets();
18//! let store = PersistentSecretsStore::open(&secrets_dir, key).await?;
19//! let reg_store = RegistryCredentialStore::new(store);
20//!
21//! let cred = reg_store.create("ghcr.io", "ci-bot", "ghp_xxxx", RegistryAuthType::Token).await?;
22//! let password = reg_store.get_password(&cred.id).await?;
23//! assert_eq!(password.expose(), "ghp_xxxx");
24//! # Ok(())
25//! # }
26//! ```
27
28use tracing::{debug, info};
29use uuid::Uuid;
30
31use crate::{Result, Secret, SecretsError, SecretsStore};
32
33pub use zlayer_types::secrets::registry::{RegistryAuthType, RegistryCredential};
34
35/// Scope used for storing registry password/token secrets.
36const REGISTRY_CRED_SCOPE: &str = "registry_credentials";
37
38/// Scope used for storing registry credential metadata (JSON).
39const REGISTRY_CRED_META_SCOPE: &str = "registry_credentials_meta";
40
41/// Store for Docker/OCI registry credentials.
42///
43/// Wraps a [`SecretsStore`] and organises data into two scopes:
44/// - **`registry_credentials_meta`**: JSON-serialised [`RegistryCredential`]
45///   metadata (everything except the password).
46/// - **`registry_credentials`**: The raw password/token as an encrypted secret.
47pub struct RegistryCredentialStore<S: SecretsStore> {
48    store: S,
49}
50
51impl<S: SecretsStore> std::fmt::Debug for RegistryCredentialStore<S> {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        f.debug_struct("RegistryCredentialStore")
54            .field("store", &"<secrets store>")
55            .finish()
56    }
57}
58
59impl<S: SecretsStore> RegistryCredentialStore<S> {
60    /// Create a new registry credential store backed by the provided secrets store.
61    pub fn new(store: S) -> Self {
62        Self { store }
63    }
64
65    /// Store a new registry credential.
66    ///
67    /// Generates a UUID for the credential, stores the password as an encrypted
68    /// secret in the `registry_credentials` scope, and the metadata as JSON in
69    /// the `registry_credentials_meta` scope.
70    ///
71    /// # Arguments
72    /// * `registry` - Registry hostname (e.g. `"docker.io"`)
73    /// * `username` - Username for authentication
74    /// * `password` - Password or token value (stored encrypted)
75    /// * `auth_type` - Authentication method
76    ///
77    /// # Errors
78    /// Returns a [`SecretsError`] if serialisation or storage fails.
79    pub async fn create(
80        &self,
81        registry: &str,
82        username: &str,
83        password: &str,
84        auth_type: RegistryAuthType,
85    ) -> Result<RegistryCredential> {
86        let id = Uuid::new_v4().to_string();
87
88        let cred = RegistryCredential {
89            id: id.clone(),
90            registry: registry.to_string(),
91            username: username.to_string(),
92            auth_type,
93        };
94
95        // Store metadata as JSON.
96        let meta_json = serde_json::to_string(&cred)
97            .map_err(|e| SecretsError::Storage(format!("failed to serialise credential: {e}")))?;
98        self.store
99            .set_secret(REGISTRY_CRED_META_SCOPE, &id, &Secret::new(meta_json))
100            .await?;
101
102        // Store the password/token as an encrypted secret.
103        self.store
104            .set_secret(REGISTRY_CRED_SCOPE, &id, &Secret::new(password))
105            .await?;
106
107        info!(id = %id, registry = %registry, username = %username, "Created registry credential");
108        Ok(cred)
109    }
110
111    /// Retrieve credential metadata (without the password).
112    ///
113    /// Returns `None` if no credential with the given `id` exists.
114    ///
115    /// # Errors
116    /// Returns a [`SecretsError`] on storage/decryption errors.
117    pub async fn get(&self, id: &str) -> Result<Option<RegistryCredential>> {
118        let secret = match self.store.get_secret(REGISTRY_CRED_META_SCOPE, id).await {
119            Ok(s) => s,
120            Err(SecretsError::NotFound { .. }) => {
121                debug!(id = %id, "Registry credential not found");
122                return Ok(None);
123            }
124            Err(e) => return Err(e),
125        };
126
127        let cred: RegistryCredential = serde_json::from_str(secret.expose()).map_err(|e| {
128            SecretsError::Storage(format!("corrupt registry credential '{id}': {e}"))
129        })?;
130
131        Ok(Some(cred))
132    }
133
134    /// Retrieve the password/token for a registry credential.
135    ///
136    /// # Errors
137    /// Returns [`SecretsError::NotFound`] if the credential does not exist.
138    pub async fn get_password(&self, id: &str) -> Result<Secret> {
139        self.store.get_secret(REGISTRY_CRED_SCOPE, id).await
140    }
141
142    /// List all registry credentials (metadata only, no passwords).
143    ///
144    /// # Errors
145    /// Returns a [`SecretsError`] on storage/decryption errors.
146    pub async fn list(&self) -> Result<Vec<RegistryCredential>> {
147        let metas = self.store.list_secrets(REGISTRY_CRED_META_SCOPE).await?;
148
149        let mut creds = Vec::with_capacity(metas.len());
150        for meta in metas {
151            if let Some(cred) = self.get(&meta.name).await? {
152                creds.push(cred);
153            }
154        }
155
156        Ok(creds)
157    }
158
159    /// Delete a registry credential and its associated secret.
160    ///
161    /// Both the metadata and the password secret are removed.
162    ///
163    /// # Errors
164    /// Returns [`SecretsError::NotFound`] if the credential does not exist.
165    pub async fn delete(&self, id: &str) -> Result<()> {
166        // Delete metadata first; if it doesn't exist, the whole credential is missing.
167        self.store
168            .delete_secret(REGISTRY_CRED_META_SCOPE, id)
169            .await?;
170
171        // Delete the password secret. Ignore NotFound here in case only metadata
172        // existed (defensive).
173        match self.store.delete_secret(REGISTRY_CRED_SCOPE, id).await {
174            Ok(()) | Err(SecretsError::NotFound { .. }) => {}
175            Err(e) => return Err(e),
176        }
177
178        info!(id = %id, "Deleted registry credential");
179        Ok(())
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::{EncryptionKey, PersistentSecretsStore};
187    use zlayer_paths::ZLayerDirs;
188
189    async fn create_test_store() -> (PersistentSecretsStore, zlayer_types::Scratch) {
190        let temp_dir = ZLayerDirs::system_default()
191            .scratch_dir("create-test-store-")
192            .unwrap();
193        let db_path = temp_dir.path().join("test_registry_creds.sqlite");
194        let key = EncryptionKey::generate();
195        let store = PersistentSecretsStore::open(&db_path, key).await.unwrap();
196        (store, temp_dir)
197    }
198
199    #[tokio::test]
200    async fn test_create_and_get() {
201        let (store, _temp) = create_test_store().await;
202        let reg_store = RegistryCredentialStore::new(store);
203
204        let cred = reg_store
205            .create("ghcr.io", "ci-bot", "ghp_xxxx", RegistryAuthType::Token)
206            .await
207            .unwrap();
208
209        assert_eq!(cred.registry, "ghcr.io");
210        assert_eq!(cred.username, "ci-bot");
211        assert_eq!(cred.auth_type, RegistryAuthType::Token);
212        assert!(!cred.id.is_empty());
213
214        let retrieved = reg_store.get(&cred.id).await.unwrap();
215        assert!(retrieved.is_some());
216        let retrieved = retrieved.unwrap();
217        assert_eq!(retrieved.id, cred.id);
218        assert_eq!(retrieved.registry, "ghcr.io");
219        assert_eq!(retrieved.username, "ci-bot");
220        assert_eq!(retrieved.auth_type, RegistryAuthType::Token);
221    }
222
223    #[tokio::test]
224    async fn test_get_password() {
225        let (store, _temp) = create_test_store().await;
226        let reg_store = RegistryCredentialStore::new(store);
227
228        let cred = reg_store
229            .create("docker.io", "user", "s3cret!", RegistryAuthType::Basic)
230            .await
231            .unwrap();
232
233        let password = reg_store.get_password(&cred.id).await.unwrap();
234        assert_eq!(password.expose(), "s3cret!");
235    }
236
237    #[tokio::test]
238    async fn test_list() {
239        let (store, _temp) = create_test_store().await;
240        let reg_store = RegistryCredentialStore::new(store);
241
242        reg_store
243            .create("docker.io", "user1", "pw1", RegistryAuthType::Basic)
244            .await
245            .unwrap();
246        reg_store
247            .create("ghcr.io", "user2", "pw2", RegistryAuthType::Token)
248            .await
249            .unwrap();
250
251        let list = reg_store.list().await.unwrap();
252        assert_eq!(list.len(), 2);
253
254        let registries: Vec<&str> = list.iter().map(|c| c.registry.as_str()).collect();
255        assert!(registries.contains(&"docker.io"));
256        assert!(registries.contains(&"ghcr.io"));
257    }
258
259    #[tokio::test]
260    async fn test_delete() {
261        let (store, _temp) = create_test_store().await;
262        let reg_store = RegistryCredentialStore::new(store);
263
264        let cred = reg_store
265            .create("docker.io", "user", "pw", RegistryAuthType::Basic)
266            .await
267            .unwrap();
268
269        reg_store.delete(&cred.id).await.unwrap();
270
271        assert!(reg_store.get(&cred.id).await.unwrap().is_none());
272        assert!(reg_store.get_password(&cred.id).await.is_err());
273    }
274
275    #[tokio::test]
276    async fn test_create_overwrites() {
277        let (store, _temp) = create_test_store().await;
278        let reg_store = RegistryCredentialStore::new(store);
279
280        let cred1 = reg_store
281            .create("docker.io", "user", "pw1", RegistryAuthType::Basic)
282            .await
283            .unwrap();
284
285        // Create a second credential -- gets its own id, so it's a separate entry,
286        // not an overwrite by design. But if we manually set the same scope+key
287        // it would overwrite. This test verifies two creates produce two entries.
288        let cred2 = reg_store
289            .create("docker.io", "user", "pw2", RegistryAuthType::Basic)
290            .await
291            .unwrap();
292
293        assert_ne!(cred1.id, cred2.id);
294
295        let pw1 = reg_store.get_password(&cred1.id).await.unwrap();
296        let pw2 = reg_store.get_password(&cred2.id).await.unwrap();
297        assert_eq!(pw1.expose(), "pw1");
298        assert_eq!(pw2.expose(), "pw2");
299    }
300
301    #[tokio::test]
302    async fn test_get_nonexistent_returns_none() {
303        let (store, _temp) = create_test_store().await;
304        let reg_store = RegistryCredentialStore::new(store);
305
306        let result = reg_store.get("nonexistent-id").await.unwrap();
307        assert!(result.is_none());
308    }
309
310    #[tokio::test]
311    async fn test_get_password_nonexistent_returns_error() {
312        let (store, _temp) = create_test_store().await;
313        let reg_store = RegistryCredentialStore::new(store);
314
315        let result = reg_store.get_password("nonexistent-id").await;
316        assert!(result.is_err());
317        assert!(matches!(result.unwrap_err(), SecretsError::NotFound { .. }));
318    }
319
320    #[tokio::test]
321    async fn test_delete_nonexistent_returns_error() {
322        let (store, _temp) = create_test_store().await;
323        let reg_store = RegistryCredentialStore::new(store);
324
325        let result = reg_store.delete("nonexistent-id").await;
326        assert!(result.is_err());
327        assert!(matches!(result.unwrap_err(), SecretsError::NotFound { .. }));
328    }
329
330    #[tokio::test]
331    async fn test_serde_auth_type_roundtrip() {
332        let basic = serde_json::to_string(&RegistryAuthType::Basic).unwrap();
333        assert_eq!(basic, "\"basic\"");
334
335        let token = serde_json::to_string(&RegistryAuthType::Token).unwrap();
336        assert_eq!(token, "\"token\"");
337
338        let parsed: RegistryAuthType = serde_json::from_str(&basic).unwrap();
339        assert_eq!(parsed, RegistryAuthType::Basic);
340
341        let parsed: RegistryAuthType = serde_json::from_str(&token).unwrap();
342        assert_eq!(parsed, RegistryAuthType::Token);
343    }
344}