use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use chrono::{DateTime, Utc};
use greentic_deploy_spec::SecretRef;
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use crate::credentials::{
BootstrapError, BootstrapInput, BootstrapOutcome, Capability, CapabilityCheck,
CapabilityStatus, DeployerCredentials, RequirementsReport, ValidationContext,
};
use super::bootstrap::{IamRulesPackInput, render_min_iam_rules_pack};
pub const AWS_STS_CALLER_IDENTITY_CAP: &str = "aws.sts.caller-identity";
pub const VALIDATED_IAM_VERBS: &[&str] = &[
"sts:GetCallerIdentity",
"iam:SimulatePrincipalPolicy",
"ecs:DescribeServices",
"ecs:CreateService",
"ecs:UpdateService",
"ecs:RegisterTaskDefinition",
"ecs:CreateTaskSet",
"ecs:DescribeTaskSets",
"ecs:DeleteTaskSet",
"ecs:DeregisterTaskDefinition",
"ecr:PutImage",
"iam:PassRole",
"elasticloadbalancing:DescribeTargetGroups",
"elasticloadbalancing:ModifyListener",
];
fn iam_verb_capability_id(verb: &str) -> String {
format!("aws.iam.allow:{verb}")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CallerIdentity {
pub arn: String,
pub account: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IamDecision {
Allowed,
Denied(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActionDecision {
pub action: String,
pub decision: IamDecision,
}
#[derive(Debug, thiserror::Error)]
pub enum AwsClientError {
#[error("AWS credential chain resolved no usable credentials: {0}")]
NoCredentialChain(String),
#[error("AWS STS rejected the credentials: {0}")]
StsRejected(String),
#[error("AWS IAM rejected the policy simulation: {0}")]
IamRejected(String),
#[error("AWS SDK transport error: {0}")]
Transport(String),
}
#[async_trait::async_trait]
pub trait AwsValidatorClient: std::fmt::Debug + Send + Sync {
async fn get_caller_identity(&self) -> Result<CallerIdentity, AwsClientError>;
async fn simulate_principal_policy<'a>(
&'a self,
principal_arn: &'a str,
actions: &'a [&'a str],
) -> Result<Vec<ActionDecision>, AwsClientError>;
}
const STS_SESSION_DURATION_SECONDS: i32 = 3600;
pub(crate) const DEPLOYER_SESSION_STORE_PATH: &str = "default/_/aws-deployer/deployer_session";
#[derive(Clone, Serialize, Deserialize)]
pub struct AssumedSession {
pub access_key_id: String,
pub secret_access_key: String,
pub session_token: String,
pub expiration: DateTime<Utc>,
pub issued_at: DateTime<Utc>,
}
impl std::fmt::Debug for AssumedSession {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AssumedSession")
.field("access_key_id", &self.access_key_id)
.field("secret_access_key", &"<redacted>")
.field("session_token", &"<redacted>")
.field("expiration", &self.expiration)
.field("issued_at", &self.issued_at)
.finish()
}
}
pub type AwsBootstrapConnectFut =
Pin<Box<dyn Future<Output = Result<Arc<dyn AwsBootstrapClient>, AwsClientError>> + Send>>;
pub type AwsBootstrapConnector = Arc<dyn Fn() -> AwsBootstrapConnectFut + Send + Sync>;
#[async_trait::async_trait]
pub trait AwsBootstrapClient: std::fmt::Debug + Send + Sync {
async fn assume_role(
&self,
role_arn: &str,
session_name: &str,
duration_seconds: i32,
) -> Result<AssumedSession, AwsClientError>;
}
#[derive(Debug)]
struct RealAwsClient {
sts: aws_sdk_sts::Client,
iam: aws_sdk_iam::Client,
}
impl RealAwsClient {
async fn resolve() -> Result<Self, AwsClientError> {
let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
let creds_provider = config.credentials_provider().ok_or_else(|| {
AwsClientError::NoCredentialChain(
"no AWS credentials provider in the resolved SDK config — set AWS_PROFILE or \
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY"
.to_string(),
)
})?;
use aws_sdk_sts::config::ProvideCredentials;
creds_provider
.provide_credentials()
.await
.map_err(|e| AwsClientError::NoCredentialChain(e.to_string()))?;
let sts = aws_sdk_sts::Client::new(&config);
let iam = aws_sdk_iam::Client::new(&config);
Ok(Self { sts, iam })
}
}
#[async_trait::async_trait]
impl AwsValidatorClient for RealAwsClient {
async fn get_caller_identity(&self) -> Result<CallerIdentity, AwsClientError> {
let out = self
.sts
.get_caller_identity()
.send()
.await
.map_err(|e| AwsClientError::StsRejected(format!("{e}")))?;
let arn = out.arn().ok_or_else(|| {
AwsClientError::StsRejected("STS returned no ARN for the caller".to_string())
})?;
let account = out.account().ok_or_else(|| {
AwsClientError::StsRejected("STS returned no account for the caller".to_string())
})?;
Ok(CallerIdentity {
arn: arn.to_string(),
account: account.to_string(),
})
}
async fn simulate_principal_policy<'a>(
&'a self,
principal_arn: &'a str,
actions: &'a [&'a str],
) -> Result<Vec<ActionDecision>, AwsClientError> {
let action_names: Vec<String> = actions.iter().map(|a| (*a).to_string()).collect();
let out = self
.iam
.simulate_principal_policy()
.policy_source_arn(principal_arn)
.set_action_names(Some(action_names))
.send()
.await
.map_err(|e| AwsClientError::IamRejected(format!("{e}")))?;
let mut by_action: std::collections::HashMap<&str, IamDecision> =
std::collections::HashMap::with_capacity(out.evaluation_results().len());
for r in out.evaluation_results() {
let decision = r.eval_decision().as_str();
let interp = if decision.eq_ignore_ascii_case("allowed") {
IamDecision::Allowed
} else {
IamDecision::Denied(decision.to_string())
};
by_action.insert(r.eval_action_name(), interp);
}
let mut out = Vec::with_capacity(actions.len());
for action in actions {
let decision = by_action.get(*action).cloned().ok_or_else(|| {
AwsClientError::IamRejected(format!(
"IAM SimulatePrincipalPolicy returned no decision for `{action}`"
))
})?;
out.push(ActionDecision {
action: (*action).to_string(),
decision,
});
}
Ok(out)
}
}
#[derive(Debug)]
pub(crate) struct RealAwsBootstrapClient {
sts: aws_sdk_sts::Client,
}
impl RealAwsBootstrapClient {
pub(crate) async fn resolve(admin_profile: &str) -> Result<Self, AwsClientError> {
let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
.profile_name(admin_profile)
.load()
.await;
let creds_provider = config.credentials_provider().ok_or_else(|| {
AwsClientError::NoCredentialChain(format!(
"AWS profile `{admin_profile}` resolved no credentials provider"
))
})?;
use aws_sdk_sts::config::ProvideCredentials;
creds_provider
.provide_credentials()
.await
.map_err(|e| AwsClientError::NoCredentialChain(e.to_string()))?;
Ok(Self {
sts: aws_sdk_sts::Client::new(&config),
})
}
}
#[async_trait::async_trait]
impl AwsBootstrapClient for RealAwsBootstrapClient {
async fn assume_role(
&self,
role_arn: &str,
session_name: &str,
duration_seconds: i32,
) -> Result<AssumedSession, AwsClientError> {
let out = self
.sts
.assume_role()
.role_arn(role_arn)
.role_session_name(session_name)
.duration_seconds(duration_seconds)
.send()
.await
.map_err(|e| AwsClientError::StsRejected(format!("AssumeRole failed: {e}")))?;
let creds = out.credentials().ok_or_else(|| {
AwsClientError::StsRejected("AssumeRole returned no credentials".to_string())
})?;
let exp = creds.expiration();
let expiration =
DateTime::from_timestamp(exp.secs(), exp.subsec_nanos()).ok_or_else(|| {
AwsClientError::StsRejected(
"AssumeRole returned an out-of-range expiration".to_string(),
)
})?;
Ok(AssumedSession {
access_key_id: creds.access_key_id().to_string(),
secret_access_key: creds.secret_access_key().to_string(),
session_token: creds.session_token().to_string(),
expiration,
issued_at: Utc::now(),
})
}
}
struct AwsBootstrapBind {
role_arn: String,
connect: AwsBootstrapConnector,
}
#[derive(Default)]
pub struct AwsDeployerCredentials {
client: Mutex<Option<Arc<dyn AwsValidatorClient>>>,
bind: Option<AwsBootstrapBind>,
}
impl std::fmt::Debug for AwsDeployerCredentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AwsDeployerCredentials")
.field("client", &"<lazy>")
.field("bind", &self.bind.as_ref().map(|b| &b.role_arn))
.finish()
}
}
impl AwsDeployerCredentials {
pub fn with_client(client: Arc<dyn AwsValidatorClient>) -> Self {
Self {
client: Mutex::new(Some(client)),
bind: None,
}
}
pub fn with_bootstrap_assume(
role_arn: impl Into<String>,
connect: AwsBootstrapConnector,
) -> Self {
Self {
client: Mutex::new(None),
bind: Some(AwsBootstrapBind {
role_arn: role_arn.into(),
connect,
}),
}
}
fn resolve_client(&self) -> Result<Arc<dyn AwsValidatorClient>, AwsClientError> {
if let Some(c) = self.client.lock().expect("mutex not poisoned").as_ref() {
return Ok(Arc::clone(c));
}
let built = run_aws_async(RealAwsClient::resolve())?;
let arc: Arc<dyn AwsValidatorClient> = Arc::new(built);
let mut slot = self.client.lock().expect("mutex not poisoned");
if let Some(c) = slot.as_ref() {
return Ok(Arc::clone(c));
}
*slot = Some(Arc::clone(&arc));
Ok(arc)
}
fn caller_identity_capability(&self) -> Capability {
Capability::new(
AWS_STS_CALLER_IDENTITY_CAP,
"AWS credential chain resolves to a caller identity (STS GetCallerIdentity)",
)
}
fn iam_verb_capability(&self, verb: &str) -> Capability {
Capability::new(
iam_verb_capability_id(verb),
format!("IAM principal is allowed to perform `{verb}`"),
)
}
fn sts_pass_verbs_failed(&self, reason: &str) -> RequirementsReport {
let mut checks = Vec::with_capacity(1 + VALIDATED_IAM_VERBS.len());
checks.push(CapabilityCheck {
capability: self.caller_identity_capability(),
status: CapabilityStatus::Pass,
});
for verb in VALIDATED_IAM_VERBS {
checks.push(CapabilityCheck {
capability: self.iam_verb_capability(verb),
status: CapabilityStatus::Fail {
reason: reason.to_string(),
},
});
}
RequirementsReport::new(checks)
}
}
impl DeployerCredentials for AwsDeployerCredentials {
fn requires_credentials_material(&self) -> bool {
true
}
fn rotate_at(&self, material: &str) -> Option<DateTime<Utc>> {
let session: AssumedSession = serde_json::from_str(material).ok()?;
Some(crate::credentials::rotate::rotate_at_from_window(
session.issued_at,
session.expiration,
))
}
fn required_capabilities(&self) -> Vec<Capability> {
let mut caps = Vec::with_capacity(1 + VALIDATED_IAM_VERBS.len());
caps.push(self.caller_identity_capability());
for verb in VALIDATED_IAM_VERBS {
caps.push(self.iam_verb_capability(verb));
}
caps
}
fn validate(&self, _ctx: &ValidationContext<'_>) -> RequirementsReport {
let caps = self.required_capabilities();
let client = match self.resolve_client() {
Ok(c) => c,
Err(AwsClientError::NoCredentialChain(reason)) => {
return all_failed(&caps, &reason);
}
Err(e) => {
return all_failed(&caps, &e.to_string());
}
};
let arn = match run_aws_async(client.get_caller_identity()) {
Ok(id) => id.arn,
Err(e) => {
return all_failed(&caps, &format!("STS GetCallerIdentity failed: {e}"));
}
};
let decisions =
match run_aws_async(client.simulate_principal_policy(&arn, VALIDATED_IAM_VERBS)) {
Ok(v) => v,
Err(e) => {
return self.sts_pass_verbs_failed(&format!(
"IAM SimulatePrincipalPolicy failed: {e}"
));
}
};
let mut checks = Vec::with_capacity(1 + decisions.len());
checks.push(CapabilityCheck {
capability: self.caller_identity_capability(),
status: CapabilityStatus::Pass,
});
for (verb, decision) in VALIDATED_IAM_VERBS.iter().zip(decisions.iter()) {
let status = match &decision.decision {
IamDecision::Allowed => CapabilityStatus::Pass,
IamDecision::Denied(raw) => CapabilityStatus::Fail {
reason: format!("IAM denied `{verb}` ({raw})"),
},
};
checks.push(CapabilityCheck {
capability: self.iam_verb_capability(verb),
status,
});
}
RequirementsReport::new(checks)
}
fn bootstrap(&self, input: &BootstrapInput<'_>) -> Result<BootstrapOutcome, BootstrapError> {
let admin_profile = input.admin.profile();
if admin_profile.is_empty() {
return Err(BootstrapError::AdminRejected(
"AWS bootstrap requires --admin-profile to identify the trust principal; \
pass an IAM role/user ARN or a named AWS profile that will execute the rules \
pack."
.to_string(),
));
}
let rules_pack = render_min_iam_rules_pack(&IamRulesPackInput {
env_id: input.env_id.as_str(),
admin_identity_hint: admin_profile,
allowed_actions: VALIDATED_IAM_VERBS,
});
let Some(bind) = self.bind.as_ref() else {
return Ok(BootstrapOutcome {
rules_pack,
bound_credentials_ref: None,
bound_expiry: None,
bound_secret_material: None,
});
};
let connector = Arc::clone(&bind.connect);
let role_arn = bind.role_arn.clone();
let session_name = sts_session_name(input.env_id.as_str());
let session = run_aws_async(async move {
let client = connector().await?;
client
.assume_role(&role_arn, &session_name, STS_SESSION_DURATION_SECONDS)
.await
})
.map_err(|e: AwsClientError| BootstrapError::ProvisioningFailed {
step: "aws-assume-role".to_string(),
message: e.to_string(),
})?;
let bound_ref = SecretRef::try_new(format!(
"secret://{}/{}",
input.env_id.as_str(),
DEPLOYER_SESSION_STORE_PATH
))
.map_err(|e| BootstrapError::ProvisioningFailed {
step: "bind-ref".to_string(),
message: format!("bound credentials ref is not well-formed: {e}"),
})?;
let bound_expiry = Some(session.expiration);
let material =
serde_json::to_string(&session).map_err(|e| BootstrapError::ProvisioningFailed {
step: "serialize-session".to_string(),
message: format!("could not serialize the assumed session: {e}"),
})?;
Ok(BootstrapOutcome {
rules_pack,
bound_credentials_ref: Some(bound_ref),
bound_expiry,
bound_secret_material: Some(Zeroizing::new(material)),
})
}
}
fn sts_session_name(env_id: &str) -> String {
format!("greentic-deployer-{env_id}")
}
fn all_failed(caps: &[Capability], reason: &str) -> RequirementsReport {
RequirementsReport::new(
caps.iter()
.map(|c| CapabilityCheck {
capability: c.clone(),
status: CapabilityStatus::Fail {
reason: reason.to_string(),
},
})
.collect(),
)
}
fn run_aws_async<F, T>(fut: F) -> T
where
F: std::future::Future<Output = T> + Send,
T: Send,
{
std::thread::scope(|scope| {
scope
.spawn(|| {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("build current-thread tokio runtime");
rt.block_on(fut)
})
.join()
.expect("AWS validate thread did not panic")
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::credentials::ZeroizedAdmin;
use greentic_deploy_spec::{EnvId, EnvironmentHostConfig};
use std::path::Path;
use std::sync::Mutex;
use tempfile::tempdir;
fn default_host_config(env_id: &EnvId) -> EnvironmentHostConfig {
EnvironmentHostConfig {
env_id: env_id.clone(),
region: None,
tenant_org_id: None,
listen_addr: None,
public_base_url: None,
gui_enabled: None,
}
}
fn ctx<'a>(
env_root: &'a Path,
env_id: &'a EnvId,
host_config: &'a EnvironmentHostConfig,
) -> ValidationContext<'a> {
ValidationContext {
env_id,
env_root,
host_config,
}
}
#[derive(Debug, Default)]
struct MockAwsClient {
sts_response: Mutex<Option<Result<CallerIdentity, AwsClientError>>>,
simulate_response: Mutex<Option<Result<Vec<ActionDecision>, AwsClientError>>>,
simulate_calls: Mutex<Vec<(String, Vec<String>)>>,
}
impl MockAwsClient {
fn with_sts(self, r: Result<CallerIdentity, AwsClientError>) -> Self {
*self.sts_response.lock().unwrap() = Some(r);
self
}
fn with_simulate(self, r: Result<Vec<ActionDecision>, AwsClientError>) -> Self {
*self.simulate_response.lock().unwrap() = Some(r);
self
}
}
#[async_trait::async_trait]
impl AwsValidatorClient for MockAwsClient {
async fn get_caller_identity(&self) -> Result<CallerIdentity, AwsClientError> {
self.sts_response
.lock()
.unwrap()
.take()
.expect("test must wire sts_response")
}
async fn simulate_principal_policy<'a>(
&'a self,
principal_arn: &'a str,
actions: &'a [&'a str],
) -> Result<Vec<ActionDecision>, AwsClientError> {
let snapshot: Vec<String> = actions.iter().map(|a| (*a).to_string()).collect();
self.simulate_calls
.lock()
.unwrap()
.push((principal_arn.to_string(), snapshot));
self.simulate_response
.lock()
.unwrap()
.take()
.expect("test must wire simulate_response")
}
}
fn arn_user() -> CallerIdentity {
CallerIdentity {
arn: "arn:aws:iam::111122223333:user/cust-admin".into(),
account: "111122223333".into(),
}
}
fn all_allowed_decisions() -> Vec<ActionDecision> {
VALIDATED_IAM_VERBS
.iter()
.map(|v| ActionDecision {
action: v.to_string(),
decision: IamDecision::Allowed,
})
.collect()
}
#[derive(Debug, Default)]
struct MockBootstrapClient {
response: Mutex<Option<Result<AssumedSession, AwsClientError>>>,
calls: Mutex<Vec<(String, String, i32)>>,
}
impl MockBootstrapClient {
fn with_session(self, r: Result<AssumedSession, AwsClientError>) -> Self {
*self.response.lock().unwrap() = Some(r);
self
}
}
#[async_trait::async_trait]
impl AwsBootstrapClient for MockBootstrapClient {
async fn assume_role(
&self,
role_arn: &str,
session_name: &str,
duration_seconds: i32,
) -> Result<AssumedSession, AwsClientError> {
self.calls.lock().unwrap().push((
role_arn.to_string(),
session_name.to_string(),
duration_seconds,
));
self.response
.lock()
.unwrap()
.take()
.expect("test must wire an assume_role response")
}
}
fn bootstrap_connector(client: Arc<MockBootstrapClient>) -> AwsBootstrapConnector {
Arc::new(move || -> AwsBootstrapConnectFut {
let client = client.clone();
Box::pin(async move { Ok(client as Arc<dyn AwsBootstrapClient>) })
})
}
fn sample_session(issued_at_unix: i64, expiration_unix: i64) -> AssumedSession {
AssumedSession {
access_key_id: "ASIAEXAMPLE".to_string(),
secret_access_key: "example-secret".to_string(),
session_token: "example-session".to_string(),
expiration: DateTime::from_timestamp(expiration_unix, 0).unwrap(),
issued_at: DateTime::from_timestamp(issued_at_unix, 0).unwrap(),
}
}
#[test]
fn required_capabilities_listed_in_documented_order() {
let creds = AwsDeployerCredentials::default();
let ids: Vec<String> = creds
.required_capabilities()
.into_iter()
.map(|c| c.id)
.collect();
let mut expected = vec![AWS_STS_CALLER_IDENTITY_CAP.to_string()];
for v in VALIDATED_IAM_VERBS {
expected.push(format!("aws.iam.allow:{v}"));
}
assert_eq!(ids, expected);
assert_eq!(ids.len(), 1 + VALIDATED_IAM_VERBS.len());
}
#[test]
fn requires_credentials_material_is_true() {
let creds = AwsDeployerCredentials::default();
assert!(creds.requires_credentials_material());
}
#[test]
fn validate_passes_when_sts_and_all_verbs_allowed() {
let mock = Arc::new(
MockAwsClient::default()
.with_sts(Ok(arn_user()))
.with_simulate(Ok(all_allowed_decisions())),
);
let creds = AwsDeployerCredentials::with_client(mock.clone());
let env_id = EnvId::try_from("prod-eu").unwrap();
let hc = default_host_config(&env_id);
let dir = tempdir().unwrap();
let report = creds.validate(&ctx(dir.path(), &env_id, &hc));
assert!(report.passed(), "report: {report:?}");
assert!(
report.missing().is_empty(),
"no missing caps; got {:?}",
report.missing()
);
let calls = mock.simulate_calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, arn_user().arn);
assert_eq!(calls[0].1.len(), VALIDATED_IAM_VERBS.len());
}
#[test]
fn validate_fails_specific_verb_when_iam_denies() {
let decisions: Vec<ActionDecision> = VALIDATED_IAM_VERBS
.iter()
.map(|v| ActionDecision {
action: v.to_string(),
decision: if *v == "ecs:CreateTaskSet" {
IamDecision::Denied("implicitDeny".into())
} else {
IamDecision::Allowed
},
})
.collect();
let mock = Arc::new(
MockAwsClient::default()
.with_sts(Ok(arn_user()))
.with_simulate(Ok(decisions)),
);
let creds = AwsDeployerCredentials::with_client(mock);
let env_id = EnvId::try_from("prod-eu").unwrap();
let hc = default_host_config(&env_id);
let dir = tempdir().unwrap();
let report = creds.validate(&ctx(dir.path(), &env_id, &hc));
assert!(!report.passed());
let missing = report.missing();
assert_eq!(missing.len(), 1, "only one verb denied; got {missing:?}");
assert!(
missing[0].ends_with("ecs:CreateTaskSet"),
"missing id should name the denied verb; got {missing:?}"
);
let denied = report
.checks
.iter()
.find(|c| c.capability.id == "aws.iam.allow:ecs:CreateTaskSet")
.unwrap();
match &denied.status {
CapabilityStatus::Fail { reason } => {
assert!(reason.contains("implicitDeny"), "reason: {reason}");
assert!(reason.contains("ecs:CreateTaskSet"), "reason: {reason}");
}
other => panic!("expected Fail, got {other:?}"),
}
}
#[test]
fn validate_fails_every_cap_when_no_credential_chain() {
let mock = Arc::new(MockAwsClient::default().with_sts(Err(
AwsClientError::NoCredentialChain("no AWS chain configured".into()),
)));
let creds = AwsDeployerCredentials::with_client(mock);
let env_id = EnvId::try_from("prod-eu").unwrap();
let hc = default_host_config(&env_id);
let dir = tempdir().unwrap();
let report = creds.validate(&ctx(dir.path(), &env_id, &hc));
assert!(
!report.passed(),
"NoCredentialChain must block overall pass"
);
let missing = report.missing();
assert_eq!(
missing.len(),
creds.required_capabilities().len(),
"every cap must be missing; got {missing:?}"
);
for check in &report.checks {
match &check.status {
CapabilityStatus::Fail { reason } => {
assert!(
reason.contains("no AWS chain configured"),
"reason: {reason}"
);
}
other => panic!("expected Fail, got {other:?}"),
}
}
}
#[test]
fn validate_fails_every_cap_when_sts_rejects() {
let mock = Arc::new(
MockAwsClient::default()
.with_sts(Err(AwsClientError::StsRejected("expired session".into()))),
);
let creds = AwsDeployerCredentials::with_client(mock);
let env_id = EnvId::try_from("prod-eu").unwrap();
let hc = default_host_config(&env_id);
let dir = tempdir().unwrap();
let report = creds.validate(&ctx(dir.path(), &env_id, &hc));
assert!(!report.passed());
for check in &report.checks {
match &check.status {
CapabilityStatus::Fail { reason } => {
assert!(reason.contains("STS GetCallerIdentity"), "reason: {reason}");
assert!(reason.contains("expired session"), "reason: {reason}");
}
other => panic!("expected Fail, got {other:?}"),
}
}
}
#[test]
fn validate_passes_sts_but_fails_verbs_when_iam_simulate_errors() {
let mock = Arc::new(
MockAwsClient::default()
.with_sts(Ok(arn_user()))
.with_simulate(Err(AwsClientError::IamRejected("throttled".into()))),
);
let creds = AwsDeployerCredentials::with_client(mock);
let env_id = EnvId::try_from("prod-eu").unwrap();
let hc = default_host_config(&env_id);
let dir = tempdir().unwrap();
let report = creds.validate(&ctx(dir.path(), &env_id, &hc));
assert!(!report.passed());
let sts_check = report
.checks
.iter()
.find(|c| c.capability.id == AWS_STS_CALLER_IDENTITY_CAP)
.unwrap();
assert!(matches!(sts_check.status, CapabilityStatus::Pass));
for verb in VALIDATED_IAM_VERBS {
let id = format!("aws.iam.allow:{verb}");
let check = report
.checks
.iter()
.find(|c| c.capability.id == id)
.unwrap();
match &check.status {
CapabilityStatus::Fail { reason } => {
assert!(reason.contains("throttled"), "reason: {reason}");
}
other => panic!("expected Fail, got {other:?}"),
}
}
}
#[test]
fn bootstrap_rejects_empty_admin_profile() {
let creds = AwsDeployerCredentials::default();
let env_id = EnvId::try_from("prod-eu").unwrap();
let dir = tempdir().unwrap();
let admin = ZeroizedAdmin::new("", "irrelevant".to_string());
let input = BootstrapInput {
env_id: &env_id,
env_root: dir.path(),
admin: &admin,
};
let err = creds.bootstrap(&input).unwrap_err();
match err {
BootstrapError::AdminRejected(msg) => {
assert!(msg.contains("--admin-profile"), "msg: {msg}");
}
other => panic!("expected AdminRejected, got {other:?}"),
}
}
#[test]
fn bootstrap_returns_rules_pack_without_binding_credentials() {
let creds = AwsDeployerCredentials::default();
let env_id = EnvId::try_from("prod-eu").unwrap();
let dir = tempdir().unwrap();
let admin = ZeroizedAdmin::new(
"arn:aws:iam::111122223333:role/customer-admin",
String::new(),
);
let input = BootstrapInput {
env_id: &env_id,
env_root: dir.path(),
admin: &admin,
};
let outcome = creds.bootstrap(&input).expect("bootstrap renders");
assert!(
outcome.bound_credentials_ref.is_none(),
"render-only AWS bootstrap must not bind credentials directly"
);
assert!(
!outcome.rules_pack.is_empty(),
"rules pack must not be empty"
);
let combined: String = outcome
.rules_pack
.entries
.iter()
.map(|e| e.content.as_str())
.collect::<Vec<_>>()
.join("\n");
for verb in VALIDATED_IAM_VERBS {
assert!(
combined.contains(verb),
"rules pack must mention `{verb}`; content:\n{combined}"
);
}
assert!(
combined.contains("arn:aws:iam::111122223333:role/customer-admin"),
"rules pack must mention the admin trust principal; content:\n{combined}"
);
}
#[test]
fn bootstrap_with_bind_assumes_role_and_returns_session() {
let session = sample_session(1_000_000, 1_000_000 + 3600);
let mock = Arc::new(MockBootstrapClient::default().with_session(Ok(session.clone())));
let creds = AwsDeployerCredentials::with_bootstrap_assume(
"arn:aws:iam::111122223333:role/greentic-deployer-prod-eu",
bootstrap_connector(mock.clone()),
);
let env_id = EnvId::try_from("prod-eu").unwrap();
let dir = tempdir().unwrap();
let admin = ZeroizedAdmin::new("admin-profile", String::new());
let input = BootstrapInput {
env_id: &env_id,
env_root: dir.path(),
admin: &admin,
};
let outcome = creds
.bootstrap(&input)
.expect("bind bootstrap mints a session");
assert!(
outcome.bound_credentials_ref.is_some(),
"bind bootstrap must bind a credentials ref"
);
assert_eq!(outcome.bound_expiry, Some(session.expiration));
let material = outcome
.bound_secret_material
.expect("bind bootstrap must set material");
let parsed: AssumedSession =
serde_json::from_str(material.as_str()).expect("material is a session blob");
assert_eq!(parsed.access_key_id, session.access_key_id);
assert_eq!(parsed.secret_access_key, session.secret_access_key);
assert_eq!(parsed.session_token, session.session_token);
assert_eq!(parsed.expiration, session.expiration);
assert_eq!(parsed.issued_at, session.issued_at);
assert!(!outcome.rules_pack.is_empty());
let calls = mock.calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(
calls[0].0,
"arn:aws:iam::111122223333:role/greentic-deployer-prod-eu"
);
assert_eq!(calls[0].1, "greentic-deployer-prod-eu");
assert_eq!(calls[0].2, STS_SESSION_DURATION_SECONDS);
}
#[test]
fn bootstrap_surfaces_assume_failure_as_provisioning_failed() {
let mock = Arc::new(MockBootstrapClient::default().with_session(Err(
AwsClientError::StsRejected("AssumeRole denied".to_string()),
)));
let creds = AwsDeployerCredentials::with_bootstrap_assume(
"arn:aws:iam::111122223333:role/greentic-deployer-prod-eu",
bootstrap_connector(mock),
);
let env_id = EnvId::try_from("prod-eu").unwrap();
let dir = tempdir().unwrap();
let admin = ZeroizedAdmin::new("admin-profile", String::new());
let input = BootstrapInput {
env_id: &env_id,
env_root: dir.path(),
admin: &admin,
};
let err = creds.bootstrap(&input).unwrap_err();
match err {
BootstrapError::ProvisioningFailed { step, message } => {
assert_eq!(step, "aws-assume-role");
assert!(message.contains("AssumeRole denied"), "message: {message}");
}
other => panic!("expected ProvisioningFailed, got {other:?}"),
}
}
#[test]
fn rotate_at_lands_at_eighty_percent_of_the_session_window() {
let session = sample_session(2_000_000, 2_000_000 + 1000);
let material = serde_json::to_string(&session).unwrap();
let creds = AwsDeployerCredentials::default();
let rotate_at = creds
.rotate_at(&material)
.expect("a session blob decodes a rotation window");
assert_eq!(
rotate_at,
DateTime::from_timestamp(2_000_000 + 800, 0).unwrap()
);
}
#[test]
fn rotate_at_is_none_and_rotation_due_fails_open_for_non_session_material() {
let creds = AwsDeployerCredentials::default();
assert!(creds.rotate_at("not-a-session-blob").is_none());
assert!(creds.rotation_due("not-a-session-blob", chrono::Utc::now()));
}
}