1use std::future::Future;
61use std::pin::Pin;
62use std::sync::{Arc, Mutex};
63
64use chrono::{DateTime, Utc};
65use greentic_deploy_spec::SecretRef;
66use serde::{Deserialize, Serialize};
67use zeroize::Zeroizing;
68
69use crate::credentials::{
70 BootstrapError, BootstrapInput, BootstrapOutcome, Capability, CapabilityCheck,
71 CapabilityStatus, DeployerCredentials, RequirementsReport, ValidationContext,
72};
73
74use super::bootstrap::{IamRulesPackInput, render_min_iam_rules_pack};
75
76pub const AWS_STS_CALLER_IDENTITY_CAP: &str = "aws.sts.caller-identity";
78
79pub const VALIDATED_IAM_VERBS: &[&str] = &[
91 "sts:GetCallerIdentity",
93 "iam:SimulatePrincipalPolicy",
94 "ecs:DescribeServices",
97 "ecs:ListServices",
98 "ecs:CreateService",
99 "ecs:UpdateService",
100 "ecs:RegisterTaskDefinition",
101 "ecs:CreateTaskSet",
102 "ecs:DescribeTaskSets",
103 "ecs:DeleteTaskSet",
104 "ecs:DeregisterTaskDefinition",
105 "ecr:PutImage",
107 "iam:PassRole",
108 "elasticloadbalancing:DescribeTargetGroups",
112 "elasticloadbalancing:ModifyListener",
113 "elasticloadbalancing:DescribeRules",
114 "elasticloadbalancing:CreateRule",
115 "elasticloadbalancing:ModifyRule",
116 "elasticloadbalancing:AddTags",
117 "elasticloadbalancing:DescribeTags",
118];
119
120fn iam_verb_capability_id(verb: &str) -> String {
122 format!("aws.iam.allow:{verb}")
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct CallerIdentity {
128 pub arn: String,
129 pub account: String,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
135pub enum IamDecision {
136 Allowed,
138 Denied(String),
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct ActionDecision {
146 pub action: String,
147 pub decision: IamDecision,
148}
149
150#[derive(Debug, thiserror::Error)]
156pub enum AwsClientError {
157 #[error("AWS credential chain resolved no usable credentials: {0}")]
158 NoCredentialChain(String),
159 #[error("AWS STS rejected the credentials: {0}")]
160 StsRejected(String),
161 #[error("AWS IAM rejected the policy simulation: {0}")]
162 IamRejected(String),
163 #[error("AWS SDK transport error: {0}")]
164 Transport(String),
165}
166
167#[async_trait::async_trait]
173pub trait AwsValidatorClient: std::fmt::Debug + Send + Sync {
174 async fn get_caller_identity(&self) -> Result<CallerIdentity, AwsClientError>;
175
176 async fn simulate_principal_policy<'a>(
188 &'a self,
189 principal_arn: &'a str,
190 actions: &'a [&'a str],
191 ) -> Result<Vec<ActionDecision>, AwsClientError>;
192}
193
194const STS_SESSION_DURATION_SECONDS: i32 = 3600;
202
203pub(crate) const DEPLOYER_SESSION_STORE_PATH: &str = "default/_/aws-deployer/deployer_session";
208
209#[derive(Clone, Serialize, Deserialize)]
220pub struct AssumedSession {
221 pub access_key_id: String,
222 pub secret_access_key: String,
223 pub session_token: String,
224 pub expiration: DateTime<Utc>,
226 pub issued_at: DateTime<Utc>,
228}
229
230impl std::fmt::Debug for AssumedSession {
231 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
232 f.debug_struct("AssumedSession")
234 .field("access_key_id", &self.access_key_id)
235 .field("secret_access_key", &"<redacted>")
236 .field("session_token", &"<redacted>")
237 .field("expiration", &self.expiration)
238 .field("issued_at", &self.issued_at)
239 .finish()
240 }
241}
242
243pub type AwsBootstrapConnectFut =
245 Pin<Box<dyn Future<Output = Result<Arc<dyn AwsBootstrapClient>, AwsClientError>> + Send>>;
246
247pub type AwsBootstrapConnector = Arc<dyn Fn() -> AwsBootstrapConnectFut + Send + Sync>;
252
253#[async_trait::async_trait]
258pub trait AwsBootstrapClient: std::fmt::Debug + Send + Sync {
259 async fn assume_role(
262 &self,
263 role_arn: &str,
264 session_name: &str,
265 duration_seconds: i32,
266 ) -> Result<AssumedSession, AwsClientError>;
267}
268
269#[derive(Debug)]
273struct RealAwsClient {
274 sts: aws_sdk_sts::Client,
275 iam: aws_sdk_iam::Client,
276}
277
278impl RealAwsClient {
279 async fn resolve() -> Result<Self, AwsClientError> {
286 let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
287 let creds_provider = config.credentials_provider().ok_or_else(|| {
291 AwsClientError::NoCredentialChain(
292 "no AWS credentials provider in the resolved SDK config — set AWS_PROFILE or \
293 AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY"
294 .to_string(),
295 )
296 })?;
297 use aws_sdk_sts::config::ProvideCredentials;
302 creds_provider
303 .provide_credentials()
304 .await
305 .map_err(|e| AwsClientError::NoCredentialChain(e.to_string()))?;
306
307 let sts = aws_sdk_sts::Client::new(&config);
308 let iam = aws_sdk_iam::Client::new(&config);
309 Ok(Self { sts, iam })
310 }
311}
312
313#[async_trait::async_trait]
314impl AwsValidatorClient for RealAwsClient {
315 async fn get_caller_identity(&self) -> Result<CallerIdentity, AwsClientError> {
316 let out = self
317 .sts
318 .get_caller_identity()
319 .send()
320 .await
321 .map_err(|e| AwsClientError::StsRejected(format!("{e}")))?;
322 let arn = out.arn().ok_or_else(|| {
323 AwsClientError::StsRejected("STS returned no ARN for the caller".to_string())
324 })?;
325 let account = out.account().ok_or_else(|| {
326 AwsClientError::StsRejected("STS returned no account for the caller".to_string())
327 })?;
328 Ok(CallerIdentity {
329 arn: arn.to_string(),
330 account: account.to_string(),
331 })
332 }
333
334 async fn simulate_principal_policy<'a>(
335 &'a self,
336 principal_arn: &'a str,
337 actions: &'a [&'a str],
338 ) -> Result<Vec<ActionDecision>, AwsClientError> {
339 let action_names: Vec<String> = actions.iter().map(|a| (*a).to_string()).collect();
341 let out = self
342 .iam
343 .simulate_principal_policy()
344 .policy_source_arn(principal_arn)
345 .set_action_names(Some(action_names))
346 .send()
347 .await
348 .map_err(|e| AwsClientError::IamRejected(format!("{e}")))?;
349
350 let mut by_action: std::collections::HashMap<&str, IamDecision> =
357 std::collections::HashMap::with_capacity(out.evaluation_results().len());
358 for r in out.evaluation_results() {
359 let decision = r.eval_decision().as_str();
360 let interp = if decision.eq_ignore_ascii_case("allowed") {
361 IamDecision::Allowed
362 } else {
363 IamDecision::Denied(decision.to_string())
364 };
365 by_action.insert(r.eval_action_name(), interp);
366 }
367 let mut out = Vec::with_capacity(actions.len());
368 for action in actions {
369 let decision = by_action.get(*action).cloned().ok_or_else(|| {
370 AwsClientError::IamRejected(format!(
371 "IAM SimulatePrincipalPolicy returned no decision for `{action}`"
372 ))
373 })?;
374 out.push(ActionDecision {
375 action: (*action).to_string(),
376 decision,
377 });
378 }
379 Ok(out)
380 }
381}
382
383#[derive(Debug)]
389pub(crate) struct RealAwsBootstrapClient {
390 sts: aws_sdk_sts::Client,
391}
392
393impl RealAwsBootstrapClient {
394 pub(crate) async fn resolve(admin_profile: &str) -> Result<Self, AwsClientError> {
399 let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
400 .profile_name(admin_profile)
401 .load()
402 .await;
403 let creds_provider = config.credentials_provider().ok_or_else(|| {
404 AwsClientError::NoCredentialChain(format!(
405 "AWS profile `{admin_profile}` resolved no credentials provider"
406 ))
407 })?;
408 use aws_sdk_sts::config::ProvideCredentials;
409 creds_provider
410 .provide_credentials()
411 .await
412 .map_err(|e| AwsClientError::NoCredentialChain(e.to_string()))?;
413 Ok(Self {
414 sts: aws_sdk_sts::Client::new(&config),
415 })
416 }
417}
418
419#[async_trait::async_trait]
420impl AwsBootstrapClient for RealAwsBootstrapClient {
421 async fn assume_role(
422 &self,
423 role_arn: &str,
424 session_name: &str,
425 duration_seconds: i32,
426 ) -> Result<AssumedSession, AwsClientError> {
427 let out = self
428 .sts
429 .assume_role()
430 .role_arn(role_arn)
431 .role_session_name(session_name)
432 .duration_seconds(duration_seconds)
433 .send()
434 .await
435 .map_err(|e| AwsClientError::StsRejected(format!("AssumeRole failed: {e}")))?;
436 let creds = out.credentials().ok_or_else(|| {
437 AwsClientError::StsRejected("AssumeRole returned no credentials".to_string())
438 })?;
439 let exp = creds.expiration();
441 let expiration =
442 DateTime::from_timestamp(exp.secs(), exp.subsec_nanos()).ok_or_else(|| {
443 AwsClientError::StsRejected(
444 "AssumeRole returned an out-of-range expiration".to_string(),
445 )
446 })?;
447 Ok(AssumedSession {
448 access_key_id: creds.access_key_id().to_string(),
449 secret_access_key: creds.secret_access_key().to_string(),
450 session_token: creds.session_token().to_string(),
451 expiration,
452 issued_at: Utc::now(),
453 })
454 }
455}
456
457struct AwsBootstrapBind {
462 role_arn: String,
465 connect: AwsBootstrapConnector,
466}
467
468#[derive(Default)]
477pub struct AwsDeployerCredentials {
478 client: Mutex<Option<Arc<dyn AwsValidatorClient>>>,
479 bind: Option<AwsBootstrapBind>,
480}
481
482impl std::fmt::Debug for AwsDeployerCredentials {
483 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
484 f.debug_struct("AwsDeployerCredentials")
486 .field("client", &"<lazy>")
487 .field("bind", &self.bind.as_ref().map(|b| &b.role_arn))
488 .finish()
489 }
490}
491
492impl AwsDeployerCredentials {
493 pub fn with_client(client: Arc<dyn AwsValidatorClient>) -> Self {
496 Self {
497 client: Mutex::new(Some(client)),
498 bind: None,
499 }
500 }
501
502 pub fn with_bootstrap_assume(
507 role_arn: impl Into<String>,
508 connect: AwsBootstrapConnector,
509 ) -> Self {
510 Self {
511 client: Mutex::new(None),
512 bind: Some(AwsBootstrapBind {
513 role_arn: role_arn.into(),
514 connect,
515 }),
516 }
517 }
518
519 fn resolve_client(&self) -> Result<Arc<dyn AwsValidatorClient>, AwsClientError> {
522 if let Some(c) = self.client.lock().expect("mutex not poisoned").as_ref() {
524 return Ok(Arc::clone(c));
525 }
526 let built = run_aws_async(RealAwsClient::resolve())?;
530 let arc: Arc<dyn AwsValidatorClient> = Arc::new(built);
531 let mut slot = self.client.lock().expect("mutex not poisoned");
532 if let Some(c) = slot.as_ref() {
535 return Ok(Arc::clone(c));
536 }
537 *slot = Some(Arc::clone(&arc));
538 Ok(arc)
539 }
540
541 fn caller_identity_capability(&self) -> Capability {
542 Capability::new(
543 AWS_STS_CALLER_IDENTITY_CAP,
544 "AWS credential chain resolves to a caller identity (STS GetCallerIdentity)",
545 )
546 }
547
548 fn iam_verb_capability(&self, verb: &str) -> Capability {
549 Capability::new(
550 iam_verb_capability_id(verb),
551 format!("IAM principal is allowed to perform `{verb}`"),
552 )
553 }
554
555 fn sts_pass_verbs_failed(&self, reason: &str) -> RequirementsReport {
563 let mut checks = Vec::with_capacity(1 + VALIDATED_IAM_VERBS.len());
564 checks.push(CapabilityCheck {
565 capability: self.caller_identity_capability(),
566 status: CapabilityStatus::Pass,
567 });
568 for verb in VALIDATED_IAM_VERBS {
569 checks.push(CapabilityCheck {
570 capability: self.iam_verb_capability(verb),
571 status: CapabilityStatus::Fail {
572 reason: reason.to_string(),
573 },
574 });
575 }
576 RequirementsReport::new(checks)
577 }
578}
579
580impl DeployerCredentials for AwsDeployerCredentials {
581 fn requires_credentials_material(&self) -> bool {
582 true
583 }
584
585 fn rotate_at(&self, material: &str) -> Option<DateTime<Utc>> {
591 let session: AssumedSession = serde_json::from_str(material).ok()?;
592 Some(crate::credentials::rotate::rotate_at_from_window(
593 session.issued_at,
594 session.expiration,
595 ))
596 }
597
598 fn required_capabilities(&self) -> Vec<Capability> {
599 let mut caps = Vec::with_capacity(1 + VALIDATED_IAM_VERBS.len());
600 caps.push(self.caller_identity_capability());
601 for verb in VALIDATED_IAM_VERBS {
602 caps.push(self.iam_verb_capability(verb));
603 }
604 caps
605 }
606
607 fn validate(&self, _ctx: &ValidationContext<'_>) -> RequirementsReport {
608 let caps = self.required_capabilities();
610
611 let client = match self.resolve_client() {
612 Ok(c) => c,
613 Err(AwsClientError::NoCredentialChain(reason)) => {
614 return all_failed(&caps, &reason);
620 }
621 Err(e) => {
622 return all_failed(&caps, &e.to_string());
623 }
624 };
625
626 let arn = match run_aws_async(client.get_caller_identity()) {
627 Ok(id) => id.arn,
628 Err(e) => {
629 return all_failed(&caps, &format!("STS GetCallerIdentity failed: {e}"));
633 }
634 };
635
636 let decisions =
638 match run_aws_async(client.simulate_principal_policy(&arn, VALIDATED_IAM_VERBS)) {
639 Ok(v) => v,
640 Err(e) => {
641 return self.sts_pass_verbs_failed(&format!(
644 "IAM SimulatePrincipalPolicy failed: {e}"
645 ));
646 }
647 };
648
649 let mut checks = Vec::with_capacity(1 + decisions.len());
650 checks.push(CapabilityCheck {
651 capability: self.caller_identity_capability(),
652 status: CapabilityStatus::Pass,
653 });
654 for (verb, decision) in VALIDATED_IAM_VERBS.iter().zip(decisions.iter()) {
655 let status = match &decision.decision {
656 IamDecision::Allowed => CapabilityStatus::Pass,
657 IamDecision::Denied(raw) => CapabilityStatus::Fail {
658 reason: format!("IAM denied `{verb}` ({raw})"),
659 },
660 };
661 checks.push(CapabilityCheck {
662 capability: self.iam_verb_capability(verb),
663 status,
664 });
665 }
666 RequirementsReport::new(checks)
667 }
668
669 fn bootstrap(&self, input: &BootstrapInput<'_>) -> Result<BootstrapOutcome, BootstrapError> {
670 let admin_profile = input.admin.profile();
675 if admin_profile.is_empty() {
676 return Err(BootstrapError::AdminRejected(
677 "AWS bootstrap requires --admin-profile to identify the trust principal; \
678 pass an IAM role/user ARN or a named AWS profile that will execute the rules \
679 pack."
680 .to_string(),
681 ));
682 }
683
684 let rules_pack = render_min_iam_rules_pack(&IamRulesPackInput {
685 env_id: input.env_id.as_str(),
686 admin_identity_hint: admin_profile,
687 allowed_actions: VALIDATED_IAM_VERBS,
688 });
689
690 let Some(bind) = self.bind.as_ref() else {
695 return Ok(BootstrapOutcome {
696 rules_pack,
697 bound_credentials_ref: None,
698 bound_expiry: None,
699 bound_secret_material: None,
700 });
701 };
702
703 let connector = Arc::clone(&bind.connect);
709 let role_arn = bind.role_arn.clone();
710 let session_name = sts_session_name(input.env_id.as_str());
711 let session = run_aws_async(async move {
712 let client = connector().await?;
713 client
714 .assume_role(&role_arn, &session_name, STS_SESSION_DURATION_SECONDS)
715 .await
716 })
717 .map_err(|e: AwsClientError| BootstrapError::ProvisioningFailed {
718 step: "aws-assume-role".to_string(),
719 message: e.to_string(),
720 })?;
721
722 let bound_ref = SecretRef::try_new(format!(
723 "secret://{}/{}",
724 input.env_id.as_str(),
725 DEPLOYER_SESSION_STORE_PATH
726 ))
727 .map_err(|e| BootstrapError::ProvisioningFailed {
728 step: "bind-ref".to_string(),
729 message: format!("bound credentials ref is not well-formed: {e}"),
730 })?;
731
732 let bound_expiry = Some(session.expiration);
733 let material =
737 serde_json::to_string(&session).map_err(|e| BootstrapError::ProvisioningFailed {
738 step: "serialize-session".to_string(),
739 message: format!("could not serialize the assumed session: {e}"),
740 })?;
741
742 Ok(BootstrapOutcome {
743 rules_pack,
744 bound_credentials_ref: Some(bound_ref),
745 bound_expiry,
746 bound_secret_material: Some(Zeroizing::new(material)),
747 })
748 }
749}
750
751fn sts_session_name(env_id: &str) -> String {
755 format!("greentic-deployer-{env_id}")
756}
757
758fn all_failed(caps: &[Capability], reason: &str) -> RequirementsReport {
763 RequirementsReport::new(
764 caps.iter()
765 .map(|c| CapabilityCheck {
766 capability: c.clone(),
767 status: CapabilityStatus::Fail {
768 reason: reason.to_string(),
769 },
770 })
771 .collect(),
772 )
773}
774
775pub(crate) fn run_aws_async<F, T>(fut: F) -> T
791where
792 F: std::future::Future<Output = T> + Send,
793 T: Send,
794{
795 std::thread::scope(|scope| {
796 scope
797 .spawn(|| {
798 let rt = tokio::runtime::Builder::new_current_thread()
799 .enable_all()
800 .build()
801 .expect("build current-thread tokio runtime");
802 rt.block_on(fut)
803 })
804 .join()
805 .expect("AWS validate thread did not panic")
806 })
807}
808
809#[cfg(test)]
810mod tests {
811 use super::*;
812 use crate::credentials::ZeroizedAdmin;
813 use greentic_deploy_spec::{EnvId, EnvironmentHostConfig};
814 use std::path::Path;
815 use std::sync::Mutex;
816 use tempfile::tempdir;
817
818 fn default_host_config(env_id: &EnvId) -> EnvironmentHostConfig {
819 EnvironmentHostConfig {
820 env_id: env_id.clone(),
821 region: None,
822 tenant_org_id: None,
823 listen_addr: None,
824 public_base_url: None,
825 gui_enabled: None,
826 }
827 }
828
829 fn ctx<'a>(
830 env_root: &'a Path,
831 env_id: &'a EnvId,
832 host_config: &'a EnvironmentHostConfig,
833 ) -> ValidationContext<'a> {
834 ValidationContext {
835 env_id,
836 env_root,
837 host_config,
838 }
839 }
840
841 #[derive(Debug, Default)]
843 struct MockAwsClient {
844 sts_response: Mutex<Option<Result<CallerIdentity, AwsClientError>>>,
845 simulate_response: Mutex<Option<Result<Vec<ActionDecision>, AwsClientError>>>,
846 simulate_calls: Mutex<Vec<(String, Vec<String>)>>,
847 }
848
849 impl MockAwsClient {
850 fn with_sts(self, r: Result<CallerIdentity, AwsClientError>) -> Self {
851 *self.sts_response.lock().unwrap() = Some(r);
852 self
853 }
854 fn with_simulate(self, r: Result<Vec<ActionDecision>, AwsClientError>) -> Self {
855 *self.simulate_response.lock().unwrap() = Some(r);
856 self
857 }
858 }
859
860 #[async_trait::async_trait]
861 impl AwsValidatorClient for MockAwsClient {
862 async fn get_caller_identity(&self) -> Result<CallerIdentity, AwsClientError> {
863 self.sts_response
864 .lock()
865 .unwrap()
866 .take()
867 .expect("test must wire sts_response")
868 }
869 async fn simulate_principal_policy<'a>(
870 &'a self,
871 principal_arn: &'a str,
872 actions: &'a [&'a str],
873 ) -> Result<Vec<ActionDecision>, AwsClientError> {
874 let snapshot: Vec<String> = actions.iter().map(|a| (*a).to_string()).collect();
878 self.simulate_calls
879 .lock()
880 .unwrap()
881 .push((principal_arn.to_string(), snapshot));
882 self.simulate_response
883 .lock()
884 .unwrap()
885 .take()
886 .expect("test must wire simulate_response")
887 }
888 }
889
890 fn arn_user() -> CallerIdentity {
891 CallerIdentity {
892 arn: "arn:aws:iam::111122223333:user/cust-admin".into(),
893 account: "111122223333".into(),
894 }
895 }
896
897 fn all_allowed_decisions() -> Vec<ActionDecision> {
898 VALIDATED_IAM_VERBS
899 .iter()
900 .map(|v| ActionDecision {
901 action: v.to_string(),
902 decision: IamDecision::Allowed,
903 })
904 .collect()
905 }
906
907 #[derive(Debug, Default)]
910 struct MockBootstrapClient {
911 response: Mutex<Option<Result<AssumedSession, AwsClientError>>>,
912 calls: Mutex<Vec<(String, String, i32)>>,
913 }
914
915 impl MockBootstrapClient {
916 fn with_session(self, r: Result<AssumedSession, AwsClientError>) -> Self {
917 *self.response.lock().unwrap() = Some(r);
918 self
919 }
920 }
921
922 #[async_trait::async_trait]
923 impl AwsBootstrapClient for MockBootstrapClient {
924 async fn assume_role(
925 &self,
926 role_arn: &str,
927 session_name: &str,
928 duration_seconds: i32,
929 ) -> Result<AssumedSession, AwsClientError> {
930 self.calls.lock().unwrap().push((
931 role_arn.to_string(),
932 session_name.to_string(),
933 duration_seconds,
934 ));
935 self.response
936 .lock()
937 .unwrap()
938 .take()
939 .expect("test must wire an assume_role response")
940 }
941 }
942
943 fn bootstrap_connector(client: Arc<MockBootstrapClient>) -> AwsBootstrapConnector {
945 Arc::new(move || -> AwsBootstrapConnectFut {
946 let client = client.clone();
947 Box::pin(async move { Ok(client as Arc<dyn AwsBootstrapClient>) })
948 })
949 }
950
951 fn sample_session(issued_at_unix: i64, expiration_unix: i64) -> AssumedSession {
953 AssumedSession {
954 access_key_id: "ASIAEXAMPLE".to_string(),
955 secret_access_key: "example-secret".to_string(),
956 session_token: "example-session".to_string(),
957 expiration: DateTime::from_timestamp(expiration_unix, 0).unwrap(),
958 issued_at: DateTime::from_timestamp(issued_at_unix, 0).unwrap(),
959 }
960 }
961
962 #[test]
963 fn required_capabilities_listed_in_documented_order() {
964 let creds = AwsDeployerCredentials::default();
965 let ids: Vec<String> = creds
966 .required_capabilities()
967 .into_iter()
968 .map(|c| c.id)
969 .collect();
970 let mut expected = vec![AWS_STS_CALLER_IDENTITY_CAP.to_string()];
971 for v in VALIDATED_IAM_VERBS {
972 expected.push(format!("aws.iam.allow:{v}"));
973 }
974 assert_eq!(ids, expected);
975 assert_eq!(ids.len(), 1 + VALIDATED_IAM_VERBS.len());
977 }
978
979 #[test]
980 fn requires_credentials_material_is_true() {
981 let creds = AwsDeployerCredentials::default();
982 assert!(creds.requires_credentials_material());
983 }
984
985 #[test]
986 fn validate_passes_when_sts_and_all_verbs_allowed() {
987 let mock = Arc::new(
988 MockAwsClient::default()
989 .with_sts(Ok(arn_user()))
990 .with_simulate(Ok(all_allowed_decisions())),
991 );
992 let creds = AwsDeployerCredentials::with_client(mock.clone());
993 let env_id = EnvId::try_from("prod-eu").unwrap();
994 let hc = default_host_config(&env_id);
995 let dir = tempdir().unwrap();
996 let report = creds.validate(&ctx(dir.path(), &env_id, &hc));
997 assert!(report.passed(), "report: {report:?}");
998 assert!(
999 report.missing().is_empty(),
1000 "no missing caps; got {:?}",
1001 report.missing()
1002 );
1003 let calls = mock.simulate_calls.lock().unwrap();
1005 assert_eq!(calls.len(), 1);
1006 assert_eq!(calls[0].0, arn_user().arn);
1007 assert_eq!(calls[0].1.len(), VALIDATED_IAM_VERBS.len());
1008 }
1009
1010 #[test]
1011 fn validate_fails_specific_verb_when_iam_denies() {
1012 let decisions: Vec<ActionDecision> = VALIDATED_IAM_VERBS
1014 .iter()
1015 .map(|v| ActionDecision {
1016 action: v.to_string(),
1017 decision: if *v == "ecs:CreateTaskSet" {
1018 IamDecision::Denied("implicitDeny".into())
1019 } else {
1020 IamDecision::Allowed
1021 },
1022 })
1023 .collect();
1024 let mock = Arc::new(
1025 MockAwsClient::default()
1026 .with_sts(Ok(arn_user()))
1027 .with_simulate(Ok(decisions)),
1028 );
1029 let creds = AwsDeployerCredentials::with_client(mock);
1030 let env_id = EnvId::try_from("prod-eu").unwrap();
1031 let hc = default_host_config(&env_id);
1032 let dir = tempdir().unwrap();
1033 let report = creds.validate(&ctx(dir.path(), &env_id, &hc));
1034 assert!(!report.passed());
1035 let missing = report.missing();
1036 assert_eq!(missing.len(), 1, "only one verb denied; got {missing:?}");
1037 assert!(
1038 missing[0].ends_with("ecs:CreateTaskSet"),
1039 "missing id should name the denied verb; got {missing:?}"
1040 );
1041 let denied = report
1043 .checks
1044 .iter()
1045 .find(|c| c.capability.id == "aws.iam.allow:ecs:CreateTaskSet")
1046 .unwrap();
1047 match &denied.status {
1048 CapabilityStatus::Fail { reason } => {
1049 assert!(reason.contains("implicitDeny"), "reason: {reason}");
1050 assert!(reason.contains("ecs:CreateTaskSet"), "reason: {reason}");
1051 }
1052 other => panic!("expected Fail, got {other:?}"),
1053 }
1054 }
1055
1056 #[test]
1063 fn validate_fails_every_cap_when_no_credential_chain() {
1064 let mock = Arc::new(MockAwsClient::default().with_sts(Err(
1065 AwsClientError::NoCredentialChain("no AWS chain configured".into()),
1066 )));
1067 let creds = AwsDeployerCredentials::with_client(mock);
1068 let env_id = EnvId::try_from("prod-eu").unwrap();
1069 let hc = default_host_config(&env_id);
1070 let dir = tempdir().unwrap();
1071 let report = creds.validate(&ctx(dir.path(), &env_id, &hc));
1072 assert!(
1073 !report.passed(),
1074 "NoCredentialChain must block overall pass"
1075 );
1076 let missing = report.missing();
1077 assert_eq!(
1078 missing.len(),
1079 creds.required_capabilities().len(),
1080 "every cap must be missing; got {missing:?}"
1081 );
1082 for check in &report.checks {
1083 match &check.status {
1084 CapabilityStatus::Fail { reason } => {
1085 assert!(
1086 reason.contains("no AWS chain configured"),
1087 "reason: {reason}"
1088 );
1089 }
1090 other => panic!("expected Fail, got {other:?}"),
1091 }
1092 }
1093 }
1094
1095 #[test]
1096 fn validate_fails_every_cap_when_sts_rejects() {
1097 let mock = Arc::new(
1098 MockAwsClient::default()
1099 .with_sts(Err(AwsClientError::StsRejected("expired session".into()))),
1100 );
1101 let creds = AwsDeployerCredentials::with_client(mock);
1102 let env_id = EnvId::try_from("prod-eu").unwrap();
1103 let hc = default_host_config(&env_id);
1104 let dir = tempdir().unwrap();
1105 let report = creds.validate(&ctx(dir.path(), &env_id, &hc));
1106 assert!(!report.passed());
1107 for check in &report.checks {
1108 match &check.status {
1109 CapabilityStatus::Fail { reason } => {
1110 assert!(reason.contains("STS GetCallerIdentity"), "reason: {reason}");
1111 assert!(reason.contains("expired session"), "reason: {reason}");
1112 }
1113 other => panic!("expected Fail, got {other:?}"),
1114 }
1115 }
1116 }
1117
1118 #[test]
1119 fn validate_passes_sts_but_fails_verbs_when_iam_simulate_errors() {
1120 let mock = Arc::new(
1121 MockAwsClient::default()
1122 .with_sts(Ok(arn_user()))
1123 .with_simulate(Err(AwsClientError::IamRejected("throttled".into()))),
1124 );
1125 let creds = AwsDeployerCredentials::with_client(mock);
1126 let env_id = EnvId::try_from("prod-eu").unwrap();
1127 let hc = default_host_config(&env_id);
1128 let dir = tempdir().unwrap();
1129 let report = creds.validate(&ctx(dir.path(), &env_id, &hc));
1130 assert!(!report.passed());
1131 let sts_check = report
1132 .checks
1133 .iter()
1134 .find(|c| c.capability.id == AWS_STS_CALLER_IDENTITY_CAP)
1135 .unwrap();
1136 assert!(matches!(sts_check.status, CapabilityStatus::Pass));
1137 for verb in VALIDATED_IAM_VERBS {
1138 let id = format!("aws.iam.allow:{verb}");
1139 let check = report
1140 .checks
1141 .iter()
1142 .find(|c| c.capability.id == id)
1143 .unwrap();
1144 match &check.status {
1145 CapabilityStatus::Fail { reason } => {
1146 assert!(reason.contains("throttled"), "reason: {reason}");
1147 }
1148 other => panic!("expected Fail, got {other:?}"),
1149 }
1150 }
1151 }
1152
1153 #[test]
1154 fn bootstrap_rejects_empty_admin_profile() {
1155 let creds = AwsDeployerCredentials::default();
1156 let env_id = EnvId::try_from("prod-eu").unwrap();
1157 let dir = tempdir().unwrap();
1158 let admin = ZeroizedAdmin::new("", "irrelevant".to_string());
1159 let input = BootstrapInput {
1160 env_id: &env_id,
1161 env_root: dir.path(),
1162 admin: &admin,
1163 };
1164 let err = creds.bootstrap(&input).unwrap_err();
1165 match err {
1166 BootstrapError::AdminRejected(msg) => {
1167 assert!(msg.contains("--admin-profile"), "msg: {msg}");
1168 }
1169 other => panic!("expected AdminRejected, got {other:?}"),
1170 }
1171 }
1172
1173 #[test]
1174 fn bootstrap_returns_rules_pack_without_binding_credentials() {
1175 let creds = AwsDeployerCredentials::default();
1176 let env_id = EnvId::try_from("prod-eu").unwrap();
1177 let dir = tempdir().unwrap();
1178 let admin = ZeroizedAdmin::new(
1179 "arn:aws:iam::111122223333:role/customer-admin",
1180 String::new(),
1181 );
1182 let input = BootstrapInput {
1183 env_id: &env_id,
1184 env_root: dir.path(),
1185 admin: &admin,
1186 };
1187 let outcome = creds.bootstrap(&input).expect("bootstrap renders");
1188 assert!(
1191 outcome.bound_credentials_ref.is_none(),
1192 "render-only AWS bootstrap must not bind credentials directly"
1193 );
1194 assert!(
1195 !outcome.rules_pack.is_empty(),
1196 "rules pack must not be empty"
1197 );
1198 let combined: String = outcome
1201 .rules_pack
1202 .entries
1203 .iter()
1204 .map(|e| e.content.as_str())
1205 .collect::<Vec<_>>()
1206 .join("\n");
1207 for verb in VALIDATED_IAM_VERBS {
1208 assert!(
1209 combined.contains(verb),
1210 "rules pack must mention `{verb}`; content:\n{combined}"
1211 );
1212 }
1213 assert!(
1216 combined.contains("arn:aws:iam::111122223333:role/customer-admin"),
1217 "rules pack must mention the admin trust principal; content:\n{combined}"
1218 );
1219 }
1220
1221 #[test]
1222 fn bootstrap_with_bind_assumes_role_and_returns_session() {
1223 let session = sample_session(1_000_000, 1_000_000 + 3600);
1225 let mock = Arc::new(MockBootstrapClient::default().with_session(Ok(session.clone())));
1226 let creds = AwsDeployerCredentials::with_bootstrap_assume(
1227 "arn:aws:iam::111122223333:role/greentic-deployer-prod-eu",
1228 bootstrap_connector(mock.clone()),
1229 );
1230 let env_id = EnvId::try_from("prod-eu").unwrap();
1231 let dir = tempdir().unwrap();
1232 let admin = ZeroizedAdmin::new("admin-profile", String::new());
1233 let input = BootstrapInput {
1234 env_id: &env_id,
1235 env_root: dir.path(),
1236 admin: &admin,
1237 };
1238
1239 let outcome = creds
1240 .bootstrap(&input)
1241 .expect("bind bootstrap mints a session");
1242
1243 assert!(
1245 outcome.bound_credentials_ref.is_some(),
1246 "bind bootstrap must bind a credentials ref"
1247 );
1248 assert_eq!(outcome.bound_expiry, Some(session.expiration));
1249 let material = outcome
1252 .bound_secret_material
1253 .expect("bind bootstrap must set material");
1254 let parsed: AssumedSession =
1255 serde_json::from_str(material.as_str()).expect("material is a session blob");
1256 assert_eq!(parsed.access_key_id, session.access_key_id);
1257 assert_eq!(parsed.secret_access_key, session.secret_access_key);
1258 assert_eq!(parsed.session_token, session.session_token);
1259 assert_eq!(parsed.expiration, session.expiration);
1260 assert_eq!(parsed.issued_at, session.issued_at);
1261 assert!(!outcome.rules_pack.is_empty());
1264
1265 let calls = mock.calls.lock().unwrap();
1268 assert_eq!(calls.len(), 1);
1269 assert_eq!(
1270 calls[0].0,
1271 "arn:aws:iam::111122223333:role/greentic-deployer-prod-eu"
1272 );
1273 assert_eq!(calls[0].1, "greentic-deployer-prod-eu");
1274 assert_eq!(calls[0].2, STS_SESSION_DURATION_SECONDS);
1275 }
1276
1277 #[test]
1278 fn bootstrap_surfaces_assume_failure_as_provisioning_failed() {
1279 let mock = Arc::new(MockBootstrapClient::default().with_session(Err(
1280 AwsClientError::StsRejected("AssumeRole denied".to_string()),
1281 )));
1282 let creds = AwsDeployerCredentials::with_bootstrap_assume(
1283 "arn:aws:iam::111122223333:role/greentic-deployer-prod-eu",
1284 bootstrap_connector(mock),
1285 );
1286 let env_id = EnvId::try_from("prod-eu").unwrap();
1287 let dir = tempdir().unwrap();
1288 let admin = ZeroizedAdmin::new("admin-profile", String::new());
1289 let input = BootstrapInput {
1290 env_id: &env_id,
1291 env_root: dir.path(),
1292 admin: &admin,
1293 };
1294 let err = creds.bootstrap(&input).unwrap_err();
1295 match err {
1296 BootstrapError::ProvisioningFailed { step, message } => {
1297 assert_eq!(step, "aws-assume-role");
1298 assert!(message.contains("AssumeRole denied"), "message: {message}");
1299 }
1300 other => panic!("expected ProvisioningFailed, got {other:?}"),
1301 }
1302 }
1303
1304 #[test]
1305 fn rotate_at_lands_at_eighty_percent_of_the_session_window() {
1306 let session = sample_session(2_000_000, 2_000_000 + 1000);
1308 let material = serde_json::to_string(&session).unwrap();
1309 let creds = AwsDeployerCredentials::default();
1310 let rotate_at = creds
1311 .rotate_at(&material)
1312 .expect("a session blob decodes a rotation window");
1313 assert_eq!(
1314 rotate_at,
1315 DateTime::from_timestamp(2_000_000 + 800, 0).unwrap()
1316 );
1317 }
1318
1319 #[test]
1320 fn rotate_at_is_none_and_rotation_due_fails_open_for_non_session_material() {
1321 let creds = AwsDeployerCredentials::default();
1324 assert!(creds.rotate_at("not-a-session-blob").is_none());
1325 assert!(creds.rotation_due("not-a-session-blob", chrono::Utc::now()));
1326 }
1327}