zlayer_secrets/registry_auth.rs
1//! Registry-credential resolution shared by the API pull handlers and the
2//! agent's supervisor recreate path.
3//!
4//! This lives in `zlayer-secrets` (rather than `zlayer-api`, its historical
5//! home) because it is fundamentally a credential-store concern and both
6//! consumers — `zlayer-api` (the `/images/pull` + container-create handlers)
7//! and `zlayer-agent` (the supervisor's `pull_and_refresh_digest`) — already
8//! depend on `zlayer-secrets`, whereas the agent cannot depend on `zlayer-api`
9//! (that edge runs the other way). `zlayer-api` re-exports both functions from
10//! `zlayer_api::auth` for source compatibility.
11
12use crate::{RegistryCredentialStore, SecretsStore};
13
14/// Resolve registry authentication asynchronously, handling
15/// [`zlayer_core::auth::AuthSource::SecretStore`] via the provided credential
16/// store.
17///
18/// For all other `AuthSource` variants this delegates to the synchronous
19/// [`zlayer_core::auth::AuthResolver::resolve_source`] method. The `SecretStore`
20/// variant performs an async lookup against the `RegistryCredentialStore`.
21///
22/// # Arguments
23///
24/// * `resolver` - The sync auth resolver (owns per-registry config)
25/// * `registry` - Registry hostname to resolve credentials for
26/// * `cred_store` - Optional credential store; if `None` and the source is
27/// `SecretStore`, returns `Anonymous` with a warning
28pub async fn resolve_registry_auth_async<S: SecretsStore>(
29 resolver: &zlayer_core::auth::AuthResolver,
30 registry: &str,
31 cred_store: Option<&RegistryCredentialStore<S>>,
32) -> oci_client::secrets::RegistryAuth {
33 let source = resolver.source_for_registry(registry);
34
35 match source {
36 zlayer_core::auth::AuthSource::SecretStore { credential_id } => {
37 let Some(store) = cred_store else {
38 tracing::warn!(
39 credential_id,
40 "No credential store provided for SecretStore auth"
41 );
42 return oci_client::secrets::RegistryAuth::Anonymous;
43 };
44 match store.get(credential_id).await {
45 Ok(Some(cred)) => match store.get_password(credential_id).await {
46 Ok(secret) => oci_client::secrets::RegistryAuth::Basic(
47 cred.username.clone(),
48 secret.expose().to_string(),
49 ),
50 Err(e) => {
51 tracing::error!(
52 error = %e,
53 credential_id,
54 "Failed to retrieve registry password"
55 );
56 oci_client::secrets::RegistryAuth::Anonymous
57 }
58 },
59 Ok(None) => {
60 tracing::warn!(credential_id, "Registry credential not found");
61 oci_client::secrets::RegistryAuth::Anonymous
62 }
63 Err(e) => {
64 tracing::error!(
65 error = %e,
66 credential_id,
67 "Failed to look up registry credential"
68 );
69 oci_client::secrets::RegistryAuth::Anonymous
70 }
71 }
72 }
73 other => resolver.resolve_source(other, registry),
74 }
75}
76
77/// Resolve registry auth for an image's host **from the stored credential
78/// store**, with a Docker-config fallback.
79///
80/// This is the "`zlayer login <host>` then plain `pull <host>/img`" path: the
81/// pull request carries neither inline `registry_auth` nor an explicit
82/// `registry_credential_id`, so we consult the [`RegistryCredentialStore`] by
83/// hostname. Every stored credential is mapped into a per-registry
84/// [`zlayer_core::auth::AuthSource::SecretStore`] entry keyed by its normalized
85/// registry host, the resolver's default is left as
86/// [`zlayer_core::auth::AuthSource::DockerConfig`], and resolution is delegated
87/// to [`resolve_registry_auth_async`]. The effective precedence for the returned
88/// auth is therefore:
89///
90/// 1. **Stored credential** whose registry matches the image host (from
91/// `zlayer login`), then
92/// 2. **`~/.docker/config.json`** (the `DockerConfig` default), then
93/// 3. **anonymous** (returned as `None`).
94///
95/// Inline `registry_auth` precedence is enforced by the *callers* (the pull /
96/// create handlers short-circuit on inline auth before ever calling this), so
97/// this function is only reached when no explicit credential was supplied.
98///
99/// Returns `None` when resolution yields anonymous access, so the caller can
100/// pass `None` straight through to the runtime (whose own resolver then performs
101/// the identical Docker-config fallback — harmless, never a private-registry
102/// regression).
103///
104/// If two stored credentials normalize to the same host, the last one wins
105/// (`HashMap` insertion order); `zlayer login` overwrites conceptually, so this
106/// matches the "most recent login for a host" intent.
107pub async fn resolve_stored_registry_auth<S: SecretsStore>(
108 image: &str,
109 cred_store: &RegistryCredentialStore<S>,
110) -> Option<zlayer_spec::RegistryAuth> {
111 use zlayer_core::auth::{AuthConfig, AuthResolver, AuthSource, RegistryAuthConfig};
112
113 let creds = match cred_store.list().await {
114 Ok(creds) => creds,
115 Err(e) => {
116 tracing::error!(error = %e, "failed to list registry credentials for host-match auth");
117 return None;
118 }
119 };
120
121 let registries: Vec<RegistryAuthConfig> = creds
122 .into_iter()
123 .map(|c| RegistryAuthConfig {
124 registry: AuthResolver::normalize_registry(&c.registry),
125 source: AuthSource::SecretStore {
126 credential_id: c.id,
127 },
128 })
129 .collect();
130
131 let config = AuthConfig {
132 registries,
133 // Fall back to ~/.docker/config.json for hosts without a stored
134 // credential, preserving the existing DockerConfig behaviour.
135 default: AuthSource::DockerConfig,
136 docker_config_path: None,
137 };
138 let resolver = AuthResolver::new(config);
139
140 let host = AuthResolver::normalize_registry(&AuthResolver::extract_registry(image));
141 match resolve_registry_auth_async(&resolver, &host, Some(cred_store)).await {
142 oci_client::secrets::RegistryAuth::Basic(username, password) => {
143 Some(zlayer_spec::RegistryAuth {
144 username,
145 password,
146 // oci Basic is what both Basic and Token creds reduce to on the
147 // wire (`spec_auth_to_oci`), so Basic is the faithful tag here.
148 auth_type: zlayer_spec::RegistryAuthType::Basic,
149 })
150 }
151 _ => None,
152 }
153}