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