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