Skip to main content

alien_bindings/providers/artifact_registry/
acr.rs

1use crate::{
2    error::{map_cloud_client_error, ErrorData, Result},
3    traits::{
4        ArtifactRegistry, ArtifactRegistryCredentials, ArtifactRegistryPermissions, Binding,
5        CrossAccountAccess, CrossAccountPermissions, RegistryAuthMethod, RepositoryResponse,
6    },
7};
8use alien_azure_clients::{AzureClientConfig, AzureTokenCache};
9use alien_core::bindings::ArtifactRegistryBinding;
10use alien_error::{AlienError, Context, IntoAlienError};
11use async_trait::async_trait;
12use tracing::info;
13
14/// Azure Container Registry implementation of the ArtifactRegistry binding.
15#[derive(Debug)]
16pub struct AcrArtifactRegistry {
17    registry_name: String,
18    registry_endpoint: String,
19    repository_prefix: String,
20    /// Azure credentials for direct registry access (AAD token exchange).
21    azure_token_cache: AzureTokenCache,
22    http_client: reqwest::Client,
23}
24
25impl AcrArtifactRegistry {
26    /// Creates a new Azure Container Registry artifact registry binding from binding parameters.
27    ///
28    /// # Arguments
29    /// * `binding_name` - The name of this binding
30    /// * `binding` - The parsed binding parameters
31    pub async fn new(
32        binding_name: String,
33        binding: ArtifactRegistryBinding,
34        azure_config: &AzureClientConfig,
35    ) -> Result<Self> {
36        info!(
37            binding_name = %binding_name,
38            "Initializing Azure Container Registry"
39        );
40
41        // Extract values from binding
42        let config = match binding {
43            ArtifactRegistryBinding::Acr(config) => config,
44            _ => {
45                return Err(AlienError::new(ErrorData::BindingConfigInvalid {
46                    binding_name: binding_name.clone(),
47                    reason: "Expected ACR binding, got different service type".to_string(),
48                }));
49            }
50        };
51
52        let registry_name = config
53            .registry_name
54            .into_value(&binding_name, "registry_name")
55            .context(ErrorData::BindingConfigInvalid {
56                binding_name: binding_name.clone(),
57                reason: "Failed to extract registry_name from binding".to_string(),
58            })?;
59
60        config
61            .resource_group_name
62            .into_value(&binding_name, "resource_group_name")
63            .context(ErrorData::BindingConfigInvalid {
64                binding_name: binding_name.clone(),
65                reason: "Failed to extract resource_group_name from binding".to_string(),
66            })?;
67
68        // Derive registry endpoint from registry name
69        let registry_endpoint = format!("{}.azurecr.io", registry_name);
70        let client = crate::http_client::create_http_client();
71        let azure_token_cache = AzureTokenCache::new(azure_config.clone());
72
73        let repository_prefix = match config.repository_prefix {
74            Some(bv) => bv
75                .into_value(&binding_name, "repository_prefix")
76                .unwrap_or_default(),
77            None => String::new(),
78        };
79
80        Ok(Self {
81            registry_name,
82            registry_endpoint,
83            repository_prefix,
84            azure_token_cache,
85            http_client: client,
86        })
87    }
88}
89
90impl Binding for AcrArtifactRegistry {}
91
92#[async_trait]
93impl ArtifactRegistry for AcrArtifactRegistry {
94    fn registry_endpoint(&self) -> String {
95        format!("https://{}", self.registry_endpoint)
96    }
97
98    fn upstream_repository_prefix(&self) -> String {
99        self.repository_prefix.clone()
100    }
101
102    async fn create_repository(&self, repo_name: &str) -> Result<RepositoryResponse> {
103        // ACR repositories are created implicitly on first push.
104        // The ACR resource itself is provisioned by alien-infra.
105        let repository_uri = format!("{}/{}", self.registry_endpoint, repo_name);
106
107        Ok(RepositoryResponse {
108            name: repo_name.to_string(),
109            uri: Some(repository_uri),
110            created_at: None,
111        })
112    }
113
114    async fn get_repository(&self, repo_id: &str) -> Result<RepositoryResponse> {
115        // ACR repositories are implicit — return the routable name and URI.
116        let repository_uri = format!("{}/{}", self.registry_endpoint, repo_id);
117
118        Ok(RepositoryResponse {
119            name: repo_id.to_string(),
120            uri: Some(repository_uri),
121            created_at: None,
122        })
123    }
124
125    async fn add_cross_account_access(
126        &self,
127        repo_id: &str,
128        _access: CrossAccountAccess,
129    ) -> Result<()> {
130        let repo_name = repo_id;
131
132        info!(
133            repo_name = %repo_name,
134            registry_name = %self.registry_name,
135            "Azure Container Registry cross-account access not supported"
136        );
137
138        Err(AlienError::new(ErrorData::OperationNotSupported {
139            operation: "add_cross_account_access".to_string(),
140            reason: "Azure Container Registry uses token-based access via generate_credentials - cross-account permissions are not supported".to_string(),
141        }))
142    }
143
144    async fn remove_cross_account_access(
145        &self,
146        repo_id: &str,
147        _access: CrossAccountAccess,
148    ) -> Result<()> {
149        let repo_name = repo_id;
150
151        info!(
152            repo_name = %repo_name,
153            registry_name = %self.registry_name,
154            "Azure Container Registry cross-account access not supported"
155        );
156
157        Err(AlienError::new(ErrorData::OperationNotSupported {
158            operation: "remove_cross_account_access".to_string(),
159            reason: "Azure Container Registry uses token-based access via generate_credentials - cross-account permissions are not supported".to_string(),
160        }))
161    }
162
163    async fn get_cross_account_access(&self, repo_id: &str) -> Result<CrossAccountPermissions> {
164        let repo_name = repo_id;
165
166        info!(
167            repo_name = %repo_name,
168            registry_name = %self.registry_name,
169            "Azure Container Registry cross-account access not supported"
170        );
171
172        Err(AlienError::new(ErrorData::OperationNotSupported {
173            operation: "get_cross_account_access".to_string(),
174            reason: "Azure Container Registry uses token-based access via generate_credentials - cross-account permissions are not supported".to_string(),
175        }))
176    }
177
178    async fn generate_credentials(
179        &self,
180        repo_id: &str,
181        permissions: ArtifactRegistryPermissions,
182        _ttl_seconds: Option<u32>,
183    ) -> Result<ArtifactRegistryCredentials> {
184        info!(
185            registry = %self.registry_endpoint,
186            repo_id = %repo_id,
187            permissions = ?permissions,
188            "Generating ACR credentials via AAD → refresh → access token flow"
189        );
190
191        // Step 1: Get an AAD access token for the management API.
192        let aad_token = self
193            .azure_token_cache
194            .get_bearer_token_with_scope("https://management.azure.com/.default")
195            .await
196            .map_err(|e| {
197                map_cloud_client_error(e, "Failed to get AAD token for ACR".to_string(), None)
198            })?;
199
200        // Step 2: Exchange AAD token for an ACR refresh token.
201        // See: https://github.com/Azure/acr/blob/main/docs/AAD-OAuth.md
202        let exchange_url = format!("https://{}/oauth2/exchange", self.registry_endpoint);
203        let exchange_resp = self
204            .http_client
205            .post(&exchange_url)
206            .form(&[
207                ("grant_type", "access_token"),
208                ("service", &self.registry_endpoint),
209                ("access_token", &aad_token),
210            ])
211            .send()
212            .await
213            .into_alien_error()
214            .context(ErrorData::Other {
215                message: "ACR OAuth2 exchange request failed".to_string(),
216            })?;
217
218        if !exchange_resp.status().is_success() {
219            let status = exchange_resp.status();
220            let body = exchange_resp.text().await.unwrap_or_default();
221            return Err(AlienError::new(ErrorData::Other {
222                message: format!("ACR OAuth2 exchange failed with {}: {}", status, body),
223            }));
224        }
225
226        #[derive(serde::Deserialize)]
227        struct ExchangeResponse {
228            refresh_token: String,
229        }
230        let refresh_token = exchange_resp
231            .json::<ExchangeResponse>()
232            .await
233            .into_alien_error()
234            .context(ErrorData::Other {
235                message: "Failed to parse ACR exchange response".to_string(),
236            })?
237            .refresh_token;
238
239        // Step 3: Exchange refresh token for a scoped access token.
240        // The access token is what ACR's /v2/ API accepts as Bearer auth.
241        // Scope: "repository:{repo}:pull,push" or "repository:{repo}:pull"
242        let scope = if repo_id.is_empty() {
243            // No specific repo — request registry-wide catalog access
244            "registry:catalog:*".to_string()
245        } else {
246            let actions = match permissions {
247                ArtifactRegistryPermissions::Pull => "pull",
248                ArtifactRegistryPermissions::PushPull => "pull,push",
249            };
250            format!("repository:{}:{}", repo_id, actions)
251        };
252
253        let token_url = format!("https://{}/oauth2/token", self.registry_endpoint);
254        let token_resp = self
255            .http_client
256            .post(&token_url)
257            .form(&[
258                ("grant_type", "refresh_token"),
259                ("service", &self.registry_endpoint),
260                ("scope", &scope),
261                ("refresh_token", &refresh_token),
262            ])
263            .send()
264            .await
265            .into_alien_error()
266            .context(ErrorData::Other {
267                message: "ACR OAuth2 token request failed".to_string(),
268            })?;
269
270        if !token_resp.status().is_success() {
271            let status = token_resp.status();
272            let body = token_resp.text().await.unwrap_or_default();
273            return Err(AlienError::new(ErrorData::Other {
274                message: format!("ACR OAuth2 token failed with {}: {}", status, body),
275            }));
276        }
277
278        #[derive(serde::Deserialize)]
279        struct TokenResponse {
280            access_token: String,
281        }
282        let access_token = token_resp
283            .json::<TokenResponse>()
284            .await
285            .into_alien_error()
286            .context(ErrorData::Other {
287                message: "Failed to parse ACR token response".to_string(),
288            })?
289            .access_token;
290
291        info!(
292            registry = %self.registry_endpoint,
293            scope = %scope,
294            "ACR access token generated"
295        );
296
297        // ACR OAuth2 access tokens expire in ~5 minutes
298        let expires_at = Some((chrono::Utc::now() + chrono::Duration::seconds(300)).to_rfc3339());
299
300        Ok(ArtifactRegistryCredentials {
301            auth_method: RegistryAuthMethod::Bearer,
302            username: String::new(),
303            password: access_token,
304            expires_at,
305        })
306    }
307
308    // No-op: generate_credentials() uses the stateless AAD → refresh → access token
309    // OAuth2 flow. No persistent resources (scope maps, tokens) are created, so
310    // there is nothing to clean up.
311
312    async fn delete_repository(&self, _repo_id: &str) -> Result<()> {
313        // ACR repositories are implicit (created on push). Nothing to delete.
314        Ok(())
315    }
316}