1use 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
37const MIN_SESSION_SECONDS: i64 = 900;
42const MAX_SESSION_SECONDS: i64 = 43200;
43
44const IAM_MAX_USER_NAME: usize = 64;
46const STS_MAX_SESSION_NAME: usize = 64;
47
48const INLINE_POLICY_NAME: &str = "secrets-server-lease";
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct AwsConfig {
55 pub region: String,
56 #[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
88fn default_ttl_seconds() -> i64 {
91 MIN_SESSION_SECONDS
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct RoleConfig {
96 pub target: String,
98 #[serde(default = "default_credential_type")]
99 pub credential_type: CredentialType,
100 #[serde(default)]
102 pub role_arn: Option<String>,
103 #[serde(default)]
106 pub session_policy: Option<serde_json::Value>,
107 #[serde(default)]
108 pub session_policy_arns: Vec<String>,
109 #[serde(default)]
111 pub external_id: Option<String>,
112 #[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 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 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 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 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 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
349fn guarantees_for(credential_type: CredentialType) -> Option<(CredentialShape, &'static str)> {
353 match credential_type {
354 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
365async 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
395fn 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
441fn 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 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 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
656fn 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 #[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 #[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 #[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 #[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}