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
36impl EcrArtifactRegistry {
37 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 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 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 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 {
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 "ecr:GetRepositoryPolicy",
239 "ecr:SetRepositoryPolicy"
240 ]
241 }));
242 }
243 }
244
245 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 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 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 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 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 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 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 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 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 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(®ion) {
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 self.set_full_policy(&full_repo_name, &merged_access)
682 .await?;
683
684 let source_region = self.credentials.region().to_string();
689 for region in &merged_access.regions {
690 if *region == source_region {
691 continue; }
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}