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