fakecloud_core/auth.rs
1//! Authentication and authorization primitives shared across services.
2//!
3//! This module defines the opt-in modes for SigV4 signature verification and
4//! IAM policy enforcement, plus the reserved "root bypass" identity that
5//! short-circuits both checks when enabled.
6//!
7//! Neither feature is enforced at this layer — the types are plumbed through
8//! [`crate::dispatch::DispatchConfig`] and consulted later by dispatch and
9//! service handlers once the corresponding batches land. See
10//! `/docs/reference/security` (added in a later batch) for the user-facing
11//! contract.
12
13use std::collections::{BTreeMap, HashMap};
14use std::fmt;
15use std::net::IpAddr;
16use std::str::FromStr;
17use std::sync::Arc;
18
19use chrono::{DateTime, Utc};
20
21/// Kind of principal a set of credentials resolves to.
22///
23/// Used to drive IAM policy evaluation (Phase 2) and the `GetCallerIdentity`
24/// response shape. Inferred from the credential's storage path in
25/// [`IamState`] and — for STS temporary credentials — from the ARN form
26/// `arn:aws:sts::<account>:assumed-role/...` or `federated-user/...`.
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
28pub enum PrincipalType {
29 /// An IAM user access key (AKID created via `CreateAccessKey`).
30 User,
31 /// An assumed role session issued by `AssumeRole` /
32 /// `AssumeRoleWithWebIdentity` / `AssumeRoleWithSAML`.
33 AssumedRole,
34 /// Credentials issued by `GetFederationToken` — i.e. a federated user.
35 FederatedUser,
36 /// The account root identity. Reserved for explicit `...:root` ARNs
37 /// only; do not return this from a generic fallback because root
38 /// principals bypass IAM enforcement (see `Principal::is_root`).
39 Root,
40 /// The ARN didn't match any known shape. Treated as a non-root,
41 /// non-bypassable principal so a malformed or unexpected ARN can never
42 /// silently grant elevated permissions during IAM evaluation.
43 Unknown,
44}
45
46impl PrincipalType {
47 pub fn as_str(self) -> &'static str {
48 match self {
49 PrincipalType::User => "user",
50 PrincipalType::AssumedRole => "assumed-role",
51 PrincipalType::FederatedUser => "federated-user",
52 PrincipalType::Root => "root",
53 PrincipalType::Unknown => "unknown",
54 }
55 }
56
57 /// Classify a principal from its ARN. Returns [`PrincipalType::Unknown`]
58 /// for ARNs that don't match any of the well-known principal shapes —
59 /// **never** [`PrincipalType::Root`] as a fallback, because root
60 /// bypasses IAM enforcement and silently treating malformed ARNs as
61 /// root would let unexpected inputs grant elevated permissions
62 /// (identified by cubic in PR #391 review).
63 pub fn from_arn(arn: &str) -> Self {
64 if arn.ends_with(":root") {
65 PrincipalType::Root
66 } else if arn.contains(":user/") {
67 PrincipalType::User
68 } else if arn.contains(":assumed-role/") {
69 PrincipalType::AssumedRole
70 } else if arn.contains(":federated-user/") {
71 PrincipalType::FederatedUser
72 } else {
73 PrincipalType::Unknown
74 }
75 }
76}
77
78/// Identity of the caller making a request, once its credentials have been
79/// resolved. Attached to [`crate::service::AwsRequest::principal`] so
80/// handlers can make identity-based decisions without re-parsing the
81/// Authorization header.
82///
83/// `account_id` is always sourced from the credential itself (via
84/// [`CredentialResolver`]), never from global config — #381 note.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct Principal {
87 pub arn: String,
88 pub user_id: String,
89 pub account_id: String,
90 pub principal_type: PrincipalType,
91 /// Optional source identity string, carried through from
92 /// `AssumeRole`'s `SourceIdentity` parameter. Reserved for later
93 /// batches that wire session policies and auditing.
94 pub source_identity: Option<String>,
95 /// Tags on the calling principal (IAM user or assumed role).
96 /// Populated at credential-resolution time from `IamState`.
97 /// Used for `aws:PrincipalTag/<key>` condition evaluation.
98 pub tags: Option<HashMap<String, String>>,
99}
100
101impl Principal {
102 /// Is this caller the account's root identity? Root bypasses IAM
103 /// evaluation, matching AWS.
104 pub fn is_root(&self) -> bool {
105 matches!(self.principal_type, PrincipalType::Root) || self.arn.ends_with(":root")
106 }
107}
108
109/// Credentials resolved from an access key ID.
110///
111/// Returned by [`CredentialResolver::resolve`]. Holds both the secret access
112/// key (needed for SigV4 verification) and the resolved [`Principal`]
113/// (needed for IAM enforcement and `GetCallerIdentity` consolidation).
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct ResolvedCredential {
116 pub secret_access_key: String,
117 pub session_token: Option<String>,
118 pub principal: Principal,
119 /// Session policies passed to the STS call that minted this credential.
120 /// Empty for IAM user access keys.
121 pub session_policies: Vec<String>,
122}
123
124impl ResolvedCredential {
125 /// Convenience accessors for the flat fields batch 3 callers use. Kept
126 /// as methods rather than re-adding the fields to avoid making the
127 /// shape inconsistent with [`Principal`] itself.
128 pub fn principal_arn(&self) -> &str {
129 &self.principal.arn
130 }
131
132 pub fn user_id(&self) -> &str {
133 &self.principal.user_id
134 }
135
136 pub fn account_id(&self) -> &str {
137 &self.principal.account_id
138 }
139}
140
141/// Abstraction over "given an access key ID, return the secret and resolved
142/// principal." Implemented by the IAM crate against `IamState`; the core
143/// crate depends only on the trait so there's no circular dependency.
144///
145/// Implementations must be cheap to clone-share via `Arc` and must be
146/// thread-safe — dispatch calls them from an axum handler under a tokio
147/// worker.
148pub trait CredentialResolver: Send + Sync {
149 /// Resolve `access_key_id` to its secret access key and principal.
150 /// Returns `None` when the AKID is unknown or its underlying credential
151 /// has expired.
152 fn resolve(&self, access_key_id: &str) -> Option<ResolvedCredential>;
153}
154
155/// One IAM action that the dispatch layer should evaluate against the
156/// caller's effective policy set.
157///
158/// Produced by [`crate::service::AwsService::iam_action_for`] on services
159/// that opt into enforcement. The `resource` is a fully-qualified AWS ARN
160/// built from `request.principal.account_id` so multi-account isolation
161/// (#381) becomes a state-partitioning change rather than a cross-cutting
162/// rewrite.
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct IamAction {
165 /// IAM service prefix, e.g. `"s3"`, `"sqs"`, `"iam"`.
166 pub service: &'static str,
167 /// AWS action name, e.g. `"GetObject"`, `"SendMessage"`.
168 pub action: &'static str,
169 /// Fully-qualified ARN of the target resource.
170 pub resource: String,
171}
172
173impl IamAction {
174 /// Compose the canonical `service:Action` string the evaluator
175 /// matches against.
176 pub fn action_string(&self) -> String {
177 format!("{}:{}", self.service, self.action)
178 }
179}
180
181/// Result of evaluating a request against an identity's effective policy
182/// set. Abstract over the concrete evaluator [`Decision`] in
183/// `fakecloud-iam::evaluator` so `fakecloud-core` can consume it without
184/// depending on `fakecloud-iam`.
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub enum IamDecision {
187 Allow,
188 ImplicitDeny,
189 ExplicitDeny,
190}
191
192impl IamDecision {
193 pub fn is_allow(self) -> bool {
194 matches!(self, IamDecision::Allow)
195 }
196}
197
198/// Request-time values consulted when a policy statement carries a
199/// `Condition` block. Populated at dispatch time from the resolved
200/// [`Principal`] and the incoming HTTP request, then handed to
201/// [`IamPolicyEvaluator::evaluate`].
202///
203/// Lives in `fakecloud-core` (not `fakecloud-iam`) so the trait can
204/// reference it without creating a circular crate dependency. All
205/// fields are optional — a missing field means the key wasn't knowable
206/// at dispatch time, and any operator that references it safe-fails to
207/// `false` (unless the operator carries the `IfExists` suffix, in which
208/// case it evaluates to `true`, matching AWS).
209///
210/// The `service_keys` map is reserved for service-specific condition
211/// keys (`s3:prefix`, `sqs:MessageAttribute`, …) which Phase 2 ships
212/// empty; service-specific support lands in a follow-up batch without
213/// a signature change.
214#[derive(Debug, Clone, Default)]
215pub struct ConditionContext {
216 /// `aws:username` — username segment of an IAM user ARN, or `None`
217 /// for assumed roles / federated users where AWS does not set the key.
218 pub aws_username: Option<String>,
219 /// `aws:userid` — the unique `AIDA...`/`AROA...` identifier.
220 pub aws_userid: Option<String>,
221 /// `aws:PrincipalArn` — full principal ARN.
222 pub aws_principal_arn: Option<String>,
223 /// `aws:PrincipalAccount` — 12-digit account ID sourced from the
224 /// credential, not global config (#381 multi-account alignment).
225 pub aws_principal_account: Option<String>,
226 /// `aws:PrincipalType` — `"User"`, `"AssumedRole"`, etc.
227 pub aws_principal_type: Option<String>,
228 /// `aws:SourceIp` — remote address of the HTTP connection.
229 pub aws_source_ip: Option<IpAddr>,
230 /// `aws:CurrentTime` — evaluation timestamp (UTC).
231 pub aws_current_time: Option<DateTime<Utc>>,
232 /// `aws:EpochTime` — same moment as `aws_current_time` in seconds
233 /// since the Unix epoch.
234 pub aws_epoch_time: Option<i64>,
235 /// `aws:SecureTransport` — `true` iff the request came in over TLS.
236 pub aws_secure_transport: Option<bool>,
237 /// `aws:RequestedRegion` — region extracted from SigV4 / config.
238 pub aws_requested_region: Option<String>,
239 /// Service-specific keys (`s3:prefix`, `sqs:MessageAttribute`, …).
240 pub service_keys: BTreeMap<String, Vec<String>>,
241 /// `aws:ResourceTag/<key>` — tags on the target resource.
242 /// Populated by [`crate::service::AwsService::resource_tags_for`].
243 /// `None` means the service doesn't expose resource tags for ABAC.
244 pub resource_tags: Option<HashMap<String, String>>,
245 /// `aws:RequestTag/<key>` — tags sent in the request body/headers.
246 /// Populated by [`crate::service::AwsService::request_tags_from`].
247 /// Also drives `aws:TagKeys` (the list of request tag keys).
248 pub request_tags: Option<HashMap<String, String>>,
249 /// `aws:PrincipalTag/<key>` — tags on the calling IAM user or role.
250 /// Populated from [`Principal::tags`] at dispatch time.
251 pub principal_tags: Option<HashMap<String, String>>,
252}
253
254impl ConditionContext {
255 /// Resolve a condition key (e.g. `"aws:username"`) to the list of
256 /// context values. Returns `None` if the key is not populated.
257 /// Key names are matched case-insensitively — AWS treats
258 /// `aws:username` and `AWS:UserName` as the same key.
259 pub fn lookup(&self, key: &str) -> Option<Vec<String>> {
260 let lower = key.to_ascii_lowercase();
261 let one = |s: &str| Some(vec![s.to_string()]);
262
263 // ABAC tag-based keys: case-insensitive prefix, case-sensitive
264 // tag key (the part after the slash). AWS treats "Environment"
265 // and "environment" as distinct tag keys.
266 //
267 // Prefix lengths: "aws:resourcetag/" = 16, "aws:requesttag/" = 15,
268 // "aws:principaltag/" = 17
269 if lower.starts_with("aws:resourcetag/") {
270 let tag_key = &key[16..]; // preserve original case
271 return self
272 .resource_tags
273 .as_ref()
274 .and_then(|tags| tags.get(tag_key))
275 .map(|v| vec![v.clone()]);
276 }
277 if lower.starts_with("aws:requesttag/") {
278 let tag_key = &key[15..];
279 return self
280 .request_tags
281 .as_ref()
282 .and_then(|tags| tags.get(tag_key))
283 .map(|v| vec![v.clone()]);
284 }
285 if lower.starts_with("aws:principaltag/") {
286 let tag_key = &key[17..];
287 return self
288 .principal_tags
289 .as_ref()
290 .and_then(|tags| tags.get(tag_key))
291 .map(|v| vec![v.clone()]);
292 }
293 if lower == "aws:tagkeys" {
294 return self
295 .request_tags
296 .as_ref()
297 .map(|tags| tags.keys().cloned().collect());
298 }
299
300 match lower.as_str() {
301 "aws:username" => self.aws_username.as_deref().and_then(one),
302 "aws:userid" => self.aws_userid.as_deref().and_then(one),
303 "aws:principalarn" => self.aws_principal_arn.as_deref().and_then(one),
304 "aws:principalaccount" => self.aws_principal_account.as_deref().and_then(one),
305 "aws:principaltype" => self.aws_principal_type.as_deref().and_then(one),
306 "aws:sourceip" => self.aws_source_ip.map(|ip| vec![ip.to_string()]),
307 "aws:currenttime" => self
308 .aws_current_time
309 .map(|t| vec![t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)]),
310 "aws:epochtime" => self.aws_epoch_time.map(|e| vec![e.to_string()]),
311 "aws:securetransport" => self.aws_secure_transport.map(|b| vec![b.to_string()]),
312 "aws:requestedregion" => self.aws_requested_region.as_deref().and_then(one),
313 _ => {
314 if let Some(vs) = self.service_keys.get(&lower) {
315 if vs.is_empty() {
316 None
317 } else {
318 Some(vs.clone())
319 }
320 } else {
321 self.service_keys
322 .iter()
323 .find(|(k, _)| k.eq_ignore_ascii_case(key))
324 .map(|(_, vs)| vs.clone())
325 }
326 }
327 }
328 }
329}
330
331/// Abstraction over "given a principal, an action, and request-time
332/// condition keys, say Allow / Deny". Implemented by `fakecloud-iam`
333/// against `IamState` + the evaluator. Dispatch calls this for every
334/// request when `FAKECLOUD_IAM != off` and the target service opts in.
335pub trait IamPolicyEvaluator: Send + Sync {
336 /// Evaluate `action` against the identity policies attached to
337 /// `principal`, using `context` for `Condition` block resolution.
338 /// `session_policies` are the raw JSON session-policy documents
339 /// from the STS call that minted the caller's credential (empty
340 /// for IAM user access keys). `scps` are the inherited SCP
341 /// documents (root-OU first, account-direct last) that form the
342 /// top-of-chain allow-list ceiling; `None` means no org exists
343 /// for this principal or the principal is exempt (management,
344 /// service-linked role) and the layer is a pass-through.
345 fn evaluate(
346 &self,
347 principal: &Principal,
348 action: &IamAction,
349 context: &ConditionContext,
350 session_policies: &[String],
351 scps: Option<&[String]>,
352 ) -> IamDecision;
353
354 /// Evaluate with resource-policy + session-policy intersection.
355 /// `scps` follows the same semantics as in [`Self::evaluate`].
356 #[allow(clippy::too_many_arguments)]
357 fn evaluate_with_resource_policy(
358 &self,
359 principal: &Principal,
360 action: &IamAction,
361 context: &ConditionContext,
362 resource_policy_json: Option<&str>,
363 resource_account_id: &str,
364 session_policies: &[String],
365 scps: Option<&[String]>,
366 ) -> IamDecision;
367}
368
369/// Abstraction over "given a principal, return the inherited SCP
370/// documents that form the top-of-chain allow-list ceiling for the
371/// principal's account". Implemented by `fakecloud-organizations`.
372///
373/// Returning `None` means SCPs do not apply (no org exists for this
374/// fakecloud process, or the principal is the management account, or
375/// the principal is a service-linked role, or the account is not
376/// enrolled in the organization). Dispatch plumbs the returned slice
377/// straight into [`IamPolicyEvaluator`].
378///
379/// The ordered list puts root-OU-attached policies first, then each
380/// descendant OU down to the account's parent, and account-direct
381/// attachments last — the evaluator treats each entry as a separate
382/// gate that must allow (intersection), matching AWS SCP semantics.
383pub trait ScpResolver: Send + Sync {
384 fn scps_for(&self, principal: &Principal) -> Option<Vec<String>>;
385}
386
387/// Abstraction over "given a service + a fully-qualified resource ARN,
388/// return the resource-based policy attached to that resource, if any."
389///
390/// Implemented by resource-owning services (S3 for bucket policies in
391/// the initial rollout; SNS topic policies, KMS key policies, and
392/// Lambda resource policies are separate future wirings) and plumbed
393/// through [`crate::dispatch::DispatchConfig`] alongside
394/// [`IamPolicyEvaluator`]. Dispatch fetches the policy for the target
395/// resource and hands it to the evaluator so cross-account Allow/Deny
396/// semantics can be computed.
397///
398/// Implementations must be cheap to clone-share via `Arc` and must be
399/// thread-safe — dispatch calls them on every enforced request.
400///
401/// Returning `None` means "no resource policy attached / resource
402/// doesn't exist / this provider doesn't handle that service." Returning
403/// `Some(json)` yields the raw JSON document as stored by the
404/// resource's CRUD handlers; parsing happens inside the evaluator so a
405/// malformed document logs a debug audit event and falls through to
406/// "no resource policy" rather than silently allowing.
407pub trait ResourcePolicyProvider: Send + Sync {
408 /// Fetch the resource-based policy document attached to
409 /// `resource_arn` on `service`. Both arguments are lowercase-ish
410 /// (`"s3"`, `"arn:aws:s3:::my-bucket"`); implementations should
411 /// match the service prefix they own and return `None` for
412 /// anything else so providers can be composed safely.
413 fn resource_policy(&self, service: &str, resource_arn: &str) -> Option<String>;
414}
415
416/// Composite [`ResourcePolicyProvider`] that delegates to a list of
417/// sub-providers in order, returning the first `Some` hit.
418///
419/// Each concrete provider (`S3ResourcePolicyProvider`,
420/// `SnsResourcePolicyProvider`, `LambdaResourcePolicyProvider`, …)
421/// already gates on its own service prefix and returns `None` for
422/// anything it doesn't own, so composition is short-circuit and
423/// order-independent. Server bootstrap builds one of these holding
424/// every resource-owning service and passes it to
425/// [`crate::dispatch::DispatchConfig::resource_policy_provider`].
426///
427/// This is the extension point for future resource-owning services:
428/// adding KMS key policies (or anything else) is a one-line push at
429/// bootstrap, never a core-crate refactor.
430pub struct MultiResourcePolicyProvider {
431 providers: Vec<Arc<dyn ResourcePolicyProvider>>,
432}
433
434impl MultiResourcePolicyProvider {
435 /// Build a composite from a list of providers.
436 pub fn new(providers: Vec<Arc<dyn ResourcePolicyProvider>>) -> Self {
437 Self { providers }
438 }
439
440 /// Shared constructor returning the composite as an
441 /// `Arc<dyn ResourcePolicyProvider>`, matching the signature of
442 /// `DispatchConfig::resource_policy_provider`.
443 pub fn shared(
444 providers: Vec<Arc<dyn ResourcePolicyProvider>>,
445 ) -> Arc<dyn ResourcePolicyProvider> {
446 Arc::new(Self::new(providers))
447 }
448
449 /// Number of sub-providers held by this composite. Used by tests.
450 pub fn len(&self) -> usize {
451 self.providers.len()
452 }
453
454 /// True when no sub-providers are registered.
455 pub fn is_empty(&self) -> bool {
456 self.providers.is_empty()
457 }
458}
459
460impl ResourcePolicyProvider for MultiResourcePolicyProvider {
461 fn resource_policy(&self, service: &str, resource_arn: &str) -> Option<String> {
462 self.providers
463 .iter()
464 .find_map(|p| p.resource_policy(service, resource_arn))
465 }
466}
467
468/// How IAM identity policies are evaluated for incoming requests.
469///
470/// Default is [`IamMode::Off`] — existing behavior, policies are stored but
471/// never consulted. [`IamMode::Soft`] evaluates and logs denied decisions via
472/// the `fakecloud::iam::audit` tracing target without failing the request, and
473/// [`IamMode::Strict`] returns an `AccessDeniedException` in the protocol-
474/// correct shape.
475#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
476pub enum IamMode {
477 /// Do not evaluate IAM policies.
478 #[default]
479 Off,
480 /// Evaluate policies and log audit events for denied requests, but allow
481 /// the request to proceed.
482 Soft,
483 /// Evaluate policies and reject denied requests with `AccessDeniedException`.
484 Strict,
485}
486
487impl IamMode {
488 /// Returns true when policy evaluation should occur at all.
489 pub fn is_enabled(self) -> bool {
490 !matches!(self, IamMode::Off)
491 }
492
493 /// Returns true when denied decisions should fail the request.
494 pub fn is_strict(self) -> bool {
495 matches!(self, IamMode::Strict)
496 }
497
498 pub fn as_str(self) -> &'static str {
499 match self {
500 IamMode::Off => "off",
501 IamMode::Soft => "soft",
502 IamMode::Strict => "strict",
503 }
504 }
505}
506
507impl fmt::Display for IamMode {
508 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
509 f.write_str(self.as_str())
510 }
511}
512
513/// Parse error for [`IamMode`] from string.
514#[derive(Debug)]
515pub struct ParseIamModeError(String);
516
517impl fmt::Display for ParseIamModeError {
518 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
519 write!(
520 f,
521 "invalid IAM mode `{}`; expected one of: off, soft, strict",
522 self.0
523 )
524 }
525}
526
527impl std::error::Error for ParseIamModeError {}
528
529impl FromStr for IamMode {
530 type Err = ParseIamModeError;
531
532 fn from_str(s: &str) -> Result<Self, Self::Err> {
533 match s.trim().to_ascii_lowercase().as_str() {
534 "off" | "none" | "disabled" => Ok(IamMode::Off),
535 "soft" | "audit" | "warn" => Ok(IamMode::Soft),
536 "strict" | "enforce" | "deny" => Ok(IamMode::Strict),
537 other => Err(ParseIamModeError(other.to_string())),
538 }
539 }
540}
541
542/// Reserved root-identity convention.
543///
544/// Any access key whose ID begins with `test` (case-insensitive) is treated as
545/// the de-facto root bypass. This matches the long-standing community
546/// convention used by LocalStack and Floci: `test`/`test` credentials should
547/// always "just work" for local development.
548///
549/// When SigV4 verification or IAM enforcement is enabled, callers using a
550/// bypass AKID skip both checks. We emit a one-time startup WARN whenever
551/// enforcement is turned on so users understand that unsigned `test` clients
552/// will silently receive positive results.
553pub fn is_root_bypass(access_key_id: &str) -> bool {
554 access_key_id
555 .trim()
556 .get(..4)
557 .is_some_and(|prefix| prefix.eq_ignore_ascii_case("test"))
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563
564 #[test]
565 fn iam_mode_default_is_off() {
566 assert_eq!(IamMode::default(), IamMode::Off);
567 assert!(!IamMode::default().is_enabled());
568 }
569
570 #[test]
571 fn iam_mode_from_str_accepts_primary_values() {
572 assert_eq!(IamMode::from_str("off").unwrap(), IamMode::Off);
573 assert_eq!(IamMode::from_str("soft").unwrap(), IamMode::Soft);
574 assert_eq!(IamMode::from_str("strict").unwrap(), IamMode::Strict);
575 }
576
577 #[test]
578 fn iam_mode_from_str_is_case_insensitive_and_trimmed() {
579 assert_eq!(IamMode::from_str(" OFF ").unwrap(), IamMode::Off);
580 assert_eq!(IamMode::from_str("Soft").unwrap(), IamMode::Soft);
581 assert_eq!(IamMode::from_str("STRICT").unwrap(), IamMode::Strict);
582 }
583
584 #[test]
585 fn iam_mode_from_str_accepts_aliases() {
586 assert_eq!(IamMode::from_str("disabled").unwrap(), IamMode::Off);
587 assert_eq!(IamMode::from_str("audit").unwrap(), IamMode::Soft);
588 assert_eq!(IamMode::from_str("enforce").unwrap(), IamMode::Strict);
589 }
590
591 #[test]
592 fn iam_mode_from_str_rejects_garbage() {
593 assert!(IamMode::from_str("").is_err());
594 assert!(IamMode::from_str("allow").is_err());
595 assert!(IamMode::from_str("yes").is_err());
596 }
597
598 #[test]
599 fn iam_mode_display_roundtrips() {
600 for mode in [IamMode::Off, IamMode::Soft, IamMode::Strict] {
601 assert_eq!(IamMode::from_str(&mode.to_string()).unwrap(), mode);
602 }
603 }
604
605 #[test]
606 fn iam_mode_flags() {
607 assert!(!IamMode::Off.is_enabled());
608 assert!(!IamMode::Off.is_strict());
609 assert!(IamMode::Soft.is_enabled());
610 assert!(!IamMode::Soft.is_strict());
611 assert!(IamMode::Strict.is_enabled());
612 assert!(IamMode::Strict.is_strict());
613 }
614
615 #[test]
616 fn root_bypass_matches_test_prefix() {
617 assert!(is_root_bypass("test"));
618 assert!(is_root_bypass("TEST"));
619 assert!(is_root_bypass("Test"));
620 assert!(is_root_bypass("testAccessKey"));
621 assert!(is_root_bypass("TESTAKIAIOSFODNN7EXAMPLE"));
622 }
623
624 #[test]
625 fn root_bypass_does_not_panic_on_multibyte_input() {
626 // Byte index 4 falls inside a multi-byte UTF-8 character; must not panic.
627 assert!(!is_root_bypass("té"));
628 assert!(!is_root_bypass("日本語キー"));
629 assert!(!is_root_bypass("🔑🔑"));
630 }
631
632 #[test]
633 fn principal_type_from_arn_classifies_known_shapes() {
634 assert_eq!(
635 PrincipalType::from_arn("arn:aws:iam::123456789012:user/alice"),
636 PrincipalType::User
637 );
638 assert_eq!(
639 PrincipalType::from_arn("arn:aws:sts::123456789012:assumed-role/R/s"),
640 PrincipalType::AssumedRole
641 );
642 assert_eq!(
643 PrincipalType::from_arn("arn:aws:sts::123456789012:federated-user/bob"),
644 PrincipalType::FederatedUser
645 );
646 assert_eq!(
647 PrincipalType::from_arn("arn:aws:iam::123456789012:root"),
648 PrincipalType::Root
649 );
650 }
651
652 #[test]
653 fn principal_type_unparseable_is_unknown_not_root() {
654 // Identified by cubic on PR #391: falling back to Root would let
655 // malformed or unexpected ARNs bypass IAM enforcement, since
656 // Principal::is_root short-circuits evaluation. The fallback must
657 // be the non-bypassable Unknown variant.
658 assert_eq!(
659 PrincipalType::from_arn("not-an-arn"),
660 PrincipalType::Unknown
661 );
662 assert_eq!(PrincipalType::from_arn(""), PrincipalType::Unknown);
663 assert_eq!(
664 PrincipalType::from_arn("arn:aws:iam::123456789012:something-weird"),
665 PrincipalType::Unknown
666 );
667
668 // And a Principal built from an Unknown ARN must not be treated
669 // as root for enforcement decisions.
670 let p = Principal {
671 arn: "garbage".to_string(),
672 user_id: "x".to_string(),
673 account_id: "123456789012".to_string(),
674 principal_type: PrincipalType::Unknown,
675 source_identity: None,
676 tags: None,
677 };
678 assert!(!p.is_root());
679 }
680
681 #[test]
682 fn principal_is_root_covers_root_type_and_arn_suffix() {
683 let p = Principal {
684 arn: "arn:aws:iam::123456789012:root".to_string(),
685 user_id: "AIDAROOT".to_string(),
686 account_id: "123456789012".to_string(),
687 principal_type: PrincipalType::Root,
688 source_identity: None,
689 tags: None,
690 };
691 assert!(p.is_root());
692
693 let user = Principal {
694 arn: "arn:aws:iam::123456789012:user/alice".to_string(),
695 user_id: "AIDAALICE".to_string(),
696 account_id: "123456789012".to_string(),
697 principal_type: PrincipalType::User,
698 source_identity: None,
699 tags: None,
700 };
701 assert!(!user.is_root());
702 }
703
704 #[test]
705 fn resolved_credential_accessors_forward_to_principal() {
706 let rc = ResolvedCredential {
707 secret_access_key: "s".into(),
708 session_token: None,
709 principal: Principal {
710 arn: "arn:aws:iam::123456789012:user/alice".into(),
711 user_id: "AIDAALICE".into(),
712 account_id: "123456789012".into(),
713 principal_type: PrincipalType::User,
714 source_identity: None,
715 tags: None,
716 },
717 session_policies: Vec::new(),
718 };
719 assert_eq!(rc.principal_arn(), "arn:aws:iam::123456789012:user/alice");
720 assert_eq!(rc.user_id(), "AIDAALICE");
721 assert_eq!(rc.account_id(), "123456789012");
722 }
723
724 #[test]
725 fn root_bypass_rejects_non_test_keys() {
726 assert!(!is_root_bypass(""));
727 assert!(!is_root_bypass(" "));
728 assert!(!is_root_bypass("AKIAIOSFODNN7EXAMPLE"));
729 assert!(!is_root_bypass("FKIA123456"));
730 assert!(!is_root_bypass("tes"));
731 assert!(!is_root_bypass("tst"));
732 }
733
734 // --- MultiResourcePolicyProvider composite -------------------------
735
736 /// Test provider that returns a canned document for one
737 /// (service, arn) pair and `None` for everything else.
738 struct FakeProvider {
739 service: &'static str,
740 arn: &'static str,
741 policy: &'static str,
742 }
743
744 impl ResourcePolicyProvider for FakeProvider {
745 fn resource_policy(&self, service: &str, resource_arn: &str) -> Option<String> {
746 if service.eq_ignore_ascii_case(self.service) && resource_arn == self.arn {
747 Some(self.policy.to_string())
748 } else {
749 None
750 }
751 }
752 }
753
754 fn fake(
755 service: &'static str,
756 arn: &'static str,
757 policy: &'static str,
758 ) -> Arc<dyn ResourcePolicyProvider> {
759 Arc::new(FakeProvider {
760 service,
761 arn,
762 policy,
763 })
764 }
765
766 #[test]
767 fn multi_provider_empty_always_returns_none() {
768 let m = MultiResourcePolicyProvider::new(vec![]);
769 assert!(m.is_empty());
770 assert_eq!(m.len(), 0);
771 assert_eq!(m.resource_policy("s3", "arn:aws:s3:::x"), None);
772 }
773
774 #[test]
775 fn multi_provider_delegates_to_single_child() {
776 let m = MultiResourcePolicyProvider::new(vec![fake("s3", "arn:aws:s3:::b", r#"{"v":1}"#)]);
777 assert_eq!(m.len(), 1);
778 assert_eq!(
779 m.resource_policy("s3", "arn:aws:s3:::b").as_deref(),
780 Some(r#"{"v":1}"#)
781 );
782 assert_eq!(m.resource_policy("s3", "arn:aws:s3:::missing"), None);
783 assert_eq!(m.resource_policy("sns", "arn:aws:s3:::b"), None);
784 }
785
786 #[test]
787 fn multi_provider_hits_first_matching_child() {
788 let m = MultiResourcePolicyProvider::new(vec![
789 fake("s3", "arn:aws:s3:::b", r#"{"v":"s3"}"#),
790 fake("sns", "arn:aws:sns:us-east-1:123:t", r#"{"v":"sns"}"#),
791 ]);
792 assert_eq!(
793 m.resource_policy("s3", "arn:aws:s3:::b").as_deref(),
794 Some(r#"{"v":"s3"}"#)
795 );
796 assert_eq!(
797 m.resource_policy("sns", "arn:aws:sns:us-east-1:123:t")
798 .as_deref(),
799 Some(r#"{"v":"sns"}"#)
800 );
801 }
802
803 #[test]
804 fn multi_provider_is_order_independent_when_services_differ() {
805 // Because each concrete provider gates on its own service
806 // prefix, swapping the order must never change the result.
807 let children: Vec<Arc<dyn ResourcePolicyProvider>> = vec![
808 fake("s3", "arn:aws:s3:::b", "s3-doc"),
809 fake("sns", "arn:aws:sns:us-east-1:123:t", "sns-doc"),
810 fake(
811 "lambda",
812 "arn:aws:lambda:us-east-1:123:function:f",
813 "lam-doc",
814 ),
815 ];
816 let forward = MultiResourcePolicyProvider::new(children.clone());
817 let reversed = MultiResourcePolicyProvider::new({
818 let mut v = children.clone();
819 v.reverse();
820 v
821 });
822 for (svc, arn) in [
823 ("s3", "arn:aws:s3:::b"),
824 ("sns", "arn:aws:sns:us-east-1:123:t"),
825 ("lambda", "arn:aws:lambda:us-east-1:123:function:f"),
826 ] {
827 assert_eq!(
828 forward.resource_policy(svc, arn),
829 reversed.resource_policy(svc, arn),
830 "service {svc}"
831 );
832 }
833 }
834
835 #[test]
836 fn multi_provider_returns_none_for_unhandled_service() {
837 let m = MultiResourcePolicyProvider::new(vec![fake("s3", "arn:aws:s3:::b", "doc")]);
838 assert_eq!(
839 m.resource_policy("kms", "arn:aws:kms:us-east-1:123:key/k"),
840 None
841 );
842 assert_eq!(m.resource_policy("iam", "arn:aws:iam::123:role/r"), None);
843 }
844
845 #[test]
846 fn multi_provider_shared_wraps_in_arc() {
847 let arc = MultiResourcePolicyProvider::shared(vec![fake("s3", "arn:aws:s3:::b", "doc")]);
848 assert_eq!(
849 arc.resource_policy("s3", "arn:aws:s3:::b").as_deref(),
850 Some("doc")
851 );
852 }
853
854 // --- ABAC tag condition key lookup ------------------------------------
855
856 fn abac_context() -> ConditionContext {
857 ConditionContext {
858 resource_tags: Some(
859 [("Environment", "prod"), ("CostCenter", "42")]
860 .iter()
861 .map(|(k, v)| (k.to_string(), v.to_string()))
862 .collect(),
863 ),
864 request_tags: Some(
865 [("Project", "web"), ("Team", "platform")]
866 .iter()
867 .map(|(k, v)| (k.to_string(), v.to_string()))
868 .collect(),
869 ),
870 principal_tags: Some(
871 [("Department", "eng"), ("Role", "developer")]
872 .iter()
873 .map(|(k, v)| (k.to_string(), v.to_string()))
874 .collect(),
875 ),
876 ..Default::default()
877 }
878 }
879
880 #[test]
881 fn lookup_resource_tag_case_sensitive_key() {
882 let ctx = abac_context();
883 assert_eq!(
884 ctx.lookup("aws:ResourceTag/Environment"),
885 Some(vec!["prod".to_string()])
886 );
887 // Different case -> different tag key -> None
888 assert_eq!(ctx.lookup("aws:ResourceTag/environment"), None);
889 }
890
891 #[test]
892 fn lookup_resource_tag_prefix_case_insensitive() {
893 let ctx = abac_context();
894 // Prefix is case-insensitive per AWS
895 assert_eq!(
896 ctx.lookup("AWS:resourcetag/Environment"),
897 Some(vec!["prod".to_string()])
898 );
899 assert_eq!(
900 ctx.lookup("Aws:RESOURCETAG/CostCenter"),
901 Some(vec!["42".to_string()])
902 );
903 }
904
905 #[test]
906 fn lookup_request_tag() {
907 let ctx = abac_context();
908 assert_eq!(
909 ctx.lookup("aws:RequestTag/Project"),
910 Some(vec!["web".to_string()])
911 );
912 assert_eq!(ctx.lookup("aws:RequestTag/project"), None);
913 }
914
915 #[test]
916 fn lookup_principal_tag() {
917 let ctx = abac_context();
918 assert_eq!(
919 ctx.lookup("aws:PrincipalTag/Department"),
920 Some(vec!["eng".to_string()])
921 );
922 assert_eq!(ctx.lookup("aws:PrincipalTag/department"), None);
923 }
924
925 #[test]
926 fn lookup_tag_keys_returns_all_request_tag_keys() {
927 let ctx = abac_context();
928 let mut keys = ctx.lookup("aws:TagKeys").unwrap();
929 keys.sort();
930 assert_eq!(keys, vec!["Project", "Team"]);
931 }
932
933 #[test]
934 fn lookup_tag_keys_case_insensitive() {
935 let ctx = abac_context();
936 assert!(ctx.lookup("AWS:TAGKEYS").is_some());
937 assert!(ctx.lookup("aws:tagkeys").is_some());
938 }
939
940 #[test]
941 fn lookup_tag_none_when_field_not_set() {
942 let ctx = ConditionContext::default();
943 assert_eq!(ctx.lookup("aws:ResourceTag/Foo"), None);
944 assert_eq!(ctx.lookup("aws:RequestTag/Foo"), None);
945 assert_eq!(ctx.lookup("aws:PrincipalTag/Foo"), None);
946 assert_eq!(ctx.lookup("aws:TagKeys"), None);
947 }
948
949 #[test]
950 fn lookup_tag_missing_key_returns_none() {
951 let ctx = abac_context();
952 assert_eq!(ctx.lookup("aws:ResourceTag/NonExistent"), None);
953 assert_eq!(ctx.lookup("aws:RequestTag/NonExistent"), None);
954 assert_eq!(ctx.lookup("aws:PrincipalTag/NonExistent"), None);
955 }
956}