Skip to main content

alien_bindings/providers/artifact_registry/
ecr.rs

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