Skip to main content

alien_bindings/providers/artifact_registry/
ecr.rs

1use crate::{
2    error::{map_cloud_client_error, Error, ErrorData, Result},
3    traits::{
4        ArtifactRegistry, ArtifactRegistryCredentials, ArtifactRegistryPermissions,
5        AwsCrossAccountAccess, Binding, ComputeServiceType, CrossAccountAccess,
6        CrossAccountPermissions, RepositoryResponse,
7    },
8};
9use alien_aws_clients::{
10    ecr::{
11        CreateRepositoryRequest, DescribeRepositoriesRequest, EcrApi, EcrClient,
12        GetAuthorizationTokenRequest, GetDownloadUrlForLayerRequest, GetRepositoryPolicyRequest,
13        SetRepositoryPolicyRequest,
14    },
15    sts::{AssumeRoleRequest, StsApi, StsClient},
16    AwsClientConfigExt as _, AwsCredentialProvider,
17};
18use alien_core::bindings::{ArtifactRegistryBinding, EcrArtifactRegistryBinding};
19use alien_error::{AlienError, Context, ContextError, IntoAlienError};
20use async_trait::async_trait;
21use base64::engine::{general_purpose::STANDARD as BASE64, Engine as _};
22use chrono::{DateTime, Utc};
23use serde_json::{json, Value};
24use std::collections::HashMap;
25use tracing::{info, warn};
26
27/// AWS ECR implementation of the ArtifactRegistry binding.
28#[derive(Debug)]
29pub struct EcrArtifactRegistry {
30    credentials: AwsCredentialProvider,
31    ecr_client: EcrClient,
32    binding_name: String,
33    repository_prefix: String,
34    pull_role_arn: Option<String>,
35    push_role_arn: Option<String>,
36}
37
38impl EcrArtifactRegistry {
39    /// Creates a new AWS ECR artifact registry binding from binding parameters.
40    pub async fn new(
41        binding_name: String,
42        binding: ArtifactRegistryBinding,
43        credentials: &AwsCredentialProvider,
44    ) -> Result<Self> {
45        info!(
46            binding_name = %binding_name,
47            "Initializing AWS ECR artifact registry"
48        );
49
50        let client = crate::http_client::create_http_client();
51        let ecr_client = EcrClient::new(client, credentials.clone());
52
53        // Extract values from binding
54        let config = match binding {
55            ArtifactRegistryBinding::Ecr(config) => config,
56            _ => {
57                return Err(AlienError::new(ErrorData::BindingConfigInvalid {
58                    binding_name: binding_name.clone(),
59                    reason: "Expected ECR binding, got different service type".to_string(),
60                }));
61            }
62        };
63
64        let repository_prefix = config
65            .repository_prefix
66            .into_value(&binding_name, "repository_prefix")
67            .context(ErrorData::BindingConfigInvalid {
68                binding_name: binding_name.clone(),
69                reason: "Failed to extract repository_prefix from binding".to_string(),
70            })?;
71
72        let pull_role_arn = config
73            .pull_role_arn
74            .map(|v| {
75                v.into_value(&binding_name, "pull_role_arn").context(
76                    ErrorData::BindingConfigInvalid {
77                        binding_name: binding_name.clone(),
78                        reason: "Failed to extract pull_role_arn from binding".to_string(),
79                    },
80                )
81            })
82            .transpose()?;
83
84        let push_role_arn = config
85            .push_role_arn
86            .map(|v| {
87                v.into_value(&binding_name, "push_role_arn").context(
88                    ErrorData::BindingConfigInvalid {
89                        binding_name: binding_name.clone(),
90                        reason: "Failed to extract push_role_arn from binding".to_string(),
91                    },
92                )
93            })
94            .transpose()?;
95
96        Ok(Self {
97            credentials: credentials.clone(),
98            ecr_client,
99            binding_name,
100            repository_prefix,
101            pull_role_arn,
102            push_role_arn,
103        })
104    }
105
106    /// Constructs the full repository name for ECR using the repository prefix.
107    /// If `repo_name` is empty, returns just the prefix (shared-repo pattern).
108    fn make_full_repo_name(&self, repo_name: &str) -> String {
109        if repo_name.is_empty() {
110            self.repository_prefix.clone()
111        } else if !self.repository_prefix.is_empty() {
112            format!("{}-{}", self.repository_prefix, repo_name)
113        } else {
114            repo_name.to_string()
115        }
116    }
117
118    /// Internal helper to set the complete ECR policy from an AwsCrossAccountAccess configuration
119    async fn set_full_policy(
120        &self,
121        repo_name: &str,
122        aws_access: &AwsCrossAccountAccess,
123    ) -> Result<()> {
124        self.set_full_policy_with_client(&self.ecr_client, repo_name, aws_access)
125            .await
126    }
127
128    async fn set_full_policy_with_client(
129        &self,
130        ecr_client: &EcrClient,
131        repo_name: &str,
132        aws_access: &AwsCrossAccountAccess,
133    ) -> Result<()> {
134        let mut statements = Vec::new();
135
136        // Add cross-account access for target accounts + specific role ARNs.
137        // Per AWS docs, Lambda cross-account ECR pulls require the account root
138        // as a principal (arn:aws:iam::{account}:root), not just specific roles.
139        // See: https://github.com/aws-samples/lambda-cross-account-ecr
140        {
141            let mut principals: Vec<String> = aws_access
142                .account_ids
143                .iter()
144                .map(|id| format!("arn:aws:iam::{}:root", id))
145                .collect();
146            for arn in &aws_access.role_arns {
147                if !principals.contains(arn) {
148                    principals.push(arn.clone());
149                }
150            }
151            if !principals.is_empty() {
152                statements.push(json!({
153                    "Sid": "CrossAccountRolePermission",
154                    "Effect": "Allow",
155                    "Principal": {
156                        "AWS": principals
157                    },
158                    "Action": [
159                        "ecr:BatchCheckLayerAvailability",
160                        "ecr:GetDownloadUrlForLayer",
161                        "ecr:BatchGetImage",
162                        // Required for Lambda CreateFunction: Lambda internally
163                        // verifies/sets the ECR repo policy when creating a
164                        // function with a cross-account image. The calling
165                        // principal needs these permissions on the ECR repo.
166                        "ecr:GetRepositoryPolicy",
167                        "ecr:SetRepositoryPolicy"
168                    ]
169                }));
170            }
171        }
172
173        // Add service-specific access based on compute service types
174        for service_type in &aws_access.allowed_service_types {
175            match service_type {
176                ComputeServiceType::Function => {
177                    if !aws_access.account_ids.is_empty() {
178                        // Build sourceArn patterns per AWS docs:
179                        // https://docs.aws.amazon.com/lambda/latest/dg/images-create.html
180                        // Pattern: arn:aws:lambda:{region}:{account_id}:function:*
181                        let source_arns: Vec<String> = aws_access
182                            .account_ids
183                            .iter()
184                            .flat_map(|account_id| {
185                                if aws_access.regions.is_empty() {
186                                    vec![format!("arn:aws:lambda:*:{}:function:*", account_id)]
187                                } else {
188                                    aws_access
189                                        .regions
190                                        .iter()
191                                        .map(|region| {
192                                            format!(
193                                                "arn:aws:lambda:{}:{}:function:*",
194                                                region, account_id
195                                            )
196                                        })
197                                        .collect()
198                                }
199                            })
200                            .collect();
201
202                        statements.push(json!({
203                            "Sid": "LambdaECRImageCrossAccountRetrievalPolicy",
204                            "Effect": "Allow",
205                            "Principal": {
206                                "Service": "lambda.amazonaws.com"
207                            },
208                            "Action": [
209                                "ecr:BatchGetImage",
210                                "ecr:GetDownloadUrlForLayer"
211                            ],
212                            "Condition": {
213                                "StringLike": {
214                                    "aws:sourceArn": source_arns
215                                }
216                            }
217                        }));
218                    }
219                }
220            }
221        }
222
223        // Create ECR policy JSON
224        let policy = json!({
225            "Version": "2012-10-17",
226            "Statement": statements
227        });
228
229        let request = SetRepositoryPolicyRequest::builder()
230            .repository_name(repo_name.to_string())
231            .policy_text(policy.to_string())
232            .build();
233
234        ecr_client
235            .set_repository_policy(request)
236            .await
237            .map_err(|e| {
238                map_cloud_client_error(
239                    e,
240                    format!(
241                        "Failed to set cross-account access for ECR repository '{}'",
242                        repo_name
243                    ),
244                    Some(repo_name.to_string()),
245                )
246            })?;
247
248        info!(
249            repo_name = %repo_name,
250            "ECR repository cross-account access policy updated successfully"
251        );
252        Ok(())
253    }
254}
255
256impl Binding for EcrArtifactRegistry {}
257
258#[async_trait]
259impl ArtifactRegistry for EcrArtifactRegistry {
260    fn registry_endpoint(&self) -> String {
261        format!(
262            "https://{}.dkr.ecr.{}.amazonaws.com",
263            self.credentials.account_id(),
264            self.credentials.region(),
265        )
266    }
267
268    fn upstream_repository_prefix(&self) -> String {
269        self.repository_prefix.clone()
270    }
271
272    async fn create_repository(&self, repo_name: &str) -> Result<RepositoryResponse> {
273        let full_repo_name = self.make_full_repo_name(repo_name);
274
275        info!(
276            repo_name = %repo_name,
277            full_repo_name = %full_repo_name,
278            "Creating ECR repository"
279        );
280
281        // Use push role for cross-account, or direct credentials for single-account.
282        let ecr_config = if let Some(push_role_arn) = &self.push_role_arn {
283            self.credentials
284                .config()
285                .impersonate(alien_aws_clients::AwsImpersonationConfig {
286                    role_arn: push_role_arn.clone(),
287                    session_name: Some("alien-ecr-create".to_string()),
288                    duration_seconds: None,
289                    external_id: None,
290                    target_region: None,
291                })
292                .await
293                .map_err(|e| {
294                    map_cloud_client_error(
295                        e,
296                        "Failed to assume ECR push role".to_string(),
297                        Some(repo_name.to_string()),
298                    )
299                })?
300        } else {
301            self.credentials.config().clone()
302        };
303        let ecr_client = alien_aws_clients::ecr::EcrClient::new(
304            crate::http_client::create_http_client(),
305            AwsCredentialProvider::from_config(ecr_config)
306                .await
307                .context(ErrorData::BindingSetupFailed {
308                    binding_type: "artifact_registry.ecr".to_string(),
309                    reason: "Failed to create credential provider for ECR access".to_string(),
310                })?,
311        );
312
313        let request = CreateRepositoryRequest::builder()
314            .repository_name(full_repo_name.clone())
315            .build();
316
317        let response = ecr_client.create_repository(request).await.map_err(|e| {
318            map_cloud_client_error(
319                e,
320                format!("Failed to create ECR repository '{}'", full_repo_name),
321                Some(repo_name.to_string()),
322            )
323        })?;
324
325        info!(
326            repo_name = %repo_name,
327            full_repo_name = %full_repo_name,
328            "ECR repository created successfully"
329        );
330
331        // ECR repositories are ready immediately after creation
332        let repository = &response.repository;
333        let created_at = if repository.created_at > 0.0 {
334            DateTime::from_timestamp(repository.created_at as i64, 0).map(|dt| dt.to_rfc3339())
335        } else {
336            None
337        };
338
339        Ok(RepositoryResponse {
340            name: repo_name.to_string(),
341            uri: Some(repository.repository_uri.clone()),
342            created_at,
343        })
344    }
345
346    async fn get_repository(&self, repo_id: &str) -> Result<RepositoryResponse> {
347        let full_repo_name = self.make_full_repo_name(repo_id);
348
349        info!(
350            repo_id = %repo_id,
351            full_repo_name = %full_repo_name,
352            "Getting ECR repository details"
353        );
354
355        // Assume the pull role for repository reads
356        let pull_role_arn = self.pull_role_arn.as_ref().ok_or_else(|| {
357            AlienError::new(ErrorData::BindingConfigInvalid {
358                binding_name: self.binding_name.clone(),
359                reason: "Pull role ARN not available".to_string(),
360            })
361        })?;
362        let impersonated = self
363            .credentials
364            .config()
365            .impersonate(alien_aws_clients::AwsImpersonationConfig {
366                role_arn: pull_role_arn.clone(),
367                session_name: Some("alien-ecr-describe".to_string()),
368                duration_seconds: None,
369                external_id: None,
370                target_region: None,
371            })
372            .await
373            .map_err(|e| {
374                map_cloud_client_error(
375                    e,
376                    "Failed to assume ECR pull role".to_string(),
377                    Some(repo_id.to_string()),
378                )
379            })?;
380        let ecr_client = alien_aws_clients::ecr::EcrClient::new(
381            crate::http_client::create_http_client(),
382            AwsCredentialProvider::from_config(impersonated)
383                .await
384                .context(ErrorData::BindingSetupFailed {
385                    binding_type: "artifact_registry.ecr".to_string(),
386                    reason: "Failed to create credential provider for impersonated role"
387                        .to_string(),
388                })?,
389        );
390
391        let request = DescribeRepositoriesRequest::builder()
392            .repository_names(vec![full_repo_name.clone()])
393            .build();
394
395        let response = ecr_client
396            .describe_repositories(request)
397            .await
398            .map_err(|e| {
399                map_cloud_client_error(
400                    e,
401                    format!(
402                        "Failed to get ECR repository details for '{}'",
403                        full_repo_name
404                    ),
405                    Some(repo_id.to_string()),
406                )
407            })?;
408
409        if response.repositories.is_empty() {
410            warn!(
411                repo_id = %repo_id,
412                full_repo_name = %full_repo_name,
413                "ECR repository not found"
414            );
415
416            return Err(AlienError::new(ErrorData::ResourceNotFound {
417                resource_id: repo_id.to_string(),
418            }));
419        }
420
421        let repository = &response.repositories[0];
422        let created_at = if repository.created_at > 0.0 {
423            DateTime::from_timestamp(repository.created_at as i64, 0).map(|dt| dt.to_rfc3339())
424        } else {
425            None
426        };
427
428        info!(
429            repo_id = %repo_id,
430            full_repo_name = %full_repo_name,
431            repo_uri = %repository.repository_uri,
432            "ECR repository details retrieved"
433        );
434
435        Ok(RepositoryResponse {
436            name: repository.repository_name.clone(),
437            uri: Some(repository.repository_uri.clone()),
438            created_at,
439        })
440    }
441
442    async fn add_cross_account_access(
443        &self,
444        repo_id: &str,
445        access: CrossAccountAccess,
446    ) -> Result<()> {
447        let full_repo_name = self.make_full_repo_name(repo_id);
448
449        let aws_access = match access {
450            CrossAccountAccess::Aws(aws_access) => aws_access,
451            _ => {
452                return Err(AlienError::new(ErrorData::BindingConfigInvalid {
453                    binding_name: self.binding_name.clone(),
454                    reason: "AWS artifact registry can only accept AWS cross-account access configuration".to_string(),
455                }));
456            }
457        };
458
459        info!(
460            repo_id = %repo_id,
461            full_repo_name = %full_repo_name,
462            account_ids = ?aws_access.account_ids,
463            allowed_service_types = ?aws_access.allowed_service_types,
464            role_arns = ?aws_access.role_arns,
465            "Adding ECR repository cross-account access"
466        );
467
468        // Get current permissions
469        let current_permissions = self.get_cross_account_access(repo_id).await?;
470        let current_aws_access = match current_permissions.access {
471            CrossAccountAccess::Aws(aws_access) => aws_access,
472            _ => AwsCrossAccountAccess {
473                account_ids: Vec::new(),
474                regions: Vec::new(),
475                allowed_service_types: Vec::new(),
476                role_arns: Vec::new(),
477            },
478        };
479
480        // Merge new permissions with existing ones
481        let mut merged_account_ids = current_aws_access.account_ids;
482        let mut merged_regions = current_aws_access.regions;
483        let mut merged_service_types = current_aws_access.allowed_service_types;
484        let mut merged_role_arns = current_aws_access.role_arns;
485
486        for account_id in aws_access.account_ids {
487            if !merged_account_ids.contains(&account_id) {
488                merged_account_ids.push(account_id);
489            }
490        }
491
492        for region in aws_access.regions {
493            if !merged_regions.contains(&region) {
494                merged_regions.push(region);
495            }
496        }
497
498        for service_type in aws_access.allowed_service_types {
499            if !merged_service_types.contains(&service_type) {
500                merged_service_types.push(service_type);
501            }
502        }
503
504        for role_arn in aws_access.role_arns {
505            if !merged_role_arns.contains(&role_arn) {
506                merged_role_arns.push(role_arn);
507            }
508        }
509
510        let merged_access = AwsCrossAccountAccess {
511            account_ids: merged_account_ids,
512            regions: merged_regions.clone(),
513            allowed_service_types: merged_service_types,
514            role_arns: merged_role_arns,
515        };
516
517        // Set policy on the source region's repo (where images are pushed).
518        self.set_full_policy(&full_repo_name, &merged_access)
519            .await?;
520
521        // Also set the policy on replicated repos in target regions.
522        // ECR replication copies images cross-region but NOT repo policies.
523        // Lambda in us-east-2 pulls from the us-east-2 replica, which needs
524        // its own cross-account policy.
525        let source_region = self.credentials.region().to_string();
526        for region in &merged_access.regions {
527            if *region == source_region {
528                continue; // Already set on source region above.
529            }
530            match self.credentials.with_region(region).await {
531                Ok(target_creds) => {
532                    let http_client = crate::http_client::create_http_client();
533                    let target_ecr = EcrClient::new(http_client, target_creds);
534                    match self
535                        .set_full_policy_with_client(&target_ecr, &full_repo_name, &merged_access)
536                        .await
537                    {
538                        Ok(()) => {
539                            info!(
540                                repo_name = %full_repo_name,
541                                region = %region,
542                                "ECR cross-account policy set on replicated repo"
543                            );
544                        }
545                        Err(e) => {
546                            warn!(
547                                repo_name = %full_repo_name,
548                                region = %region,
549                                error = %e,
550                                "Failed to set ECR policy on replicated repo (may not exist yet)"
551                            );
552                        }
553                    }
554                }
555                Err(e) => {
556                    warn!(
557                        region = %region,
558                        error = %e,
559                        "Failed to create credentials for target region"
560                    );
561                }
562            }
563        }
564
565        Ok(())
566    }
567
568    async fn remove_cross_account_access(
569        &self,
570        repo_id: &str,
571        access: CrossAccountAccess,
572    ) -> Result<()> {
573        let full_repo_name = self.make_full_repo_name(repo_id);
574
575        let aws_access = match access {
576            CrossAccountAccess::Aws(aws_access) => aws_access,
577            _ => {
578                return Err(AlienError::new(ErrorData::BindingConfigInvalid {
579                    binding_name: self.binding_name.clone(),
580                    reason: "AWS artifact registry can only accept AWS cross-account access configuration".to_string(),
581                }));
582            }
583        };
584
585        info!(
586            repo_id = %repo_id,
587            full_repo_name = %full_repo_name,
588            account_ids = ?aws_access.account_ids,
589            allowed_service_types = ?aws_access.allowed_service_types,
590            role_arns = ?aws_access.role_arns,
591            "Removing ECR repository cross-account access"
592        );
593
594        // Get current permissions
595        let current_permissions = self.get_cross_account_access(repo_id).await?;
596        let current_aws_access = match current_permissions.access {
597            CrossAccountAccess::Aws(aws_access) => aws_access,
598            _ => {
599                // No existing permissions to remove from
600                info!(repo_id = %repo_id, full_repo_name = %full_repo_name, "No existing AWS cross-account permissions to remove");
601                return Ok(());
602            }
603        };
604
605        let mut filtered_account_ids = current_aws_access.account_ids;
606        let mut filtered_regions = current_aws_access.regions;
607        let mut filtered_service_types = current_aws_access.allowed_service_types;
608        let mut filtered_role_arns = current_aws_access.role_arns;
609
610        filtered_account_ids.retain(|id| !aws_access.account_ids.contains(id));
611        filtered_regions.retain(|r| !aws_access.regions.contains(r));
612        filtered_service_types
613            .retain(|service_type| !aws_access.allowed_service_types.contains(service_type));
614        filtered_role_arns.retain(|arn| !aws_access.role_arns.contains(arn));
615
616        let filtered_access = AwsCrossAccountAccess {
617            account_ids: filtered_account_ids,
618            regions: filtered_regions,
619            allowed_service_types: filtered_service_types,
620            role_arns: filtered_role_arns,
621        };
622
623        self.set_full_policy(&full_repo_name, &filtered_access)
624            .await
625    }
626
627    async fn get_cross_account_access(&self, repo_id: &str) -> Result<CrossAccountPermissions> {
628        let full_repo_name = self.make_full_repo_name(repo_id);
629
630        info!(
631            repo_id = %repo_id,
632            full_repo_name = %full_repo_name,
633            "Getting ECR repository cross-account access"
634        );
635
636        let request = GetRepositoryPolicyRequest::builder()
637            .repository_name(full_repo_name.clone())
638            .build();
639
640        let response = self
641            .ecr_client
642            .get_repository_policy(request)
643            .await
644            .map_err(|e| {
645                warn!(
646                    repo_id = %repo_id,
647                    full_repo_name = %full_repo_name,
648                    error = %e,
649                    "Failed to get ECR repository policy (repository may not have a policy)"
650                );
651                e
652            });
653
654        let response = match response {
655            Ok(response) => response,
656            Err(_) => {
657                return Ok(CrossAccountPermissions {
658                    access: CrossAccountAccess::Aws(AwsCrossAccountAccess {
659                        account_ids: Vec::new(),
660                        regions: Vec::new(),
661                        allowed_service_types: Vec::new(),
662                        role_arns: Vec::new(),
663                    }),
664                    last_updated: None,
665                });
666            }
667        };
668
669        // Parse the policy JSON to extract role ARNs, account IDs, and resource types
670        let policy: Value = serde_json::from_str(&response.policy_text)
671            .into_alien_error()
672            .context(ErrorData::UnexpectedResponseFormat {
673                provider: "aws".to_string(),
674                binding_name: "artifact_registry".to_string(),
675                field: "policy_text".to_string(),
676                response_json: response.policy_text.clone(),
677            })?;
678
679        let mut account_ids = Vec::new();
680        let mut role_arns = Vec::new();
681        let mut allowed_service_types = Vec::new();
682
683        if let Some(statements) = policy["Statement"].as_array() {
684            for statement in statements {
685                // Check for cross-account role permissions
686                if statement["Sid"] == "CrossAccountRolePermission" {
687                    if let Some(principals) = statement["Principal"]["AWS"].as_array() {
688                        for principal in principals {
689                            if let Some(principal_str) = principal.as_str() {
690                                // AWS replaces deleted role ARNs with role unique IDs (e.g. "AROA...")
691                                // in existing policies. Filter these out to avoid "Principal not found"
692                                // errors when rewriting the policy.
693                                if !principal_str.starts_with("arn:") {
694                                    warn!(
695                                        principal = %principal_str,
696                                        "Skipping stale principal in ECR policy (deleted role replaced by unique ID)"
697                                    );
698                                    continue;
699                                }
700                                role_arns.push(principal_str.to_string());
701                                // Extract account ID from role ARN: arn:aws:iam::ACCOUNT_ID:role/RoleName
702                                if let Some(account_id) = principal_str.split(':').nth(4) {
703                                    account_ids.push(account_id.to_string());
704                                }
705                            }
706                        }
707                    } else if let Some(principal) = statement["Principal"]["AWS"].as_str() {
708                        if !principal.starts_with("arn:") {
709                            warn!(
710                                principal = %principal,
711                                "Skipping stale principal in ECR policy (deleted role replaced by unique ID)"
712                            );
713                        } else {
714                            role_arns.push(principal.to_string());
715                            if let Some(account_id) = principal.split(':').nth(4) {
716                                account_ids.push(account_id.to_string());
717                            }
718                        }
719                    }
720                }
721
722                // Check for Lambda service access (both old and new Sid names)
723                if statement["Sid"] == "LambdaECRImageCrossAccountRetrievalPolicy"
724                    || statement["Sid"] == "LambdaServiceAccess"
725                {
726                    if statement["Principal"]["Service"] == "lambda.amazonaws.com" {
727                        allowed_service_types.push(ComputeServiceType::Function);
728                    }
729                }
730            }
731        }
732
733        // Remove duplicates
734        account_ids.sort();
735        account_ids.dedup();
736        role_arns.sort();
737        role_arns.dedup();
738        allowed_service_types.sort_by_key(|rt| format!("{:?}", rt));
739        allowed_service_types.dedup();
740
741        info!(
742            repo_id = %repo_id,
743            full_repo_name = %full_repo_name,
744            account_ids = ?account_ids,
745            role_arns = ?role_arns,
746            allowed_service_types = ?allowed_service_types,
747            "Retrieved ECR repository cross-account access"
748        );
749
750        Ok(CrossAccountPermissions {
751            access: CrossAccountAccess::Aws(AwsCrossAccountAccess {
752                account_ids,
753                regions: Vec::new(),
754                allowed_service_types,
755                role_arns,
756            }),
757            last_updated: None,
758        })
759    }
760
761    async fn generate_credentials(
762        &self,
763        repo_id: &str,
764        permissions: ArtifactRegistryPermissions,
765        ttl_seconds: Option<u32>,
766    ) -> Result<ArtifactRegistryCredentials> {
767        info!(
768            repo_id = %repo_id,
769            permissions = ?permissions,
770            ttl_seconds = ?ttl_seconds,
771            "Generating ECR credentials by assuming role"
772        );
773
774        // Get the role ARN (optional for single-account deployments)
775        let role_arn = match permissions {
776            ArtifactRegistryPermissions::Pull => self.pull_role_arn.as_ref(),
777            ArtifactRegistryPermissions::PushPull => self.push_role_arn.as_ref(),
778        };
779
780        // When a role ARN is configured, assume it for cross-account access.
781        // When no role is configured (single-account), use base credentials directly.
782        let ecr_config = if let Some(role_arn) = role_arn {
783            info!(role_arn = %role_arn, "Assuming role for ECR access");
784            self.credentials
785                .config()
786                .impersonate(alien_aws_clients::AwsImpersonationConfig {
787                    role_arn: role_arn.clone(),
788                    session_name: Some(format!(
789                        "alien-ecr-access-{}",
790                        chrono::Utc::now().timestamp()
791                    )),
792                    duration_seconds: ttl_seconds.map(|ttl| ttl.min(43200) as i32),
793                    external_id: None,
794                    target_region: None,
795                })
796                .await
797                .map_err(|e| {
798                    map_cloud_client_error(
799                        e,
800                        "Failed to assume ECR access role".to_string(),
801                        Some(repo_id.to_string()),
802                    )
803                })?
804        } else {
805            info!("Using direct credentials for ECR access (no role configured)");
806            self.credentials.config().clone()
807        };
808
809        // Create ECR client with resolved credentials
810        let ecr_client = alien_aws_clients::ecr::EcrClient::new(
811            crate::http_client::create_http_client(),
812            AwsCredentialProvider::from_config(ecr_config)
813                .await
814                .context(ErrorData::BindingSetupFailed {
815                    binding_type: "artifact_registry.ecr".to_string(),
816                    reason: "Failed to create credential provider for ECR access".to_string(),
817                })?,
818        );
819
820        // Get ECR authorization token
821        let request = alien_aws_clients::ecr::GetAuthorizationTokenRequest::builder().build();
822
823        let response = ecr_client
824            .get_authorization_token(request)
825            .await
826            .map_err(|e| {
827                map_cloud_client_error(
828                    e,
829                    "Failed to get ECR authorization token with assumed role".to_string(),
830                    Some(repo_id.to_string()),
831                )
832            })?;
833
834        if let Some(auth_data) = response.authorization_data.first() {
835            // Decode the base64 authorization token
836            let token_bytes = BASE64
837                .decode(&auth_data.authorization_token)
838                .into_alien_error()
839                .context(ErrorData::UnexpectedResponseFormat {
840                    provider: "aws".to_string(),
841                    binding_name: "artifact_registry".to_string(),
842                    field: "authorization_token".to_string(),
843                    response_json: auth_data.authorization_token.clone(),
844                })?;
845
846            let token_str = String::from_utf8(token_bytes.clone())
847                .into_alien_error()
848                .context(ErrorData::UnexpectedResponseFormat {
849                    provider: "aws".to_string(),
850                    binding_name: "artifact_registry".to_string(),
851                    field: "authorization_token".to_string(),
852                    response_json: format!("{:?}", token_bytes),
853                })?;
854
855            // Token format is "username:password"
856            if let Some((username, password)) = token_str.split_once(':') {
857                let expires_at = if ttl_seconds.is_some() || auth_data.expires_at > 0.0 {
858                    DateTime::from_timestamp(auth_data.expires_at as i64, 0)
859                        .map(|dt| dt.to_rfc3339())
860                } else {
861                    None
862                };
863
864                info!(
865                    permissions = ?permissions,
866                    "ECR authorization token generated successfully with assumed role"
867                );
868
869                Ok(ArtifactRegistryCredentials {
870                    username: username.to_string(),
871                    password: password.to_string(),
872                    expires_at,
873                })
874            } else {
875                Err(AlienError::new(ErrorData::UnexpectedResponseFormat {
876                    provider: "aws".to_string(),
877                    binding_name: "artifact_registry".to_string(),
878                    field: "authorization_token".to_string(),
879                    response_json: token_str.to_string(),
880                }))
881            }
882        } else {
883            Err(AlienError::new(ErrorData::CloudPlatformError {
884                message: "ECR authorization response did not contain authorization data"
885                    .to_string(),
886                resource_id: Some(repo_id.to_string()),
887            }))
888        }
889    }
890
891    async fn delete_repository(&self, repo_id: &str) -> Result<()> {
892        let full_repo_name = self.make_full_repo_name(repo_id);
893
894        info!(
895            repo_id = %repo_id,
896            full_repo_name = %full_repo_name,
897            "Deleting ECR repository"
898        );
899
900        // Use push role for cross-account, or direct credentials for single-account.
901        let ecr_config = if let Some(push_role_arn) = &self.push_role_arn {
902            self.credentials
903                .config()
904                .impersonate(alien_aws_clients::AwsImpersonationConfig {
905                    role_arn: push_role_arn.clone(),
906                    session_name: Some("alien-ecr-delete".to_string()),
907                    duration_seconds: None,
908                    external_id: None,
909                    target_region: None,
910                })
911                .await
912                .map_err(|e| {
913                    map_cloud_client_error(
914                        e,
915                        "Failed to assume ECR push role".to_string(),
916                        Some(repo_id.to_string()),
917                    )
918                })?
919        } else {
920            self.credentials.config().clone()
921        };
922        let ecr_client = alien_aws_clients::ecr::EcrClient::new(
923            crate::http_client::create_http_client(),
924            AwsCredentialProvider::from_config(ecr_config)
925                .await
926                .context(ErrorData::BindingSetupFailed {
927                    binding_type: "artifact_registry.ecr".to_string(),
928                    reason: "Failed to create credential provider for ECR access".to_string(),
929                })?,
930        );
931
932        let request = alien_aws_clients::ecr::DeleteRepositoryRequest::builder()
933            .repository_name(full_repo_name.clone())
934            .force(true)
935            .build();
936
937        ecr_client.delete_repository(request).await.map_err(|e| {
938            map_cloud_client_error(
939                e,
940                format!("Failed to delete ECR repository '{}'", full_repo_name),
941                Some(repo_id.to_string()),
942            )
943        })?;
944
945        info!(
946            repo_id = %repo_id,
947            full_repo_name = %full_repo_name,
948            "ECR repository deleted successfully"
949        );
950        Ok(())
951    }
952
953    async fn generate_blob_download_url(
954        &self,
955        repo_name: &str,
956        digest: &str,
957        _ttl_seconds: u32,
958    ) -> Result<Option<String>> {
959        let full_repo_name = self.make_full_repo_name(repo_name);
960
961        let request = GetDownloadUrlForLayerRequest::builder()
962            .repository_name(full_repo_name.clone())
963            .layer_digest(digest.to_string())
964            .build();
965
966        let response = self
967            .ecr_client
968            .get_download_url_for_layer(request)
969            .await
970            .map_err(|e| {
971                map_cloud_client_error(
972                    e,
973                    format!(
974                        "Failed to get download URL for layer '{}' in repository '{}'",
975                        digest, full_repo_name
976                    ),
977                    Some(repo_name.to_string()),
978                )
979            })?;
980
981        Ok(Some(response.download_url))
982    }
983}