Skip to main content

alien_bindings/providers/artifact_registry/
ecr.rs

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