Skip to main content

greentic_deployer/env_packs/aws/
credentials.rs

1//! [`DeployerCredentials`] impl for the AWS-ECS deployer env-pack (C3).
2//!
3//! Validation goes through the typed AWS SDK (per plan §C3 rule: typed cloud
4//! APIs, not shell-out to `aws` CLI):
5//!
6//! - **`aws.sts.caller-identity`** — `STS::GetCallerIdentity`. Proves the
7//!   ambient AWS credential chain resolves to a usable principal.
8//! - **One capability per validated IAM verb**, evaluated via
9//!   `IAM::SimulatePrincipalPolicy` against the resolved caller ARN. The verb
10//!   list ([`VALIDATED_IAM_VERBS`]) covers the full ECS task-set rollout
11//!   surface that `RealEcsTarget` exercises (DescribeServices / CreateService,
12//!   RegisterTaskDefinition / CreateTaskSet / DescribeTaskSets, DeleteTaskSet /
13//!   DeregisterTaskDefinition, DescribeTargetGroups, and the ELB traffic-shift
14//!   verbs — ModifyListener for the legacy default-action path,
15//!   DescribeRules / CreateRule / ModifyRule plus AddTags / DescribeTags
16//!   (owner-tagging) for per-deployment listener rules)
17//!   plus this
18//!   handler's STS/IAM self-probes, `iam:PassRole`, and `ecr:PutImage` staging.
19//!   Keeping the list in parity with the real target's runtime calls means a
20//!   role that passes `gtc op credentials requirements` does not then fail on
21//!   the first live warm / traffic-shift / archive.
22//!
23//! ## Sync trait + async SDK
24//!
25//! [`DeployerCredentials::validate`] is sync. The AWS SDK is async. We
26//! isolate the async block in a freshly-built current-thread tokio runtime
27//! running on a dedicated thread (`std::thread::scope`). This pattern was
28//! codified by B12a's deployer fix: `tokio::task::block_in_place` panics on
29//! a current-thread parent runtime, and the operator CLI may run on one;
30//! a dedicated thread sidesteps that without leaking the runtime choice
31//! through the trait. ~10ms thread overhead per validate; negligible
32//! against AWS round-trips.
33//!
34//! ## Pluggable client for testability
35//!
36//! [`AwsValidatorClient`] is the seam unit tests mock. Production builds a
37//! real client lazily on first validate via [`RealAwsClient::resolve`],
38//! which walks the standard AWS credential chain
39//! (`$AWS_PROFILE` → `~/.aws/credentials` → IMDS → IRSA …). The real client
40//! is held behind a `Mutex<Option<Arc<dyn AwsValidatorClient>>>` so repeated
41//! validates reuse the SDK client; the first call pays the chain-resolve
42//! cost (~50-200ms), subsequent calls reuse it.
43//!
44//! ## Bootstrap
45//!
46//! [`bootstrap`](DeployerCredentials::bootstrap) emits a minimum-privilege
47//! IAM role + inline policy Terraform module via [`super::bootstrap`]. The
48//! rules pack lands under `rules/<env>/greentic.deployer.aws-ecs/aws-min-iam.tf`
49//! and the customer's admin applies it via `tofu apply` / `terraform apply`
50//! against their own state backend. Two paths:
51//!
52//! - **Render-only (default):** `bound_credentials_ref: None` — the admin
53//!   applies the pack offline, then binds the resulting role via `--bind`.
54//! - **`--bind`** (instance built via [`AwsDeployerCredentials::with_bootstrap_assume`]):
55//!   assume that already-applied deployer role AS THE ADMIN, minting a
56//!   short-lived STS session ([`AssumedSession`]) returned as the bound
57//!   material. The rotation engine re-mints it at 80% of lifetime
58//!   ([`rotate_at`](DeployerCredentials::rotate_at)).
59
60use 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
76/// Stable ID for the STS caller-identity probe.
77pub const AWS_STS_CALLER_IDENTITY_CAP: &str = "aws.sts.caller-identity";
78
79/// IAM verbs this handler validates (one capability each, rendered by
80/// `required_capabilities` in this order; tests derive their expectations from
81/// this slice, so the grouping is free to change).
82///
83/// Covers the full ECS task-set rollout surface `RealEcsTarget` exercises at
84/// deploy time, this handler's own STS/IAM self-probes, image-push staging, and
85/// `iam:PassRole`. The runtime subset the real target actually calls is listed
86/// in `real_target::REAL_ECS_TARGET_IAM_ACTIONS`; a test pins it ⊆ this list, so
87/// adding an SDK call to the real target without a matching verb here fails CI
88/// rather than the customer's first live deploy. Each verb maps to a capability
89/// ID `aws.iam.allow:<verb>`.
90pub const VALIDATED_IAM_VERBS: &[&str] = &[
91    // Self-validation: this handler's own STS identity + IAM policy probes.
92    "sts:GetCallerIdentity",
93    "iam:SimulatePrincipalPolicy",
94    // ECS service + task-set lifecycle driven by `RealEcsTarget` (one
95    // EXTERNAL-controller service per deployment, one task set per revision).
96    "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    // Image-push staging + passing the task execution/task roles to ECS.
106    "ecr:PutImage",
107    "iam:PassRole",
108    // ELBv2 weighted traffic shifting across the revisions' target groups.
109    // ModifyListener serves the legacy default-action path (one deployment per
110    // listener); the rule verbs serve the per-deployment routing path.
111    "elasticloadbalancing:DescribeTargetGroups",
112    "elasticloadbalancing:ModifyListener",
113    "elasticloadbalancing:DescribeRules",
114    "elasticloadbalancing:CreateRule",
115    "elasticloadbalancing:ModifyRule",
116    "elasticloadbalancing:AddTags",
117    "elasticloadbalancing:DescribeTags",
118];
119
120/// Returns the canonical capability ID for an IAM verb.
121fn iam_verb_capability_id(verb: &str) -> String {
122    format!("aws.iam.allow:{verb}")
123}
124
125/// Caller identity returned by [`AwsValidatorClient::get_caller_identity`].
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct CallerIdentity {
128    pub arn: String,
129    pub account: String,
130}
131
132/// Decision for a single IAM action under
133/// [`AwsValidatorClient::simulate_principal_policy`].
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub enum IamDecision {
136    /// `Allowed` from `SimulatePrincipalPolicy`.
137    Allowed,
138    /// `ImplicitDeny` or `ExplicitDeny`. Carries the raw decision string
139    /// for the operator-facing failure message.
140    Denied(String),
141}
142
143/// One entry in the simulate-policy result.
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct ActionDecision {
146    pub action: String,
147    pub decision: IamDecision,
148}
149
150/// Errors the client can surface to validate. All variants flow into a
151/// `CapabilityStatus::Fail { reason }` — the trait doesn't distinguish
152/// transport from auth failures because the operator's fix is the same:
153/// `gtc op credentials requirements <env>` re-runs after the operator
154/// reconfigures credentials.
155#[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/// Pluggable AWS client used by [`AwsDeployerCredentials::validate`]. Unit
168/// tests mock this; production resolves [`RealAwsClient`] lazily.
169///
170/// The trait is `async_trait` because the AWS SDK is async; the validate
171/// path bridges sync→async via a dedicated thread (see module docstring).
172#[async_trait::async_trait]
173pub trait AwsValidatorClient: std::fmt::Debug + Send + Sync {
174    async fn get_caller_identity(&self) -> Result<CallerIdentity, AwsClientError>;
175
176    /// `actions` are the IAM verbs to evaluate against `principal_arn`'s
177    /// effective policy. Returns one [`ActionDecision`] per verb in the
178    /// same order as the input slice; missing-from-response is the
179    /// client's responsibility to detect and surface as
180    /// [`AwsClientError::IamRejected`].
181    ///
182    /// Borrowed `&[&str]` (not `&[String]`) so the call site can pass
183    /// `VALIDATED_IAM_VERBS` directly. Real impls that need owned
184    /// `Vec<String>` for the SDK do the conversion locally. The shared
185    /// `'a` lifetime is required by `async_trait` to unify the nested
186    /// references in the returned future.
187    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
194/// Lifetime requested for the assumed deployer session (1 hour). STS clamps
195/// this to the role's `MaxSessionDuration`; 1h is below every role's floor
196/// (15min min, 1h default), so the request never fails on duration alone. The
197/// rotation engine re-mints at 80% of the GRANTED window via [`rotate_at`]
198/// (mirrors the K8s bind token's proactive re-mint).
199///
200/// [`rotate_at`]: AwsDeployerCredentials::rotate_at
201const STS_SESSION_DURATION_SECONDS: i32 = 3600;
202
203/// Tenant/team-scoped store path the assumed session lands at, mirroring the
204/// K8s deployer token's `default/_/<kind>/<artifact>` shape. The bound
205/// `secret://<env>/<this>` ref is what the runtime client resolves to sign
206/// ECS/ELB calls (the resolver lands in a follow-up slice).
207pub(crate) const DEPLOYER_SESSION_STORE_PATH: &str = "default/_/aws-deployer/deployer_session";
208
209/// A short-lived AWS session minted by assuming the scoped deployer role.
210///
211/// Serializes to JSON as the env's bound credential material. AWS needs all
212/// three session parts (not just a bearer, unlike K8s) to sign requests, so
213/// the material is a structured blob rather than an opaque string. `issued_at`
214/// is the assume-role call time (STS returns only `expiration`); the pair
215/// drives the 80% proactive-rotation point in [`rotate_at`]. This shape is the
216/// forward contract the runtime ECS/STS client parses in a follow-up slice.
217///
218/// [`rotate_at`]: AwsDeployerCredentials::rotate_at
219#[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    /// STS-reported session expiry.
225    pub expiration: DateTime<Utc>,
226    /// When the session was minted (assume-role call time).
227    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        // Never log the secret access key or session token.
233        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
243/// Future yielded by an [`AwsBootstrapConnector`].
244pub type AwsBootstrapConnectFut =
245    Pin<Box<dyn Future<Output = Result<Arc<dyn AwsBootstrapClient>, AwsClientError>> + Send>>;
246
247/// Lazily connect the admin-authenticated STS client the `--bind` bootstrap
248/// path assumes the deployer role with. Connect + assume share one
249/// `run_aws_async` call (the sync-trait → async-SDK bridge), mirroring the K8s
250/// bind connector.
251pub type AwsBootstrapConnector = Arc<dyn Fn() -> AwsBootstrapConnectFut + Send + Sync>;
252
253/// STS client used by the `--bind` bootstrap path. Tests mock this; production
254/// resolves [`RealAwsBootstrapClient`] from the admin profile's credential
255/// chain. Distinct from [`AwsValidatorClient`]: validate and bind never run on
256/// the same instance, and bind needs only `AssumeRole`.
257#[async_trait::async_trait]
258pub trait AwsBootstrapClient: std::fmt::Debug + Send + Sync {
259    /// Assume `role_arn` for `duration_seconds`, returning the minted session.
260    /// `session_name` tags the session in CloudTrail.
261    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/// Production AWS client. Built lazily on first [`validate`] — the SDK
270/// credential chain resolution is ~50-200ms and we don't want to pay it
271/// for a no-op `requirements` call when AWS isn't configured.
272#[derive(Debug)]
273struct RealAwsClient {
274    sts: aws_sdk_sts::Client,
275    iam: aws_sdk_iam::Client,
276}
277
278impl RealAwsClient {
279    /// Resolve the AWS credential chain and build the STS + IAM clients.
280    ///
281    /// Walks: `$AWS_*` env vars → `$AWS_PROFILE` / `~/.aws/credentials` →
282    /// IMDS / IRSA / EKS pod identity → SSO. Same chain the rest of the
283    /// codebase's AWS code uses (`bundle_upload/s3.rs`,
284    /// `runtime_secrets/aws.rs`).
285    async fn resolve() -> Result<Self, AwsClientError> {
286        let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
287        // Empty credentials provider = no chain resolved. Probe by asking
288        // for credentials directly — if the provider chain is empty, the
289        // call returns NoMatchingTraits or CredentialsNotLoaded.
290        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        // ProvideCredentials trait lives in aws_credential_types, but we
298        // don't list it as a direct dep — pull the re-export through
299        // aws-sdk-sts (which is a direct dep) to keep the Cargo manifest
300        // small. Same trait either way.
301        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        // SDK wants owned `Vec<String>`; convert at the edge.
340        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        // The API returns one EvaluationResult per (action, resource) pair.
351        // We didn't pass resource ARNs, so resource is implicit-* — one
352        // entry per action. Build a lookup map and emit decisions in the
353        // requested order so callers can zip results to requests. HashMap
354        // because lookup order isn't observed (the output is built by
355        // re-iterating the request slice).
356        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/// Production STS client for the `--bind` bootstrap path. Resolves the named
384/// admin profile's credential chain (the identity allowed to assume the
385/// deployer role) and calls `AssumeRole`. Separate from [`RealAwsClient`]:
386/// bind authenticates AS THE ADMIN (an explicit profile), not via the ambient
387/// chain `validate` walks.
388#[derive(Debug)]
389pub(crate) struct RealAwsBootstrapClient {
390    sts: aws_sdk_sts::Client,
391}
392
393impl RealAwsBootstrapClient {
394    /// Resolve the SDK config for `admin_profile` and build the STS client.
395    /// Probes the chain up front (like [`RealAwsClient::resolve`]) so a
396    /// missing/empty profile fails here with a clear message rather than on
397    /// the `AssumeRole` call.
398    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        // STS reports expiry as an aws-smithy `DateTime`; convert to chrono.
440        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
457/// `--bind` configuration: the scoped deployer role to assume + the admin-
458/// authenticated STS connector that assumes it. Held only on instances the
459/// CLI builds for `op credentials bootstrap --bind`; the render-only
460/// `bootstrap` path and `validate` never touch it.
461struct AwsBootstrapBind {
462    /// The deployer role the admin created by applying the rules-pack
463    /// Terraform (the binding answers' `assume_role_arn`).
464    role_arn: String,
465    connect: AwsBootstrapConnector,
466}
467
468/// AWS-ECS deployer credentials handler.
469///
470/// Holds a lazy-init validator client behind an `Arc<Mutex<...>>`. Tests
471/// inject a mock via [`with_client`](Self::with_client). The default
472/// constructor defers SDK setup until the first validate, so building the
473/// handler is free even on a host with no AWS credentials. The `--bind`
474/// bootstrap path is a separate instance built via
475/// [`with_bootstrap_assume`](Self::with_bootstrap_assume).
476#[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        // The bind connector is a closure (no Debug); summarize its presence.
485        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    /// Construct with a pre-built client. Used by tests + by callers that
494    /// want to inject a mock or pre-configured production client.
495    pub fn with_client(client: Arc<dyn AwsValidatorClient>) -> Self {
496        Self {
497            client: Mutex::new(Some(client)),
498            bind: None,
499        }
500    }
501
502    /// Build credentials wired for the `--bind` bootstrap path: assume
503    /// `role_arn` via `connect` (the admin's STS client) to mint a session.
504    /// Holds no validator client — `validate` is not the bind path's concern,
505    /// and the two never run on the same instance.
506    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    /// Return the held client or build a real one. Cheap on the hot path:
520    /// only the first validate pays the chain-resolve cost.
521    fn resolve_client(&self) -> Result<Arc<dyn AwsValidatorClient>, AwsClientError> {
522        // Fast path: client already built.
523        if let Some(c) = self.client.lock().expect("mutex not poisoned").as_ref() {
524            return Ok(Arc::clone(c));
525        }
526        // Slow path: build the real client on a dedicated thread + runtime.
527        // See module docstring: `block_in_place` panics on a current-thread
528        // parent runtime, so we always isolate via std::thread::scope.
529        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        // Another thread may have raced us — keep their client to avoid
533        // tearing down a perfectly good SDK client.
534        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    /// Mixed-status report for the case where STS succeeded but the
556    /// downstream IAM SimulatePrincipalPolicy call errored. STS cap
557    /// passes (we have a usable caller identity); every verb cap fails
558    /// with the same Simulate-error reason. Mirrors `all_failed` for
559    /// the STS-already-passed case so the validate path stays a
560    /// straight-line sequence of helper calls instead of carrying an
561    /// inline 14-line CapabilityCheck construction.
562    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    /// The bound material is a serialized [`AssumedSession`]; decode its
586    /// `issued_at`/`expiration` and schedule the re-mint at 80% of lifetime
587    /// (the shared [`rotate_at_from_window`](crate::credentials::rotate)
588    /// policy K8s also uses). `None` for material that isn't a session blob —
589    /// the rotation engine then fails open and rotates.
590    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        // Hoist caps once — every early-return arm reuses it.
609        let caps = self.required_capabilities();
610
611        let client = match self.resolve_client() {
612            Ok(c) => c,
613            Err(AwsClientError::NoCredentialChain(reason)) => {
614                // No credentials at all — for a deployer that requires
615                // credential material, missing chain is an auth failure,
616                // not a "we couldn't check" skip. Fail every cap so
617                // `report.passed()` is false and the downstream doc
618                // stamps `result: Fail`.
619                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                // STS rejected the chain — fail every cap with the same
630                // diagnostic; downstream IAM simulate can't run without
631                // a principal ARN.
632                return all_failed(&caps, &format!("STS GetCallerIdentity failed: {e}"));
633            }
634        };
635
636        // STS passed; now SimulatePrincipalPolicy for the verb list.
637        let decisions =
638            match run_aws_async(client.simulate_principal_policy(&arn, VALIDATED_IAM_VERBS)) {
639                Ok(v) => v,
640                Err(e) => {
641                    // STS passed but IAM Simulate failed — STS cap passes,
642                    // every verb cap fails with the simulate error.
643                    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        // Admin material is the named AWS profile (the customer's admin-IAM
671        // role referenced in the rules pack's
672        // `aws_iam_role.trust_policy.assume_role_policy`, and — on the
673        // `--bind` path — the profile that assumes the deployer role).
674        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        // Render-only (default): the admin applies the rules pack offline
691        // (Terraform), then binds the resulting role via `--bind` / `rotate`.
692        // No credentials are minted here — `bound_credentials_ref: None` tells
693        // the runner NOT to mark the env as credentialed.
694        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        // `--bind`: assume the scoped deployer role (already created by the
704        // rules pack the admin applied) AS THE ADMIN, minting a short-lived
705        // STS session. Unlike K8s, nothing is applied live here — the role
706        // MUST pre-exist (the admin ran Terraform offline first). Connect +
707        // assume share one `run_aws_async` call (the sync→async bridge).
708        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        // Serialize the full session (the runtime client needs all three
734        // parts to sign requests). The secret sink writes it to the secret
735        // backend; `rotate_at` decodes the window back out of it.
736        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
751/// STS `RoleSessionName` for the assumed deployer session: tags the session in
752/// CloudTrail. `env_id` is already DNS-safe (it derives K8s namespaces), so it
753/// fits the `[\w+=,.@-]{2,64}` role-session-name charset without sanitizing.
754fn sts_session_name(env_id: &str) -> String {
755    format!("greentic-deployer-{env_id}")
756}
757
758/// Build every-capability-failed report with the same reason. Used when
759/// the credential chain doesn't resolve or the SDK errors in a way that
760/// is neither verb-specific denial nor a transport issue the operator can
761/// distinguish from auth failure.
762fn 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
775/// Run an async block in a dedicated thread with its own current-thread
776/// tokio runtime. The trait surface is sync; this is the bridge.
777///
778/// Why a dedicated thread + current-thread runtime instead of
779/// `block_in_place` or `Handle::current().block_on`:
780///
781/// - `block_in_place` panics on a current-thread parent runtime (the
782///   operator CLI uses one — confirmed in B12a's deployer-fix incident).
783/// - `Handle::current().block_on` is a self-deadlock if called from
784///   inside any runtime, current-thread or multi-thread.
785///
786/// `std::thread::scope` + `Builder::new_current_thread().build().block_on`
787/// is the pattern B12a settled on (see
788/// `project_next_gen_deployment_phase_b.md` precedents). ~10ms overhead
789/// per invocation — negligible against AWS round-trips.
790pub(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    /// Test double recording received calls; outputs are scripted.
842    #[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            // Snapshot the borrowed slice into owned Strings for the call
875            // recorder — tests need a stable record even after `actions`
876            // goes out of scope.
877            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    /// Bind-path test double: records the `assume_role` call and returns a
908    /// scripted result.
909    #[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    /// Wrap a mock bootstrap client in the connector closure `bootstrap` calls.
944    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    /// A session with a known `[issued_at, expiration]` window for assertions.
952    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        // Sanity on the count — STS + one cap per verb.
976        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        // Verify the principal ARN was threaded through to simulate.
1004        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        // Deny `ecs:CreateTaskSet`; all other verbs allowed.
1013        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        // The matching check carries the raw denial reason.
1042        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    /// `NoCredentialChain` must produce `Fail` for every capability —
1057    /// missing credentials is an auth failure for a deployer that requires
1058    /// material, not a "we couldn't check" skip. This test exercises the
1059    /// validate path end-to-end via a mock client that surfaces
1060    /// `NoCredentialChain` at the STS call (the closest we can get to
1061    /// triggering the `resolve_client` chain-error path through the mock).
1062    #[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        // Render-only (no `--bind`): returns None — the admin applies the
1189        // Terraform offline, then binds via `--bind` / `op credentials rotate`.
1190        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        // The HCL must mention every validated action so the customer's
1199        // admin can audit it.
1200        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        // The admin profile/ARN must appear in the trust policy so the
1214        // customer sees who can assume the generated role.
1215        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        // 1h window from a fixed issue time.
1224        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        // Bound ref + expiry come from the assumed session.
1244        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        // Material round-trips back to the full session (all three parts +
1250        // both timestamps), so the runtime client can rebuild a signer.
1251        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        // The rules pack is still rendered on the bind path (the admin can
1262        // re-audit the role's policy even when binding live).
1263        assert!(!outcome.rules_pack.is_empty());
1264
1265        // assume_role was called once with the configured role, the env-derived
1266        // session name, and the 1h duration constant.
1267        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        // 1000s window ⇒ rotate at issued_at + 800s (the shared 80% policy).
1307        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        // Opaque / non-JSON material has no decodable window, so the rotation
1322        // engine fails open and treats it as due (mirrors the K8s opaque case).
1323        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}