use std::sync::{Arc, Mutex};
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:CreateService",
"ecs:UpdateService",
"ecs:CreateTaskSet",
"ecr:PutImage",
"elasticloadbalancing:ModifyListener",
"iam:PassRole",
];
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>;
}
#[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, Default)]
pub struct AwsDeployerCredentials {
client: Mutex<Option<Arc<dyn AwsValidatorClient>>>,
}
impl AwsDeployerCredentials {
pub fn with_client(client: Arc<dyn AwsValidatorClient>) -> Self {
Self {
client: Mutex::new(Some(client)),
}
}
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 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,
});
Ok(BootstrapOutcome {
rules_pack,
bound_credentials_ref: None,
})
}
}
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,
}
}
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()
}
#[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(),
"AWS C3 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}"
);
}
}