Skip to main content

secrets_engine_aws/
lib.rs

1//! AWS credentials, in the two flavours the platform actually offers.
2//!
3//! `assumed_role` mints an STS session: short, narrowable to a bucket and
4//! prefix by a session policy, and **impossible to revoke**. `iam_user` mints a
5//! throwaway IAM user with one access key, mirroring the Postgres engine's
6//! create/drop pattern: long-lived until deleted, but genuinely revocable.
7//!
8//! Those are different promises, so a credential declares its own guarantees
9//! rather than inheriting this engine's headline shape — see
10//! `GeneratedCredential::with_shape`.
11//!
12//! See `docs/delegation/aws.md` for the mechanism and
13//! `docs/delegation/setup/aws.md` for the operator walkthrough.
14
15use std::collections::HashMap;
16use std::sync::Mutex;
17
18use async_trait::async_trait;
19use aws_config::{BehaviorVersion, Region, SdkConfig};
20use aws_sdk_iam::error::ProvideErrorMetadata;
21use aws_sdk_sts::types::PolicyDescriptorType;
22use chrono::{DateTime, Utc};
23use secrets_core::engine::{
24    CredentialShape, EngineDoc, EngineError, EngineResult, GeneratedCredential, PathDoc,
25    SecretsEngine, TtlDoc,
26};
27use secrets_core::lease::Lease;
28use secrets_core::mount::ConfigRoleStore;
29use secrets_core::storage::StorageBackend;
30use serde::{Deserialize, Serialize};
31use serde_json::json;
32use uuid::Uuid;
33
34const STORE: ConfigRoleStore = ConfigRoleStore::new("aws/config/", "aws/roles/");
35const MOUNT: &str = "aws/creds/";
36
37/// STS refuses anything outside this envelope, and a request above the role's
38/// own `MaxSessionDuration` fails outright rather than being truncated — so
39/// it is worth rejecting locally with a clear message instead of sending a
40/// doomed call.
41const MIN_SESSION_SECONDS: i64 = 900;
42const MAX_SESSION_SECONDS: i64 = 43200;
43
44/// IAM's limits on the names we generate.
45const IAM_MAX_USER_NAME: usize = 64;
46const STS_MAX_SESSION_NAME: usize = 64;
47
48/// One inline policy per generated user, named so `revoke()` can find it
49/// without listing.
50const INLINE_POLICY_NAME: &str = "secrets-server-lease";
51
52/// How the server authenticates to AWS for one target account.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct AwsConfig {
55    pub region: String,
56    /// `default` walks AWS's own provider chain — instance profile, IRSA, ECS
57    /// task role, environment. `static` uses the key pair below.
58    #[serde(default = "default_auth")]
59    pub auth: AuthMode,
60    #[serde(default)]
61    pub access_key_id: Option<String>,
62    #[serde(default)]
63    pub secret_access_key: Option<String>,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68pub enum AuthMode {
69    Default,
70    Static,
71}
72
73fn default_auth() -> AuthMode {
74    AuthMode::Default
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "snake_case")]
79pub enum CredentialType {
80    AssumedRole,
81    IamUser,
82}
83
84fn default_credential_type() -> CredentialType {
85    CredentialType::AssumedRole
86}
87
88/// Fifteen minutes: the shortest STS allows, and the right default for a
89/// credential nobody can recall.
90fn default_ttl_seconds() -> i64 {
91    MIN_SESSION_SECONDS
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct RoleConfig {
96    /// Which `aws/config/{name}` document to authenticate with.
97    pub target: String,
98    #[serde(default = "default_credential_type")]
99    pub credential_type: CredentialType,
100    /// Required for `assumed_role`.
101    #[serde(default)]
102    pub role_arn: Option<String>,
103    /// Narrows the session. Permissions end up as the *intersection* of this
104    /// and the role's own policy, so it can only ever subtract.
105    #[serde(default)]
106    pub session_policy: Option<serde_json::Value>,
107    #[serde(default)]
108    pub session_policy_arns: Vec<String>,
109    /// Confused-deputy guard when assuming a role in an account you do not own.
110    #[serde(default)]
111    pub external_id: Option<String>,
112    /// Inline policy attached to a generated `iam_user`. Without it the user
113    /// can do nothing, which is the safe default but rarely the useful one.
114    #[serde(default)]
115    pub user_policy: Option<serde_json::Value>,
116    #[serde(default = "default_ttl_seconds")]
117    pub default_ttl_seconds: i64,
118}
119
120#[derive(Default)]
121pub struct AwsEngine {
122    /// Building an `SdkConfig` can reach out to IMDS, so keep one per target
123    /// rather than paying that on every mint. Never held across an await.
124    configs: Mutex<HashMap<String, SdkConfig>>,
125}
126
127impl AwsEngine {
128    pub fn new() -> Self {
129        Self::default()
130    }
131
132    async fn sdk_config(&self, target: &str, config: &AwsConfig) -> EngineResult<SdkConfig> {
133        if let Some(cached) = self
134            .configs
135            .lock()
136            .expect("aws config cache poisoned")
137            .get(target)
138        {
139            return Ok(cached.clone());
140        }
141
142        let mut loader = aws_config::defaults(BehaviorVersion::latest())
143            .region(Region::new(config.region.clone()));
144
145        if config.auth == AuthMode::Static {
146            let (Some(access_key_id), Some(secret_access_key)) =
147                (&config.access_key_id, &config.secret_access_key)
148            else {
149                return Err(EngineError::InvalidRequest(
150                    "auth 'static' needs both access_key_id and secret_access_key".into(),
151                ));
152            };
153            loader = loader.credentials_provider(aws_sdk_sts::config::Credentials::new(
154                access_key_id,
155                secret_access_key,
156                None,
157                None,
158                "secrets-server-static",
159            ));
160        }
161
162        let sdk = loader.load().await;
163        self.configs
164            .lock()
165            .expect("aws config cache poisoned")
166            .insert(target.to_string(), sdk.clone());
167        Ok(sdk)
168    }
169
170    async fn assume_role(
171        &self,
172        sdk: &SdkConfig,
173        role_name: &str,
174        role: &RoleConfig,
175    ) -> EngineResult<GeneratedCredential> {
176        let role_arn = role.role_arn.as_deref().ok_or_else(|| {
177            EngineError::InvalidRequest(
178                "role_arn is required for credential_type 'assumed_role'".into(),
179            )
180        })?;
181        let duration = validate_session_ttl(role.default_ttl_seconds)?;
182        let session_name = unique_name("s", role_name, 8, STS_MAX_SESSION_NAME);
183
184        let mut request = aws_sdk_sts::Client::new(sdk)
185            .assume_role()
186            .role_arn(role_arn)
187            .role_session_name(&session_name)
188            .duration_seconds(duration);
189
190        if let Some(policy) = &role.session_policy {
191            request = request.policy(
192                serde_json::to_string(policy)
193                    .map_err(|e| EngineError::InvalidRequest(format!("bad session_policy: {e}")))?,
194            );
195        }
196        for arn in &role.session_policy_arns {
197            request = request.policy_arns(
198                PolicyDescriptorType::builder()
199                    .arn(arn)
200                    .build(),
201            );
202        }
203        if let Some(external_id) = &role.external_id {
204            request = request.external_id(external_id);
205        }
206
207        let output = request
208            .send()
209            .await
210            .map_err(|e| EngineError::Provider(format!("AWS AssumeRole failed: {}", chain(&e))))?;
211        let credentials = output
212            .credentials()
213            .ok_or_else(|| EngineError::Provider("AWS returned no credentials".into()))?;
214
215        // STS's own expiry, not the TTL we asked for: a lease must never
216        // outlive the credential it governs.
217        let expires_at = to_chrono(credentials.expiration())?;
218        let now = Utc::now();
219
220        let lease = Lease {
221            id: Uuid::new_v4(),
222            token_id_hash: String::new(),
223            engine_mount: MOUNT.to_string(),
224            // Nothing here is used to revoke, because nothing can — it is kept
225            // so an operator reading the lease can tell what was handed out.
226            internal_data: json!({
227                "credential_type": "assumed_role",
228                "target": role.target,
229                "role": role_name,
230                "role_arn": role_arn,
231                "session_name": session_name,
232            }),
233            issued_at: now,
234            expires_at,
235        };
236
237        Ok(GeneratedCredential::new(
238            json!({
239                "access_key_id": credentials.access_key_id(),
240                "secret_access_key": credentials.secret_access_key(),
241                "session_token": credentials.session_token(),
242                "expiration": expires_at,
243            }),
244            lease,
245            scope_description(role, role_arn),
246        ))
247    }
248
249    async fn iam_user(
250        &self,
251        sdk: &SdkConfig,
252        role_name: &str,
253        role: &RoleConfig,
254    ) -> EngineResult<GeneratedCredential> {
255        if role.default_ttl_seconds <= 0 {
256            return Err(EngineError::InvalidRequest(
257                "default_ttl_seconds must be positive".into(),
258            ));
259        }
260
261        let iam = aws_sdk_iam::Client::new(sdk);
262        let user_name = unique_name("v", role_name, 12, IAM_MAX_USER_NAME);
263
264        iam.create_user()
265            .user_name(&user_name)
266            .send()
267            .await
268            .map_err(|e| EngineError::Provider(format!("AWS CreateUser failed: {}", chain(&e))))?;
269
270        // From here on, any failure would otherwise leave an orphaned IAM user
271        // that no lease knows about, so unwind before returning.
272        if let Some(policy) = &role.user_policy {
273            let document = match serde_json::to_string(policy) {
274                Ok(document) => document,
275                Err(e) => {
276                    cleanup_user(&iam, &user_name, false).await;
277                    return Err(EngineError::InvalidRequest(format!("bad user_policy: {e}")));
278                }
279            };
280            if let Err(e) = iam
281                .put_user_policy()
282                .user_name(&user_name)
283                .policy_name(INLINE_POLICY_NAME)
284                .policy_document(document)
285                .send()
286                .await
287            {
288                cleanup_user(&iam, &user_name, false).await;
289                return Err(EngineError::Provider(format!(
290                    "AWS PutUserPolicy failed: {}",
291                    chain(&e)
292                )));
293            }
294        }
295
296        let key_output = match iam.create_access_key().user_name(&user_name).send().await {
297            Ok(output) => output,
298            Err(e) => {
299                cleanup_user(&iam, &user_name, role.user_policy.is_some()).await;
300                return Err(EngineError::Provider(format!(
301                    "AWS CreateAccessKey failed: {}",
302                    chain(&e)
303                )));
304            }
305        };
306        let Some(key) = key_output.access_key() else {
307            cleanup_user(&iam, &user_name, role.user_policy.is_some()).await;
308            return Err(EngineError::Provider(
309                "AWS CreateAccessKey returned no access key".into(),
310            ));
311        };
312
313        let now = Utc::now();
314        let lease = Lease {
315            id: Uuid::new_v4(),
316            token_id_hash: String::new(),
317            engine_mount: MOUNT.to_string(),
318            internal_data: json!({
319                "credential_type": "iam_user",
320                "target": role.target,
321                "role": role_name,
322                "user_name": user_name,
323                "access_key_id": key.access_key_id(),
324                "has_inline_policy": role.user_policy.is_some(),
325            }),
326            issued_at: now,
327            // An access key has no intrinsic expiry, so the reaper is the only
328            // clock — a missed revocation leaves a permanent credential.
329            expires_at: now + chrono::Duration::seconds(role.default_ttl_seconds),
330        };
331
332        let credential = GeneratedCredential::new(
333            json!({
334                "access_key_id": key.access_key_id(),
335                "secret_access_key": key.secret_access_key(),
336                "user_name": user_name,
337            }),
338            lease,
339            scope_description(role, &format!("iam-user:{user_name}")),
340        );
341
342        Ok(match guarantees_for(CredentialType::IamUser) {
343            Some((shape, effect)) => credential.with_shape(shape, effect),
344            None => credential,
345        })
346    }
347}
348
349/// The guarantees a credential carries when they differ from this engine's
350/// headline shape. Keeping this a pure function is what lets the override be
351/// tested without touching AWS.
352fn guarantees_for(credential_type: CredentialType) -> Option<(CredentialShape, &'static str)> {
353    match credential_type {
354        // Inherits the engine's headline shape: an STS session cannot be recalled.
355        CredentialType::AssumedRole => None,
356        CredentialType::IamUser => Some((
357            CredentialShape::MintAndRevoke,
358            "deletes the access key, then the inline user policy, then the IAM user, \
359             so the credential stops working. IAM is eventually consistent, so allow \
360             a few seconds for the deletion to take effect everywhere.",
361        )),
362    }
363}
364
365/// Best-effort unwind after a partial `iam_user` creation. Errors are logged
366/// rather than returned: the caller is already failing, and the useful error is
367/// the original one.
368async fn cleanup_user(iam: &aws_sdk_iam::Client, user_name: &str, has_policy: bool) {
369    if has_policy
370        && let Err(e) = iam
371            .delete_user_policy()
372            .user_name(user_name)
373            .policy_name(INLINE_POLICY_NAME)
374            .send()
375            .await
376    {
377        tracing::warn!(user_name, error = %chain(&e), "failed to unwind inline policy");
378    }
379    if let Err(e) = iam.delete_user().user_name(user_name).send().await {
380        tracing::warn!(user_name, error = %chain(&e), "leaked an IAM user after a failed mint");
381    }
382}
383
384fn validate_session_ttl(seconds: i64) -> EngineResult<i32> {
385    if !(MIN_SESSION_SECONDS..=MAX_SESSION_SECONDS).contains(&seconds) {
386        return Err(EngineError::InvalidRequest(format!(
387            "default_ttl_seconds must be between {MIN_SESSION_SECONDS} and \
388             {MAX_SESSION_SECONDS} for an assumed role, got {seconds}. The role's own \
389             MaxSessionDuration may cap it further."
390        )));
391    }
392    Ok(seconds as i32)
393}
394
395/// IAM and STS both accept only `[\w+=,.@-]` and cap names at 64 characters, so
396/// a role name goes through unchanged only if it happens to be tame.
397fn unique_name(prefix: &str, role: &str, suffix_len: usize, max_len: usize) -> String {
398    let suffix: String = Uuid::new_v4().simple().to_string().chars().take(suffix_len).collect();
399    let sanitized: String = role
400        .chars()
401        .map(|c| {
402            if c.is_ascii_alphanumeric() || "+=,.@-_".contains(c) {
403                c
404            } else {
405                '-'
406            }
407        })
408        .collect();
409    let budget = max_len.saturating_sub(prefix.len() + suffix.len() + 2);
410    let head: String = sanitized.chars().take(budget).collect();
411    format!("{prefix}-{head}-{suffix}")
412}
413
414fn scope_description(role: &RoleConfig, principal: &str) -> Vec<String> {
415    let mut scoped = vec![principal.to_string()];
416    match &role.session_policy {
417        Some(_) => scoped.push("session_policy: applied (permissions are the intersection)".to_string()),
418        None if role.credential_type == CredentialType::AssumedRole => scoped
419            .push("session_policy: NONE — the session has the role's full permissions".to_string()),
420        None => {}
421    }
422    if role.user_policy.is_none() && role.credential_type == CredentialType::IamUser {
423        scoped.push("user_policy: NONE — the generated user can do nothing".to_string());
424    }
425    scoped.extend(
426        role.session_policy_arns
427            .iter()
428            .map(|arn| format!("policy_arn:{arn}")),
429    );
430    if let Some(external_id) = &role.external_id {
431        scoped.push(format!("external_id:{external_id}"));
432    }
433    scoped
434}
435
436fn to_chrono(timestamp: &aws_sdk_sts::primitives::DateTime) -> EngineResult<DateTime<Utc>> {
437    DateTime::from_timestamp(timestamp.secs(), 0)
438        .ok_or_else(|| EngineError::Provider("AWS returned an unrepresentable expiry".into()))
439}
440
441/// AWS SDK errors put the useful detail in the source chain, so `to_string()`
442/// alone reports little more than "service error".
443fn chain(error: &dyn std::error::Error) -> String {
444    let mut parts = vec![error.to_string()];
445    let mut source = error.source();
446    while let Some(current) = source {
447        parts.push(current.to_string());
448        source = current.source();
449    }
450    parts.join(": ")
451}
452
453#[async_trait]
454impl SecretsEngine for AwsEngine {
455    fn doc(&self) -> EngineDoc {
456        EngineDoc {
457            provider: "AWS".to_string(),
458            mechanism: "STS AssumeRole sessions narrowed by a session policy \
459                        (credential_type 'assumed_role'), or a throwaway IAM user \
460                        with one access key (credential_type 'iam_user')"
461                .to_string(),
462            shape: CredentialShape::MintExpiryOnly,
463            revocable: false,
464            revoke_effect: "for an assumed role: NOTHING. AWS cannot invalidate an \
465                            issued STS session, so revoking the lease only deletes our \
466                            record of it and the credential keeps working until it \
467                            expires — keep TTLs short, that is the whole containment \
468                            story. Roles with credential_type 'iam_user' are genuinely \
469                            revocable and say so in their own _doc."
470                .to_string(),
471            ttl: TtlDoc::range(
472                MIN_SESSION_SECONDS,
473                MAX_SESSION_SECONDS,
474                "STS allows 15 minutes to 12 hours, further capped by the role's own \
475                 MaxSessionDuration. An 'iam_user' credential has no intrinsic expiry \
476                 at all — its lease TTL is enforced only by our reaper.",
477            ),
478            scoping: "an inline session policy plus up to 10 managed policy ARNs, \
479                      which narrow an assumed role to (say) one bucket and prefix. \
480                      Permissions are the intersection with the role's own policy, so \
481                      a session policy can only ever subtract. An 'iam_user' is scoped \
482                      by its inline user_policy instead."
483                .to_string(),
484            root_credential: "ideally NONE: run the server on EC2, ECS or EKS and let \
485                              the instance profile, task role or IRSA supply rotating \
486                              credentials via the default provider chain (auth \
487                              'default'). Only use auth 'static' — an IAM user access \
488                              key in aws/config/{target} — where no attached role is \
489                              available, and rotate it."
490                .to_string(),
491            paths: vec![
492                PathDoc::new(
493                    "aws/config/{target}",
494                    &["POST", "GET", "DELETE"],
495                    "sudo",
496                    "register the region and how to authenticate. GET reports only \
497                     whether it is configured — keys are never returned.",
498                ),
499                PathDoc::new(
500                    "aws/roles/{role}",
501                    &["POST", "GET", "DELETE"],
502                    "create / read / sudo",
503                    "define one consumer's credential type, role ARN, session policy \
504                     and TTL",
505                ),
506                PathDoc::new(
507                    "aws/creds/{role}",
508                    &["GET"],
509                    "read",
510                    "mint a session or an IAM user access key, and open a lease",
511                ),
512                PathDoc::new("aws/help", &["GET"], "authenticated", "this document"),
513            ],
514            docs_url: Some("docs/delegation/aws.md".to_string()),
515            caveats: vec![
516                "No STS session can be revoked. The console's \"revoke active \
517                 sessions\" button only attaches a role-wide Deny conditioned on \
518                 aws:TokenIssueTime, which also kills every innocent session issued \
519                 from that role before that moment — revocation is per-role, never \
520                 per-session. Give each consumer its own role if you need precision."
521                    .to_string(),
522                "DurationSeconds must be 900–43200 and within the role's own \
523                 MaxSessionDuration; exceeding the latter fails the call rather than \
524                 truncating it."
525                    .to_string(),
526                "Role chaining — an assumed role assuming another role — caps the \
527                 resulting session at 1 hour regardless of any other setting."
528                    .to_string(),
529                "Session policies only ever narrow. They cannot grant a permission \
530                 the role itself lacks, so a policy that looks ignored usually means \
531                 the role never had that permission."
532                    .to_string(),
533                "IAM allows only two access keys per user, which is why 'iam_user' \
534                 creates a user per lease rather than keys on a shared user."
535                    .to_string(),
536                "IAM is eventually consistent: a freshly created access key may not \
537                 authenticate for a few seconds, and a deleted one may keep working \
538                 just as briefly. Retry rather than treating either as failure."
539                    .to_string(),
540            ],
541        }
542    }
543
544    async fn read(&self, storage: &dyn StorageBackend, path: &str) -> EngineResult<serde_json::Value> {
545        STORE.handle_read::<RoleConfig>(storage, path).await
546    }
547
548    async fn write(
549        &self,
550        storage: &dyn StorageBackend,
551        path: &str,
552        data: serde_json::Value,
553    ) -> EngineResult<()> {
554        STORE.handle_write::<AwsConfig, RoleConfig>(storage, path, data).await
555    }
556
557    async fn delete(&self, storage: &dyn StorageBackend, path: &str) -> EngineResult<()> {
558        STORE.handle_delete(storage, path).await
559    }
560
561    async fn list(&self, storage: &dyn StorageBackend, prefix: &str) -> EngineResult<Vec<String>> {
562        STORE.handle_list(storage, prefix).await
563    }
564
565    async fn generate(
566        &self,
567        storage: &dyn StorageBackend,
568        role_name: &str,
569    ) -> EngineResult<GeneratedCredential> {
570        let role: RoleConfig = STORE.require_role(storage, role_name).await?;
571        let config: AwsConfig = STORE.require_config(storage, &role.target).await?;
572        let sdk = self.sdk_config(&role.target, &config).await?;
573
574        match role.credential_type {
575            CredentialType::AssumedRole => self.assume_role(&sdk, role_name, &role).await,
576            CredentialType::IamUser => self.iam_user(&sdk, role_name, &role).await,
577        }
578    }
579
580    async fn revoke(&self, storage: &dyn StorageBackend, lease: &Lease) -> EngineResult<()> {
581        let credential_type = lease.internal_data["credential_type"]
582            .as_str()
583            .unwrap_or("assumed_role");
584
585        if credential_type != "iam_user" {
586            // Deliberately not an error. The reaper needs to clear the lease
587            // record, and failing here would only make it retry forever against
588            // a provider that has no revocation API at all.
589            tracing::warn!(
590                lease_id = %lease.id,
591                "AWS cannot revoke an issued STS session; the credential remains \
592                 valid until its expiry. Deleting the lease record only."
593            );
594            return Ok(());
595        }
596
597        let user_name = lease.internal_data["user_name"]
598            .as_str()
599            .ok_or_else(|| EngineError::Other("lease missing 'user_name'".into()))?;
600        let target = lease.internal_data["target"]
601            .as_str()
602            .ok_or_else(|| EngineError::Other("lease missing 'target'".into()))?;
603        let access_key_id = lease.internal_data["access_key_id"].as_str();
604
605        let config: AwsConfig = STORE.require_config(storage, target).await?;
606        let sdk = self.sdk_config(target, &config).await?;
607        let iam = aws_sdk_iam::Client::new(&sdk);
608
609        // Every step tolerates "already gone" so the reaper can retry a
610        // partially-completed revocation without getting stuck.
611        if let Some(access_key_id) = access_key_id
612            && let Err(e) = iam
613                .delete_access_key()
614                .user_name(user_name)
615                .access_key_id(access_key_id)
616                .send()
617                .await
618            && !is_missing(&e)
619        {
620            return Err(EngineError::Provider(format!(
621                "AWS DeleteAccessKey failed: {}",
622                chain(&e)
623            )));
624        }
625
626        if lease.internal_data["has_inline_policy"]
627            .as_bool()
628            .unwrap_or(true)
629            && let Err(e) = iam
630                .delete_user_policy()
631                .user_name(user_name)
632                .policy_name(INLINE_POLICY_NAME)
633                .send()
634                .await
635            && !is_missing(&e)
636        {
637            return Err(EngineError::Provider(format!(
638                "AWS DeleteUserPolicy failed: {}",
639                chain(&e)
640            )));
641        }
642
643        if let Err(e) = iam.delete_user().user_name(user_name).send().await
644            && !is_missing(&e)
645        {
646            return Err(EngineError::Provider(format!(
647                "AWS DeleteUser failed: {}",
648                chain(&e)
649            )));
650        }
651
652        Ok(())
653    }
654}
655
656/// IAM reports an already-deleted resource as `NoSuchEntity`, which is success
657/// as far as revocation is concerned. Matching the error *code* rather than the
658/// message keeps the reaper's retry path from breaking on a wording change.
659fn is_missing<E: ProvideErrorMetadata>(error: &E) -> bool {
660    error.code() == Some("NoSuchEntity")
661}
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666
667    fn role(credential_type: CredentialType) -> RoleConfig {
668        RoleConfig {
669            target: "production".to_string(),
670            credential_type,
671            role_arn: Some("arn:aws:iam::123456789012:role/reports".to_string()),
672            session_policy: None,
673            session_policy_arns: vec![],
674            external_id: None,
675            user_policy: None,
676            default_ttl_seconds: 900,
677        }
678    }
679
680    #[test]
681    fn generated_names_fit_the_provider_limits() {
682        let long_role = "a-very-long-consumer-name-".repeat(10);
683        let user = unique_name("v", &long_role, 12, IAM_MAX_USER_NAME);
684        let session = unique_name("s", &long_role, 8, STS_MAX_SESSION_NAME);
685        assert!(user.len() <= IAM_MAX_USER_NAME, "{} chars", user.len());
686        assert!(session.len() <= STS_MAX_SESSION_NAME, "{} chars", session.len());
687    }
688
689    /// IAM rejects anything outside `[\w+=,.@-]`, so a role name containing a
690    /// slash or a space must not reach AWS verbatim.
691    #[test]
692    fn generated_names_are_sanitised() {
693        let name = unique_name("v", "team/reports svc!", 12, IAM_MAX_USER_NAME);
694        assert!(
695            name.chars()
696                .all(|c| c.is_ascii_alphanumeric() || "+=,.@-_".contains(c)),
697            "{name} contains a character IAM will reject"
698        );
699    }
700
701    #[test]
702    fn generated_names_are_unique_per_call() {
703        let first = unique_name("v", "reports", 12, IAM_MAX_USER_NAME);
704        let second = unique_name("v", "reports", 12, IAM_MAX_USER_NAME);
705        assert_ne!(first, second);
706    }
707
708    /// The whole point of the per-credential override: an IAM user is
709    /// revocable, an STS session is not, and the `_doc` must not average them.
710    #[test]
711    fn only_the_iam_user_path_claims_revocability() {
712        assert!(guarantees_for(CredentialType::AssumedRole).is_none());
713        let (shape, effect) = guarantees_for(CredentialType::IamUser).expect("override");
714        assert_eq!(shape, CredentialShape::MintAndRevoke);
715        assert!(shape.revocable());
716        assert!(!effect.is_empty());
717    }
718
719    #[test]
720    fn session_ttl_outside_the_sts_envelope_is_rejected() {
721        assert!(validate_session_ttl(60).is_err());
722        assert!(validate_session_ttl(MAX_SESSION_SECONDS + 1).is_err());
723        assert_eq!(validate_session_ttl(900).unwrap(), 900);
724        assert_eq!(
725            validate_session_ttl(MAX_SESSION_SECONDS).unwrap(),
726            MAX_SESSION_SECONDS as i32
727        );
728    }
729
730    /// An unscoped session hands over the role's full permissions, so the
731    /// consumer's `_doc` has to say that rather than showing an empty list.
732    #[test]
733    fn scope_description_calls_out_a_missing_policy() {
734        let scoped = scope_description(&role(CredentialType::AssumedRole), "arn:aws:iam::1:role/r");
735        assert!(scoped.iter().any(|s| s.contains("session_policy: NONE")));
736
737        let mut scoped_role = role(CredentialType::AssumedRole);
738        scoped_role.session_policy = Some(json!({"Version": "2012-10-17"}));
739        let scoped = scope_description(&scoped_role, "arn:aws:iam::1:role/r");
740        assert!(scoped.iter().any(|s| s.contains("intersection")));
741    }
742
743    #[test]
744    fn scope_description_calls_out_a_powerless_iam_user() {
745        let scoped = scope_description(&role(CredentialType::IamUser), "iam-user:v-reports-abc");
746        assert!(scoped.iter().any(|s| s.contains("user_policy: NONE")));
747    }
748
749    #[test]
750    fn doc_agrees_with_its_shape() {
751        let doc = AwsEngine::new().doc();
752        assert_eq!(doc.shape, CredentialShape::MintExpiryOnly);
753        assert_eq!(doc.revocable, doc.shape.revocable());
754        assert!(!doc.revocable, "an STS session cannot be revoked");
755        assert!(
756            doc.revoke_effect.contains("NOTHING"),
757            "the doc must not dress up a no-op revocation"
758        );
759    }
760
761    /// `assumed_role` without a role ARN is a configuration error worth
762    /// catching before it becomes a confusing AWS error.
763    #[test]
764    fn credential_type_defaults_to_assumed_role() {
765        let parsed: RoleConfig = serde_json::from_value(json!({
766            "target": "production",
767            "role_arn": "arn:aws:iam::123456789012:role/reports",
768        }))
769        .expect("role should parse with defaults");
770        assert_eq!(parsed.credential_type, CredentialType::AssumedRole);
771        assert_eq!(parsed.default_ttl_seconds, MIN_SESSION_SECONDS);
772    }
773
774    #[test]
775    fn config_defaults_to_the_provider_chain() {
776        let parsed: AwsConfig = serde_json::from_value(json!({ "region": "eu-west-3" }))
777            .expect("config should parse with defaults");
778        assert_eq!(parsed.auth, AuthMode::Default);
779        assert!(parsed.access_key_id.is_none());
780    }
781}