Skip to main content

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    /// An AWS service principal (`cloudfront.amazonaws.com`) acting on its
45    /// own behalf. Its `arn` is the service name. Never produced from a
46    /// credential: only an [`InternalCaller::Service`] yields one, so no
47    /// client can present it.
48    Service,
49}
50
51impl PrincipalType {
52    pub fn as_str(self) -> &'static str {
53        match self {
54            PrincipalType::User => "user",
55            PrincipalType::AssumedRole => "assumed-role",
56            PrincipalType::FederatedUser => "federated-user",
57            PrincipalType::Root => "root",
58            PrincipalType::Unknown => "unknown",
59            PrincipalType::Service => "service",
60        }
61    }
62
63    /// Classify a principal from its ARN. Returns [`PrincipalType::Unknown`]
64    /// for ARNs that don't match any of the well-known principal shapes —
65    /// **never** [`PrincipalType::Root`] as a fallback, because root
66    /// bypasses IAM enforcement and silently treating malformed ARNs as
67    /// root would let unexpected inputs grant elevated permissions
68    /// (identified by cubic in PR #391 review).
69    pub fn from_arn(arn: &str) -> Self {
70        if arn.ends_with(":root") {
71            PrincipalType::Root
72        } else if arn.contains(":user/") {
73            PrincipalType::User
74        } else if arn.contains(":assumed-role/") {
75            PrincipalType::AssumedRole
76        } else if arn.contains(":federated-user/") {
77            PrincipalType::FederatedUser
78        } else {
79            PrincipalType::Unknown
80        }
81    }
82}
83
84/// Identity of the caller making a request, once its credentials have been
85/// resolved. Attached to [`crate::service::AwsRequest::principal`] so
86/// handlers can make identity-based decisions without re-parsing the
87/// Authorization header.
88///
89/// `account_id` is always sourced from the credential itself (via
90/// [`CredentialResolver`]), never from global config — #381 note.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct Principal {
93    pub arn: String,
94    pub user_id: String,
95    pub account_id: String,
96    pub principal_type: PrincipalType,
97    /// Optional source identity string, carried through from
98    /// `AssumeRole`'s `SourceIdentity` parameter. Reserved for later
99    /// batches that wire session policies and auditing.
100    pub source_identity: Option<String>,
101    /// Tags on the calling principal (IAM user or assumed role).
102    /// Populated at credential-resolution time from `IamState`.
103    /// Used for `aws:PrincipalTag/<key>` condition evaluation.
104    pub tags: Option<HashMap<String, String>>,
105}
106
107impl Principal {
108    /// Is this caller the account's root identity? Root bypasses IAM
109    /// evaluation, matching AWS.
110    pub fn is_root(&self) -> bool {
111        matches!(self.principal_type, PrincipalType::Root) || self.arn.ends_with(":root")
112    }
113}
114
115/// Credentials resolved from an access key ID.
116///
117/// Returned by [`CredentialResolver::resolve`]. Holds both the secret access
118/// key (needed for SigV4 verification) and the resolved [`Principal`]
119/// (needed for IAM enforcement and `GetCallerIdentity` consolidation).
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct ResolvedCredential {
122    pub secret_access_key: String,
123    pub session_token: Option<String>,
124    pub principal: Principal,
125    /// Session policies passed to the STS call that minted this credential.
126    /// Empty for IAM user access keys.
127    pub session_policies: Vec<String>,
128    /// True iff the underlying STS credential was minted with MFA. Drives
129    /// `aws:MultiFactorAuthPresent` for downstream IAM evaluation. Always
130    /// false for raw IAM user access keys.
131    pub mfa_present: bool,
132    /// Wall-clock time at which the underlying STS credential was issued.
133    /// Drives `aws:TokenIssueTime` and `aws:MultiFactorAuthAge` (the latter
134    /// computed at evaluation time as `now - token_issued_at` when
135    /// [`Self::mfa_present`] is true). `None` for raw IAM user access keys
136    /// — AWS does not expose `aws:TokenIssueTime` for long-lived credentials.
137    pub token_issued_at: Option<DateTime<Utc>>,
138    /// `aws:FederatedProvider` — SAML provider ARN for AssumeRoleWithSAML,
139    /// OIDC provider ARN for AssumeRoleWithWebIdentity. `None` for raw IAM
140    /// user keys, plain AssumeRole, GetSessionToken, GetFederationToken.
141    pub federated_provider: Option<String>,
142}
143
144impl ResolvedCredential {
145    /// Convenience accessors for the flat fields batch 3 callers use. Kept
146    /// as methods rather than re-adding the fields to avoid making the
147    /// shape inconsistent with [`Principal`] itself.
148    pub fn principal_arn(&self) -> &str {
149        &self.principal.arn
150    }
151
152    pub fn user_id(&self) -> &str {
153        &self.principal.user_id
154    }
155
156    pub fn account_id(&self) -> &str {
157        &self.principal.account_id
158    }
159}
160
161/// Abstraction over "given an access key ID, return the secret and resolved
162/// principal." Implemented by the IAM crate against `IamState`; the core
163/// crate depends only on the trait so there's no circular dependency.
164///
165/// Implementations must be cheap to clone-share via `Arc` and must be
166/// thread-safe — dispatch calls them from an axum handler under a tokio
167/// worker.
168pub trait CredentialResolver: Send + Sync {
169    /// Resolve `access_key_id` to its secret access key and principal.
170    /// Returns `None` when the AKID is unknown or its underlying credential
171    /// has expired.
172    fn resolve(&self, access_key_id: &str) -> Option<ResolvedCredential>;
173
174    /// True when `access_key_id` names a temporary credential that existed
175    /// but has expired. Lets dispatch answer `ExpiredToken` instead of
176    /// `InvalidClientTokenId` for it, as AWS does.
177    fn is_expired(&self, _access_key_id: &str) -> bool {
178        false
179    }
180}
181
182/// One IAM action that the dispatch layer should evaluate against the
183/// caller's effective policy set.
184///
185/// Produced by [`crate::service::AwsService::iam_action_for`] on services
186/// that opt into enforcement. The `resource` is a fully-qualified AWS ARN
187/// built from `request.principal.account_id` so multi-account isolation
188/// (#381) becomes a state-partitioning change rather than a cross-cutting
189/// rewrite.
190#[derive(Debug, Clone, PartialEq, Eq)]
191pub struct IamAction {
192    /// IAM service prefix, e.g. `"s3"`, `"sqs"`, `"iam"`.
193    pub service: &'static str,
194    /// AWS action name, e.g. `"GetObject"`, `"SendMessage"`.
195    pub action: &'static str,
196    /// Fully-qualified ARN of the target resource.
197    pub resource: String,
198}
199
200impl IamAction {
201    /// Compose the canonical `service:Action` string the evaluator
202    /// matches against.
203    pub fn action_string(&self) -> String {
204        format!("{}:{}", self.service, self.action)
205    }
206
207    /// `iam:PassRole` on `role_arn`: the authorization AWS requires of the
208    /// caller whenever a request hands a role to a service (Lambda's `Role`,
209    /// a Scheduler target's `RoleArn`, an S3 replication `Role`, ...). It is
210    /// evaluated in addition to the operation's own action, and the service
211    /// supplies the `iam:PassedToService` / `iam:AssociatedResourceArn`
212    /// condition keys via [`pass_role_condition_keys`].
213    pub fn pass_role(role_arn: impl Into<String>) -> Self {
214        Self {
215            service: "iam",
216            action: "PassRole",
217            resource: role_arn.into(),
218        }
219    }
220
221    /// True for the `iam:PassRole` action built by [`IamAction::pass_role`].
222    pub fn is_pass_role(&self) -> bool {
223        self.service == "iam" && self.action == "PassRole"
224    }
225}
226
227/// Condition keys AWS sets on an `iam:PassRole` authorization:
228/// `iam:PassedToService` (the service principal receiving the role) and,
229/// when the resource the role is attached to is known,
230/// `iam:AssociatedResourceArn`.
231pub fn pass_role_condition_keys(
232    passed_to_service: &str,
233    associated_resource_arn: Option<&str>,
234) -> BTreeMap<String, Vec<String>> {
235    let mut out = BTreeMap::new();
236    out.insert(
237        "iam:passedtoservice".to_string(),
238        vec![passed_to_service.to_string()],
239    );
240    if let Some(arn) = associated_resource_arn.filter(|a| !a.is_empty() && *a != "*") {
241        out.insert(
242            "iam:associatedresourcearn".to_string(),
243            vec![arn.to_string()],
244        );
245    }
246    out
247}
248
249/// Result of evaluating a request against an identity's effective policy
250/// set. Abstract over the concrete evaluator [`Decision`] in
251/// `fakecloud-iam::evaluator` so `fakecloud-core` can consume it without
252/// depending on `fakecloud-iam`.
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254pub enum IamDecision {
255    Allow,
256    ImplicitDeny,
257    ExplicitDeny,
258}
259
260impl IamDecision {
261    pub fn is_allow(self) -> bool {
262        matches!(self, IamDecision::Allow)
263    }
264}
265
266/// Request-time values consulted when a policy statement carries a
267/// `Condition` block. Populated at dispatch time from the resolved
268/// [`Principal`] and the incoming HTTP request, then handed to
269/// [`IamPolicyEvaluator::evaluate`].
270///
271/// Lives in `fakecloud-core` (not `fakecloud-iam`) so the trait can
272/// reference it without creating a circular crate dependency. All
273/// fields are optional — a missing field means the key wasn't knowable
274/// at dispatch time. As on AWS, a positive operator on an absent key
275/// evaluates to `false`, while a negated one (`StringNotEquals`,
276/// `NotIpAddress`, ...) and any `...IfExists` operator evaluate to `true`.
277///
278/// The `service_keys` map is reserved for service-specific condition
279/// keys (`s3:prefix`, `sqs:MessageAttribute`, …) which Phase 2 ships
280/// empty; service-specific support lands in a follow-up batch without
281/// a signature change.
282#[derive(Debug, Clone, Default)]
283pub struct ConditionContext {
284    /// `aws:username` — username segment of an IAM user ARN, or `None`
285    /// for assumed roles / federated users where AWS does not set the key.
286    pub aws_username: Option<String>,
287    /// `aws:userid` — the unique `AIDA...`/`AROA...` identifier.
288    pub aws_userid: Option<String>,
289    /// `aws:PrincipalArn` — full principal ARN.
290    pub aws_principal_arn: Option<String>,
291    /// `aws:PrincipalAccount` — 12-digit account ID sourced from the
292    /// credential, not global config (#381 multi-account alignment).
293    pub aws_principal_account: Option<String>,
294    /// `aws:PrincipalType` — `"User"`, `"AssumedRole"`, etc.
295    pub aws_principal_type: Option<String>,
296    /// `aws:SourceIp` — remote address of the HTTP connection.
297    pub aws_source_ip: Option<IpAddr>,
298    /// `aws:CurrentTime` — evaluation timestamp (UTC).
299    pub aws_current_time: Option<DateTime<Utc>>,
300    /// `aws:EpochTime` — same moment as `aws_current_time` in seconds
301    /// since the Unix epoch.
302    pub aws_epoch_time: Option<i64>,
303    /// `aws:SecureTransport` — `true` iff the request came in over TLS.
304    pub aws_secure_transport: Option<bool>,
305    /// `aws:RequestedRegion` — region extracted from SigV4 / config.
306    pub aws_requested_region: Option<String>,
307    /// `aws:MultiFactorAuthPresent` — true iff the caller supplied an
308    /// MFA credential when minting the session (AssumeRole with
309    /// SerialNumber + TokenCode, or a long-lived user credential
310    /// re-asserted via STS GetSessionToken with MFA).
311    pub aws_mfa_present: Option<bool>,
312    /// `aws:MultiFactorAuthAge` — seconds since MFA was asserted on
313    /// the session.
314    pub aws_mfa_age_seconds: Option<i64>,
315    /// `aws:CalledVia` — the chain of service principals that have
316    /// re-invoked downstream services on the caller's behalf
317    /// (e.g. `["cloudformation.amazonaws.com"]`). Multi-value key.
318    pub aws_called_via: Vec<String>,
319    /// `aws:SourceVpce` — VPC endpoint id when the request transited
320    /// a VPC interface endpoint.
321    pub aws_source_vpce: Option<String>,
322    /// `aws:SourceVpc` — VPC id when the request originated inside a
323    /// VPC.
324    pub aws_source_vpc: Option<String>,
325    /// `aws:VpcSourceIp` — private source IP inside the VPC (distinct
326    /// from `aws:SourceIp` which is the public NAT/Edge IP).
327    pub aws_vpc_source_ip: Option<IpAddr>,
328    /// `aws:FederatedProvider` — `cognito-identity.amazonaws.com`,
329    /// `accounts.google.com`, or the SAML-provider ARN, depending on
330    /// how the credential was minted.
331    pub aws_federated_provider: Option<String>,
332    /// `aws:TokenIssueTime` — when the temporary credential
333    /// underlying this session was issued (UTC).
334    pub aws_token_issue_time: Option<DateTime<Utc>>,
335    /// Service-specific keys (`s3:prefix`, `sqs:MessageAttribute`, …).
336    pub service_keys: BTreeMap<String, Vec<String>>,
337    /// `aws:ResourceTag/<key>` — tags on the target resource.
338    /// Populated by [`crate::service::AwsService::resource_tags_for`].
339    /// `None` means the service doesn't expose resource tags for ABAC.
340    pub resource_tags: Option<HashMap<String, String>>,
341    /// `aws:RequestTag/<key>` — tags sent in the request body/headers.
342    /// Populated by [`crate::service::AwsService::request_tags_from`].
343    /// Also drives `aws:TagKeys` (the list of request tag keys).
344    pub request_tags: Option<HashMap<String, String>>,
345    /// `aws:PrincipalTag/<key>` — tags on the calling IAM user or role.
346    /// Populated from [`Principal::tags`] at dispatch time.
347    pub principal_tags: Option<HashMap<String, String>>,
348}
349
350/// Whether two condition key names are the same key: the `service:name`
351/// part compares case-insensitively, and anything after the first `/` (a tag
352/// key in `aws:RequestTag/<key>`) compares exactly.
353fn same_condition_key(a: &str, b: &str) -> bool {
354    fn split(k: &str) -> (&str, &str) {
355        match k.find('/') {
356            Some(i) => (&k[..i], &k[i..]),
357            None => (k, ""),
358        }
359    }
360    let ((a_name, a_tail), (b_name, b_tail)) = (split(a), split(b));
361    a_name.eq_ignore_ascii_case(b_name) && a_tail == b_tail
362}
363
364impl ConditionContext {
365    /// Resolve a condition key (e.g. `"aws:username"`) to the list of
366    /// context values. Returns `None` if the key is not populated.
367    /// Key names are matched case-insensitively — AWS treats
368    /// `aws:username` and `AWS:UserName` as the same key.
369    pub fn lookup(&self, key: &str) -> Option<Vec<String>> {
370        let lower = key.to_ascii_lowercase();
371        let one = |s: &str| Some(vec![s.to_string()]);
372
373        // ABAC tag-based keys: case-insensitive prefix, case-sensitive
374        // tag key (the part after the slash). AWS treats "Environment"
375        // and "environment" as distinct tag keys.
376        //
377        // Prefix lengths: "aws:resourcetag/" = 16, "aws:requesttag/" = 15,
378        //                 "aws:principaltag/" = 17
379        let tagged = if lower.starts_with("aws:resourcetag/") {
380            let tag_key = &key[16..]; // preserve original case
381            Some(
382                self.resource_tags
383                    .as_ref()
384                    .and_then(|tags| tags.get(tag_key))
385                    .map(|v| vec![v.clone()]),
386            )
387        } else if lower.starts_with("aws:requesttag/") {
388            let tag_key = &key[15..];
389            Some(
390                self.request_tags
391                    .as_ref()
392                    .and_then(|tags| tags.get(tag_key))
393                    .map(|v| vec![v.clone()]),
394            )
395        } else if lower.starts_with("aws:principaltag/") {
396            let tag_key = &key[17..];
397            Some(
398                self.principal_tags
399                    .as_ref()
400                    .and_then(|tags| tags.get(tag_key))
401                    .map(|v| vec![v.clone()]),
402            )
403        } else if lower == "aws:tagkeys" {
404            Some(
405                self.request_tags
406                    .as_ref()
407                    .map(|tags| tags.keys().cloned().collect()),
408            )
409        } else {
410            None
411        };
412        if let Some(tagged) = tagged {
413            // Tag keys are case-sensitive after the prefix, so a plain entry
414            // must match the key exactly.
415            return tagged.or_else(|| {
416                self.service_keys
417                    .iter()
418                    .find(|(entry, _)| same_condition_key(entry, key))
419                    .map(|(_, vs)| vs.clone())
420            });
421        }
422
423        let typed = match lower.as_str() {
424            "aws:username" => self.aws_username.as_deref().and_then(one),
425            "aws:userid" => self.aws_userid.as_deref().and_then(one),
426            "aws:principalarn" => self.aws_principal_arn.as_deref().and_then(one),
427            "aws:principalaccount" => self.aws_principal_account.as_deref().and_then(one),
428            "aws:principaltype" => self.aws_principal_type.as_deref().and_then(one),
429            "aws:sourceip" => self.aws_source_ip.map(|ip| vec![ip.to_string()]),
430            "aws:currenttime" => self
431                .aws_current_time
432                .map(|t| vec![t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)]),
433            "aws:epochtime" => self.aws_epoch_time.map(|e| vec![e.to_string()]),
434            "aws:securetransport" => self.aws_secure_transport.map(|b| vec![b.to_string()]),
435            "aws:requestedregion" => self.aws_requested_region.as_deref().and_then(one),
436            "aws:multifactorauthpresent" => self.aws_mfa_present.map(|b| vec![b.to_string()]),
437            "aws:multifactorauthage" => self.aws_mfa_age_seconds.map(|s| vec![s.to_string()]),
438            "aws:calledvia" => {
439                if self.aws_called_via.is_empty() {
440                    None
441                } else {
442                    Some(self.aws_called_via.clone())
443                }
444            }
445            "aws:sourcevpce" => self.aws_source_vpce.as_deref().and_then(one),
446            "aws:sourcevpc" => self.aws_source_vpc.as_deref().and_then(one),
447            "aws:vpcsourceip" => self.aws_vpc_source_ip.map(|ip| vec![ip.to_string()]),
448            "aws:federatedprovider" => self.aws_federated_provider.as_deref().and_then(one),
449            "aws:tokenissuetime" => self
450                .aws_token_issue_time
451                .map(|t| vec![t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)]),
452            _ => None,
453        };
454        // A key with no typed value -- a service-specific key, or a global key
455        // supplied as a plain entry (a policy simulator's ContextEntries) --
456        // comes from `service_keys`. An entry with an empty value list means
457        // the key applies to the request but carries no values, which set
458        // operators distinguish from a key that was never populated.
459        typed.or_else(|| {
460            self.service_keys.get(&lower).cloned().or_else(|| {
461                self.service_keys
462                    .iter()
463                    .find(|(k, _)| k.eq_ignore_ascii_case(key))
464                    .map(|(_, vs)| vs.clone())
465            })
466        })
467    }
468}
469
470/// Abstraction over "given a principal, an action, and request-time
471/// condition keys, say Allow / Deny". Implemented by `fakecloud-iam`
472/// against `IamState` + the evaluator. Dispatch calls this for every
473/// request when `FAKECLOUD_IAM != off` and the target service opts in.
474pub trait IamPolicyEvaluator: Send + Sync {
475    /// Evaluate `action` against the identity policies attached to
476    /// `principal`, using `context` for `Condition` block resolution.
477    /// `session_policies` are the raw JSON session-policy documents
478    /// from the STS call that minted the caller's credential (empty
479    /// for IAM user access keys). `scps` are the inherited SCP
480    /// documents (root-OU first, account-direct last) that form the
481    /// top-of-chain allow-list ceiling; `None` means no org exists
482    /// for this principal or the principal is exempt (management,
483    /// service-linked role) and the layer is a pass-through.
484    fn evaluate(
485        &self,
486        principal: &Principal,
487        action: &IamAction,
488        context: &ConditionContext,
489        session_policies: &[String],
490        scps: Option<&[String]>,
491    ) -> IamDecision;
492
493    /// Evaluate with resource-policy + session-policy intersection.
494    /// `scps` follows the same semantics as in [`Self::evaluate`].
495    #[allow(clippy::too_many_arguments)]
496    fn evaluate_with_resource_policy(
497        &self,
498        principal: &Principal,
499        action: &IamAction,
500        context: &ConditionContext,
501        resource_policy_json: Option<&str>,
502        resource_account_id: &str,
503        session_policies: &[String],
504        scps: Option<&[String]>,
505    ) -> IamDecision;
506
507    /// Evaluate `action` for an **anonymous** (unsigned) caller against a
508    /// resource-based policy in isolation. Anonymous requests carry no
509    /// identity, so the resource policy is the sole authorization source:
510    /// the request is allowed only if the policy explicitly grants the
511    /// action to a wildcard principal (`Principal:"*"` / `{"AWS":"*"}`).
512    ///
513    /// `resource_policy_json` is the raw policy document (S3 bucket policy
514    /// today); `None` or a non-public policy yields [`IamDecision::ImplicitDeny`].
515    /// ACL-based public grants are evaluated separately by the dispatcher
516    /// via [`ResourcePolicyProvider::public_acl_allows`].
517    ///
518    /// The default implementation returns [`IamDecision::ImplicitDeny`] so
519    /// evaluators that don't support anonymous access never silently grant.
520    fn evaluate_anonymous(
521        &self,
522        _action: &IamAction,
523        _context: &ConditionContext,
524        _resource_policy_json: Option<&str>,
525    ) -> IamDecision {
526        IamDecision::ImplicitDeny
527    }
528
529    /// Evaluate `action` for `principal` against a resource-based policy in
530    /// isolation, with no identity-policy, boundary, session or SCP layer.
531    ///
532    /// This is how AWS authorizes a principal that lives outside every
533    /// customer account -- an AWS service principal or a service-owned
534    /// identity such as a CloudFront origin access identity (see
535    /// [`InternalCaller`]): only the resource policy can grant it access.
536    ///
537    /// The default implementation returns [`IamDecision::ImplicitDeny`] so
538    /// evaluators that don't support it never silently grant.
539    fn evaluate_resource_policy_only(
540        &self,
541        _principal: &Principal,
542        _action: &IamAction,
543        _context: &ConditionContext,
544        _resource_policy_json: Option<&str>,
545    ) -> IamDecision {
546        IamDecision::ImplicitDeny
547    }
548}
549
550/// The AWS-owned principal a request is made as when fakecloud itself issues
551/// it on a customer's behalf through its own front door -- CloudFront
552/// fetching from an S3 origin through an origin access control, for example.
553///
554/// It travels as an `http::Request` **extension** set by in-process code and
555/// is never parsed from the wire, so a client cannot claim it. Dispatch
556/// honors it only on a request that carries no credentials of its own, and
557/// authorizes it against the resource policy alone (see
558/// [`IamPolicyEvaluator::evaluate_resource_policy_only`]): neither kind of
559/// principal belongs to an account with identity policies.
560#[derive(Debug, Clone, PartialEq, Eq)]
561pub enum InternalCaller {
562    /// An AWS service principal (`cloudfront.amazonaws.com`) acting for one
563    /// of its resources. `source_arn` / `source_account` are that resource
564    /// and its owner -- the `aws:SourceArn` / `aws:SourceAccount` values a
565    /// confused-deputy condition compares against.
566    Service {
567        service: String,
568        source_arn: String,
569        source_account: String,
570    },
571    /// An IAM identity an AWS service owns outside every customer account,
572    /// such as a CloudFront origin access identity
573    /// (`arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity <id>`).
574    /// A resource policy names it by `arn` or, in S3, by its
575    /// `canonical_user_id`. `acting_account` is the customer account whose
576    /// resource made the request.
577    ServiceOwned {
578        arn: String,
579        canonical_user_id: Option<String>,
580        acting_account: String,
581    },
582}
583
584impl InternalCaller {
585    /// The principal the request is evaluated as. Neither kind carries an
586    /// account id: a service principal belongs to no account and a
587    /// service-owned identity to AWS's, so an account-root grant
588    /// (`arn:aws:iam::<acct>:root`) never matches either, as in AWS. A
589    /// service-owned identity's canonical user id is its `user_id`.
590    pub fn principal(&self) -> Principal {
591        match self {
592            InternalCaller::Service { service, .. } => Principal {
593                arn: service.clone(),
594                user_id: service.clone(),
595                account_id: String::new(),
596                principal_type: PrincipalType::Service,
597                source_identity: None,
598                tags: None,
599            },
600            InternalCaller::ServiceOwned {
601                arn,
602                canonical_user_id,
603                ..
604            } => Principal {
605                arn: arn.clone(),
606                user_id: canonical_user_id.clone().unwrap_or_default(),
607                account_id: String::new(),
608                principal_type: PrincipalType::from_arn(arn),
609                source_identity: None,
610                tags: None,
611            },
612        }
613    }
614
615    /// The customer account the request is made for: the owner of the
616    /// resource on whose behalf the service acts.
617    pub fn acting_account(&self) -> &str {
618        match self {
619            InternalCaller::Service { source_account, .. } => source_account,
620            InternalCaller::ServiceOwned { acting_account, .. } => acting_account,
621        }
622    }
623
624    /// The request-context keys this caller contributes: for a service
625    /// principal `aws:SourceArn`, `aws:SourceAccount`,
626    /// `aws:PrincipalServiceName` and `aws:PrincipalIsAWSService`; for a
627    /// service-owned identity nothing beyond its principal ARN (set on the
628    /// typed context by dispatch).
629    pub fn condition_keys(&self) -> BTreeMap<String, Vec<String>> {
630        let mut keys = BTreeMap::new();
631        match self {
632            InternalCaller::Service {
633                service,
634                source_arn,
635                source_account,
636            } => {
637                keys.insert("aws:SourceArn".to_string(), vec![source_arn.clone()]);
638                keys.insert(
639                    "aws:SourceAccount".to_string(),
640                    vec![source_account.clone()],
641                );
642                keys.insert(
643                    "aws:PrincipalServiceName".to_string(),
644                    vec![service.clone()],
645                );
646                keys.insert(
647                    "aws:PrincipalIsAWSService".to_string(),
648                    vec!["true".to_string()],
649                );
650            }
651            InternalCaller::ServiceOwned { .. } => {
652                keys.insert(
653                    "aws:PrincipalIsAWSService".to_string(),
654                    vec!["false".to_string()],
655                );
656            }
657        }
658        keys
659    }
660}
661
662/// Abstraction over "given a principal, return the inherited SCP
663/// documents that form the top-of-chain allow-list ceiling for the
664/// principal's account". Implemented by `fakecloud-organizations`.
665///
666/// Returning `None` means SCPs do not apply (no org exists for this
667/// fakecloud process, or the principal is the management account, or
668/// the principal is a service-linked role, or the account is not
669/// enrolled in the organization). Dispatch plumbs the returned slice
670/// straight into [`IamPolicyEvaluator`].
671///
672/// The ordered list puts root-OU-attached policies first, then each
673/// descendant OU down to the account's parent, and account-direct
674/// attachments last — the evaluator treats each entry as a separate
675/// gate that must allow (intersection), matching AWS SCP semantics.
676pub trait ScpResolver: Send + Sync {
677    fn scps_for(&self, principal: &Principal) -> Option<Vec<String>>;
678
679    /// `aws:PrincipalOrgID` and `aws:PrincipalOrgPaths` for a principal in
680    /// `account_id`: the organization ID and the account's path
681    /// (`o-xxx/r-xxx/ou-xxx/.../`), or `None` when the account belongs to no
682    /// organization (AWS then omits both keys).
683    fn principal_org(&self, _account_id: &str) -> Option<(String, String)> {
684        None
685    }
686}
687
688/// Abstraction over "does the organization topology permit `caller_account` to
689/// mint centralized-root (`sts:AssumeRoot`) credentials for `target_account`".
690/// Implemented by `fakecloud-organizations`, which owns the membership graph
691/// the IAM/STS crate has no visibility into.
692///
693/// Returns `true` only when an organization exists, `target_account` is a
694/// member of it, and `caller_account` is that org's management account (or a
695/// registered delegated administrator for centralized root access). Any other
696/// case — no org, target not enrolled, caller not privileged — returns
697/// `false`, so a bare `sts:AssumeRoot` grant can no longer escalate to root
698/// over an arbitrary account. Same-account AssumeRoot is handled by the caller
699/// and never consults this resolver.
700pub trait OrgMembershipResolver: Send + Sync {
701    fn can_assume_root_into(&self, caller_account: &str, target_account: &str) -> bool;
702}
703
704/// Abstraction over "given a service + a fully-qualified resource ARN,
705/// return the resource-based policy attached to that resource, if any."
706///
707/// Implemented by resource-owning services (S3 for bucket policies in
708/// the initial rollout; SNS topic policies, KMS key policies, and
709/// Lambda resource policies are separate future wirings) and plumbed
710/// through [`crate::dispatch::DispatchConfig`] alongside
711/// [`IamPolicyEvaluator`]. Dispatch fetches the policy for the target
712/// resource and hands it to the evaluator so cross-account Allow/Deny
713/// semantics can be computed.
714///
715/// Implementations must be cheap to clone-share via `Arc` and must be
716/// thread-safe — dispatch calls them on every enforced request.
717///
718/// Returning `None` means "no resource policy attached / resource
719/// doesn't exist / this provider doesn't handle that service." Returning
720/// `Some(json)` yields the raw JSON document as stored by the
721/// resource's CRUD handlers; parsing happens inside the evaluator so a
722/// malformed document logs a debug audit event and falls through to
723/// "no resource policy" rather than silently allowing.
724pub trait ResourcePolicyProvider: Send + Sync {
725    /// Fetch the resource-based policy document attached to
726    /// `resource_arn` on `service`. Both arguments are lowercase-ish
727    /// (`"s3"`, `"arn:aws:s3:::my-bucket"`); implementations should
728    /// match the service prefix they own and return `None` for
729    /// anything else so providers can be composed safely.
730    fn resource_policy(&self, service: &str, resource_arn: &str) -> Option<String>;
731
732    /// Resolve the 12-digit account that owns `resource_arn` on `service`,
733    /// when the ARN itself does not carry it. S3 ARNs have an empty account
734    /// field (`arn:aws:s3:::bucket`), so without this the dispatcher would
735    /// fall back to the caller's account and treat every S3 request as
736    /// same-account — letting account A reach account B's bucket without B's
737    /// bucket policy granting it (bug-audit 2026-05-28, 5.3). Providers whose
738    /// ARNs already carry the account (SQS/SNS/Lambda/…) return `None` and let
739    /// the dispatcher parse it from the ARN. Default `None`.
740    fn resource_owner_account(&self, _service: &str, _resource_arn: &str) -> Option<String> {
741        None
742    }
743
744    /// Whether a **public-read ACL** on `resource_arn` grants `action` to
745    /// an anonymous (unsigned) caller. Distinct from a bucket policy: S3
746    /// ACLs are a separate grant surface, so an object/bucket with an
747    /// `AllUsers` group grant is publicly readable even without a bucket
748    /// policy. `action` is the bare AWS action name (`"GetObject"`,
749    /// `"ListBucket"`, …).
750    ///
751    /// Implementations must honor `PublicAccessBlock` (a bucket with
752    /// `IgnorePublicAcls` set is not public via ACL). Default `false` so
753    /// providers that don't model ACLs never grant anonymous access.
754    fn public_acl_allows(&self, _service: &str, _resource_arn: &str, _action: &str) -> bool {
755        false
756    }
757}
758
759/// Failure mode for IAM PassRole trust-policy validation.
760///
761/// Exists in `fakecloud-core` so service crates (Lambda, ECS, …) can
762/// surface a wire-shaped error without taking a dependency on
763/// `fakecloud-iam`. The server crate wires the concrete validator that
764/// reads the IAM state.
765#[derive(Debug, Clone, PartialEq, Eq)]
766pub enum PassRoleError {
767    /// No role with this ARN exists in the IAM state.
768    RoleNotFound(String),
769    /// Role exists but its `AssumeRolePolicyDocument` does not allow the
770    /// service principal to call `sts:AssumeRole`. Real AWS returns
771    /// `InvalidParameterValueException` in this shape.
772    TrustPolicyDenies {
773        role_arn: String,
774        service_principal: String,
775    },
776    /// Role's `AssumeRolePolicyDocument` could not be parsed as JSON.
777    InvalidTrustPolicy(String),
778}
779
780impl std::fmt::Display for PassRoleError {
781    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
782        match self {
783            Self::RoleNotFound(arn) => write!(f, "role not found: {arn}"),
784            Self::TrustPolicyDenies {
785                role_arn,
786                service_principal,
787            } => write!(
788                f,
789                "Role's trust policy does not allow {service_principal} to assume the role: {role_arn}"
790            ),
791            Self::InvalidTrustPolicy(arn) => {
792                write!(f, "invalid trust policy on role {arn}")
793            }
794        }
795    }
796}
797
798impl std::error::Error for PassRoleError {}
799
800/// Validator that checks whether a role can be passed to a given
801/// service. Used by Lambda / ECS / EC2 etc. to reject `CreateFunction`,
802/// `RegisterTaskDefinition`, etc. when the supplied role's trust policy
803/// doesn't allow the service principal — matching the `iam:PassRole`
804/// trust-side behavior real AWS enforces unconditionally (separate from
805/// identity-policy `iam:PassRole`, which sits behind the IAM evaluator).
806pub trait RoleTrustValidator: Send + Sync {
807    fn validate(
808        &self,
809        account_id: &str,
810        role_arn: &str,
811        service_principal: &str,
812    ) -> Result<(), PassRoleError>;
813}
814
815/// Temporary credentials for an assumed-role session, as a compute service
816/// hands them to the code it runs (Lambda's execution-role environment, for
817/// example).
818#[derive(Clone, Debug)]
819pub struct SessionCredentials {
820    pub access_key_id: String,
821    pub secret_access_key: String,
822    pub session_token: String,
823    pub expiration: DateTime<Utc>,
824    /// Account the session is registered under, so it can be revoked there.
825    pub account_id: String,
826}
827
828/// Issues assumed-role session credentials on behalf of a compute service,
829/// registered so that requests signed with them resolve to
830/// `arn:<partition>:sts::<account>:assumed-role/<role>/<session>` (and verify
831/// under `--verify-sigv4`). Implemented over IAM state; services that run
832/// user code under a role take it as an optional hook so they stay decoupled
833/// from the IAM crate.
834pub trait SessionCredentialIssuer: Send + Sync {
835    /// Mint credentials for `role_arn` with the given session name, valid for
836    /// `duration`.
837    fn issue(
838        &self,
839        role_arn: &str,
840        session_name: &str,
841        duration: chrono::Duration,
842    ) -> SessionCredentials;
843
844    /// Unregister credentials once the code they were issued to has stopped.
845    /// Idempotent.
846    fn revoke(&self, credentials: &SessionCredentials);
847}
848
849/// Composite [`ResourcePolicyProvider`] that delegates to a list of
850/// sub-providers in order, returning the first `Some` hit.
851///
852/// Each concrete provider (`S3ResourcePolicyProvider`,
853/// `SnsResourcePolicyProvider`, `LambdaResourcePolicyProvider`, …)
854/// already gates on its own service prefix and returns `None` for
855/// anything it doesn't own, so composition is short-circuit and
856/// order-independent. Server bootstrap builds one of these holding
857/// every resource-owning service and passes it to
858/// [`crate::dispatch::DispatchConfig::resource_policy_provider`].
859///
860/// This is the extension point for future resource-owning services:
861/// adding KMS key policies (or anything else) is a one-line push at
862/// bootstrap, never a core-crate refactor.
863pub struct MultiResourcePolicyProvider {
864    providers: Vec<Arc<dyn ResourcePolicyProvider>>,
865}
866
867impl MultiResourcePolicyProvider {
868    /// Build a composite from a list of providers.
869    pub fn new(providers: Vec<Arc<dyn ResourcePolicyProvider>>) -> Self {
870        Self { providers }
871    }
872
873    /// Shared constructor returning the composite as an
874    /// `Arc<dyn ResourcePolicyProvider>`, matching the signature of
875    /// `DispatchConfig::resource_policy_provider`.
876    pub fn shared(
877        providers: Vec<Arc<dyn ResourcePolicyProvider>>,
878    ) -> Arc<dyn ResourcePolicyProvider> {
879        Arc::new(Self::new(providers))
880    }
881
882    /// Number of sub-providers held by this composite. Used by tests.
883    pub fn len(&self) -> usize {
884        self.providers.len()
885    }
886
887    /// True when no sub-providers are registered.
888    pub fn is_empty(&self) -> bool {
889        self.providers.is_empty()
890    }
891}
892
893impl ResourcePolicyProvider for MultiResourcePolicyProvider {
894    fn resource_policy(&self, service: &str, resource_arn: &str) -> Option<String> {
895        self.providers
896            .iter()
897            .find_map(|p| p.resource_policy(service, resource_arn))
898    }
899
900    fn resource_owner_account(&self, service: &str, resource_arn: &str) -> Option<String> {
901        self.providers
902            .iter()
903            .find_map(|p| p.resource_owner_account(service, resource_arn))
904    }
905
906    fn public_acl_allows(&self, service: &str, resource_arn: &str, action: &str) -> bool {
907        self.providers
908            .iter()
909            .any(|p| p.public_acl_allows(service, resource_arn, action))
910    }
911}
912
913/// How IAM identity policies are evaluated for incoming requests.
914///
915/// Default is [`IamMode::Off`] — existing behavior, policies are stored but
916/// never consulted. [`IamMode::Soft`] evaluates and logs denied decisions via
917/// the `fakecloud::iam::audit` tracing target without failing the request, and
918/// [`IamMode::Strict`] returns an `AccessDeniedException` in the protocol-
919/// correct shape.
920#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
921pub enum IamMode {
922    /// Do not evaluate IAM policies.
923    #[default]
924    Off,
925    /// Evaluate policies and log audit events for denied requests, but allow
926    /// the request to proceed.
927    Soft,
928    /// Evaluate policies and reject denied requests with `AccessDeniedException`.
929    Strict,
930}
931
932impl IamMode {
933    /// Returns true when policy evaluation should occur at all.
934    pub fn is_enabled(self) -> bool {
935        !matches!(self, IamMode::Off)
936    }
937
938    /// Returns true when denied decisions should fail the request.
939    pub fn is_strict(self) -> bool {
940        matches!(self, IamMode::Strict)
941    }
942
943    pub fn as_str(self) -> &'static str {
944        match self {
945            IamMode::Off => "off",
946            IamMode::Soft => "soft",
947            IamMode::Strict => "strict",
948        }
949    }
950}
951
952impl fmt::Display for IamMode {
953    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
954        f.write_str(self.as_str())
955    }
956}
957
958/// Parse error for [`IamMode`] from string.
959#[derive(Debug)]
960pub struct ParseIamModeError(String);
961
962impl fmt::Display for ParseIamModeError {
963    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
964        write!(
965            f,
966            "invalid IAM mode `{}`; expected one of: off, soft, strict",
967            self.0
968        )
969    }
970}
971
972impl std::error::Error for ParseIamModeError {}
973
974impl FromStr for IamMode {
975    type Err = ParseIamModeError;
976
977    fn from_str(s: &str) -> Result<Self, Self::Err> {
978        match s.trim().to_ascii_lowercase().as_str() {
979            "off" | "none" | "disabled" => Ok(IamMode::Off),
980            "soft" | "audit" | "warn" => Ok(IamMode::Soft),
981            "strict" | "enforce" | "deny" => Ok(IamMode::Strict),
982            other => Err(ParseIamModeError(other.to_string())),
983        }
984    }
985}
986
987/// Reserved root-identity convention.
988///
989/// Any access key whose ID begins with `test` (case-insensitive) is treated as
990/// the de-facto root bypass. This matches the long-standing community
991/// convention used by LocalStack and Floci: `test`/`test` credentials should
992/// always "just work" for local development.
993///
994/// When SigV4 verification or IAM enforcement is enabled, callers using a
995/// bypass AKID skip both checks. We emit a one-time startup WARN whenever
996/// enforcement is turned on so users understand that unsigned `test` clients
997/// will silently receive positive results.
998pub fn is_root_bypass(access_key_id: &str) -> bool {
999    access_key_id
1000        .trim()
1001        .get(..4)
1002        .is_some_and(|prefix| prefix.eq_ignore_ascii_case("test"))
1003}
1004
1005#[cfg(test)]
1006mod tests {
1007    use super::*;
1008
1009    #[test]
1010    fn service_internal_caller_is_an_accountless_service_principal() {
1011        let caller = InternalCaller::Service {
1012            service: "cloudfront.amazonaws.com".into(),
1013            source_arn: "arn:aws:cloudfront::123456789012:distribution/E1".into(),
1014            source_account: "123456789012".into(),
1015        };
1016        let p = caller.principal();
1017        assert_eq!(p.arn, "cloudfront.amazonaws.com");
1018        assert_eq!(p.principal_type, PrincipalType::Service);
1019        assert!(p.account_id.is_empty());
1020        assert!(!p.is_root());
1021        assert_eq!(caller.acting_account(), "123456789012");
1022        let ctx = ConditionContext {
1023            service_keys: caller.condition_keys(),
1024            ..Default::default()
1025        };
1026        assert_eq!(
1027            ctx.lookup("aws:SourceArn"),
1028            Some(vec![
1029                "arn:aws:cloudfront::123456789012:distribution/E1".to_string()
1030            ])
1031        );
1032        // Condition keys are case-insensitive, as CDK writes `AWS:SourceArn`.
1033        assert!(ctx.lookup("AWS:SourceArn").is_some());
1034        assert_eq!(
1035            ctx.lookup("aws:SourceAccount"),
1036            Some(vec!["123456789012".to_string()])
1037        );
1038        assert_eq!(
1039            ctx.lookup("aws:PrincipalServiceName"),
1040            Some(vec!["cloudfront.amazonaws.com".to_string()])
1041        );
1042        assert_eq!(
1043            ctx.lookup("aws:PrincipalIsAWSService"),
1044            Some(vec!["true".to_string()])
1045        );
1046    }
1047
1048    #[test]
1049    fn service_owned_internal_caller_carries_its_canonical_user_id() {
1050        let caller = InternalCaller::ServiceOwned {
1051            arn: "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity E2Q".into(),
1052            canonical_user_id: Some("0123456789abcdef".into()),
1053            acting_account: "123456789012".into(),
1054        };
1055        let p = caller.principal();
1056        assert_eq!(p.principal_type, PrincipalType::User);
1057        assert_eq!(p.user_id, "0123456789abcdef");
1058        assert!(p.account_id.is_empty());
1059        assert!(!p.is_root());
1060        assert_eq!(caller.acting_account(), "123456789012");
1061        assert!(!caller.condition_keys().contains_key("aws:SourceArn"));
1062    }
1063
1064    #[test]
1065    fn iam_mode_default_is_off() {
1066        assert_eq!(IamMode::default(), IamMode::Off);
1067        assert!(!IamMode::default().is_enabled());
1068    }
1069
1070    #[test]
1071    fn iam_mode_from_str_accepts_primary_values() {
1072        assert_eq!(IamMode::from_str("off").unwrap(), IamMode::Off);
1073        assert_eq!(IamMode::from_str("soft").unwrap(), IamMode::Soft);
1074        assert_eq!(IamMode::from_str("strict").unwrap(), IamMode::Strict);
1075    }
1076
1077    #[test]
1078    fn iam_mode_from_str_is_case_insensitive_and_trimmed() {
1079        assert_eq!(IamMode::from_str(" OFF ").unwrap(), IamMode::Off);
1080        assert_eq!(IamMode::from_str("Soft").unwrap(), IamMode::Soft);
1081        assert_eq!(IamMode::from_str("STRICT").unwrap(), IamMode::Strict);
1082    }
1083
1084    #[test]
1085    fn iam_mode_from_str_accepts_aliases() {
1086        assert_eq!(IamMode::from_str("disabled").unwrap(), IamMode::Off);
1087        assert_eq!(IamMode::from_str("audit").unwrap(), IamMode::Soft);
1088        assert_eq!(IamMode::from_str("enforce").unwrap(), IamMode::Strict);
1089    }
1090
1091    #[test]
1092    fn iam_mode_from_str_rejects_garbage() {
1093        assert!(IamMode::from_str("").is_err());
1094        assert!(IamMode::from_str("allow").is_err());
1095        assert!(IamMode::from_str("yes").is_err());
1096    }
1097
1098    #[test]
1099    fn iam_mode_display_roundtrips() {
1100        for mode in [IamMode::Off, IamMode::Soft, IamMode::Strict] {
1101            assert_eq!(IamMode::from_str(&mode.to_string()).unwrap(), mode);
1102        }
1103    }
1104
1105    #[test]
1106    fn iam_mode_flags() {
1107        assert!(!IamMode::Off.is_enabled());
1108        assert!(!IamMode::Off.is_strict());
1109        assert!(IamMode::Soft.is_enabled());
1110        assert!(!IamMode::Soft.is_strict());
1111        assert!(IamMode::Strict.is_enabled());
1112        assert!(IamMode::Strict.is_strict());
1113    }
1114
1115    #[test]
1116    fn root_bypass_matches_test_prefix() {
1117        assert!(is_root_bypass("test"));
1118        assert!(is_root_bypass("TEST"));
1119        assert!(is_root_bypass("Test"));
1120        assert!(is_root_bypass("testAccessKey"));
1121        assert!(is_root_bypass("TESTAKIAIOSFODNN7EXAMPLE"));
1122    }
1123
1124    #[test]
1125    fn root_bypass_does_not_panic_on_multibyte_input() {
1126        // Byte index 4 falls inside a multi-byte UTF-8 character; must not panic.
1127        assert!(!is_root_bypass("té"));
1128        assert!(!is_root_bypass("日本語キー"));
1129        assert!(!is_root_bypass("🔑🔑"));
1130    }
1131
1132    #[test]
1133    fn principal_type_from_arn_classifies_known_shapes() {
1134        assert_eq!(
1135            PrincipalType::from_arn("arn:aws:iam::123456789012:user/alice"),
1136            PrincipalType::User
1137        );
1138        assert_eq!(
1139            PrincipalType::from_arn("arn:aws:sts::123456789012:assumed-role/R/s"),
1140            PrincipalType::AssumedRole
1141        );
1142        assert_eq!(
1143            PrincipalType::from_arn("arn:aws:sts::123456789012:federated-user/bob"),
1144            PrincipalType::FederatedUser
1145        );
1146        assert_eq!(
1147            PrincipalType::from_arn("arn:aws:iam::123456789012:root"),
1148            PrincipalType::Root
1149        );
1150    }
1151
1152    #[test]
1153    fn principal_type_unparseable_is_unknown_not_root() {
1154        // Identified by cubic on PR #391: falling back to Root would let
1155        // malformed or unexpected ARNs bypass IAM enforcement, since
1156        // Principal::is_root short-circuits evaluation. The fallback must
1157        // be the non-bypassable Unknown variant.
1158        assert_eq!(
1159            PrincipalType::from_arn("not-an-arn"),
1160            PrincipalType::Unknown
1161        );
1162        assert_eq!(PrincipalType::from_arn(""), PrincipalType::Unknown);
1163        assert_eq!(
1164            PrincipalType::from_arn("arn:aws:iam::123456789012:something-weird"),
1165            PrincipalType::Unknown
1166        );
1167
1168        // And a Principal built from an Unknown ARN must not be treated
1169        // as root for enforcement decisions.
1170        let p = Principal {
1171            arn: "garbage".to_string(),
1172            user_id: "x".to_string(),
1173            account_id: "123456789012".to_string(),
1174            principal_type: PrincipalType::Unknown,
1175            source_identity: None,
1176            tags: None,
1177        };
1178        assert!(!p.is_root());
1179    }
1180
1181    #[test]
1182    fn principal_is_root_covers_root_type_and_arn_suffix() {
1183        let p = Principal {
1184            arn: "arn:aws:iam::123456789012:root".to_string(),
1185            user_id: "AIDAROOT".to_string(),
1186            account_id: "123456789012".to_string(),
1187            principal_type: PrincipalType::Root,
1188            source_identity: None,
1189            tags: None,
1190        };
1191        assert!(p.is_root());
1192
1193        let user = Principal {
1194            arn: "arn:aws:iam::123456789012:user/alice".to_string(),
1195            user_id: "AIDAALICE".to_string(),
1196            account_id: "123456789012".to_string(),
1197            principal_type: PrincipalType::User,
1198            source_identity: None,
1199            tags: None,
1200        };
1201        assert!(!user.is_root());
1202    }
1203
1204    #[test]
1205    fn resolved_credential_accessors_forward_to_principal() {
1206        let rc = ResolvedCredential {
1207            secret_access_key: "s".into(),
1208            session_token: None,
1209            principal: Principal {
1210                arn: "arn:aws:iam::123456789012:user/alice".into(),
1211                user_id: "AIDAALICE".into(),
1212                account_id: "123456789012".into(),
1213                principal_type: PrincipalType::User,
1214                source_identity: None,
1215                tags: None,
1216            },
1217            session_policies: Vec::new(),
1218            mfa_present: false,
1219            token_issued_at: None,
1220            federated_provider: None,
1221        };
1222        assert_eq!(rc.principal_arn(), "arn:aws:iam::123456789012:user/alice");
1223        assert_eq!(rc.user_id(), "AIDAALICE");
1224        assert_eq!(rc.account_id(), "123456789012");
1225    }
1226
1227    #[test]
1228    fn root_bypass_rejects_non_test_keys() {
1229        assert!(!is_root_bypass(""));
1230        assert!(!is_root_bypass("   "));
1231        assert!(!is_root_bypass("AKIAIOSFODNN7EXAMPLE"));
1232        assert!(!is_root_bypass("FKIA123456"));
1233        assert!(!is_root_bypass("tes"));
1234        assert!(!is_root_bypass("tst"));
1235    }
1236
1237    // --- MultiResourcePolicyProvider composite -------------------------
1238
1239    /// Test provider that returns a canned document for one
1240    /// (service, arn) pair and `None` for everything else.
1241    struct FakeProvider {
1242        service: &'static str,
1243        arn: &'static str,
1244        policy: &'static str,
1245    }
1246
1247    impl ResourcePolicyProvider for FakeProvider {
1248        fn resource_policy(&self, service: &str, resource_arn: &str) -> Option<String> {
1249            if service.eq_ignore_ascii_case(self.service) && resource_arn == self.arn {
1250                Some(self.policy.to_string())
1251            } else {
1252                None
1253            }
1254        }
1255    }
1256
1257    fn fake(
1258        service: &'static str,
1259        arn: &'static str,
1260        policy: &'static str,
1261    ) -> Arc<dyn ResourcePolicyProvider> {
1262        Arc::new(FakeProvider {
1263            service,
1264            arn,
1265            policy,
1266        })
1267    }
1268
1269    #[test]
1270    fn multi_provider_empty_always_returns_none() {
1271        let m = MultiResourcePolicyProvider::new(vec![]);
1272        assert!(m.is_empty());
1273        assert_eq!(m.len(), 0);
1274        assert_eq!(m.resource_policy("s3", "arn:aws:s3:::x"), None);
1275    }
1276
1277    #[test]
1278    fn multi_provider_delegates_to_single_child() {
1279        let m = MultiResourcePolicyProvider::new(vec![fake("s3", "arn:aws:s3:::b", r#"{"v":1}"#)]);
1280        assert_eq!(m.len(), 1);
1281        assert_eq!(
1282            m.resource_policy("s3", "arn:aws:s3:::b").as_deref(),
1283            Some(r#"{"v":1}"#)
1284        );
1285        assert_eq!(m.resource_policy("s3", "arn:aws:s3:::missing"), None);
1286        assert_eq!(m.resource_policy("sns", "arn:aws:s3:::b"), None);
1287    }
1288
1289    #[test]
1290    fn multi_provider_hits_first_matching_child() {
1291        let m = MultiResourcePolicyProvider::new(vec![
1292            fake("s3", "arn:aws:s3:::b", r#"{"v":"s3"}"#),
1293            fake("sns", "arn:aws:sns:us-east-1:123:t", r#"{"v":"sns"}"#),
1294        ]);
1295        assert_eq!(
1296            m.resource_policy("s3", "arn:aws:s3:::b").as_deref(),
1297            Some(r#"{"v":"s3"}"#)
1298        );
1299        assert_eq!(
1300            m.resource_policy("sns", "arn:aws:sns:us-east-1:123:t")
1301                .as_deref(),
1302            Some(r#"{"v":"sns"}"#)
1303        );
1304    }
1305
1306    #[test]
1307    fn multi_provider_is_order_independent_when_services_differ() {
1308        // Because each concrete provider gates on its own service
1309        // prefix, swapping the order must never change the result.
1310        let children: Vec<Arc<dyn ResourcePolicyProvider>> = vec![
1311            fake("s3", "arn:aws:s3:::b", "s3-doc"),
1312            fake("sns", "arn:aws:sns:us-east-1:123:t", "sns-doc"),
1313            fake(
1314                "lambda",
1315                "arn:aws:lambda:us-east-1:123:function:f",
1316                "lam-doc",
1317            ),
1318        ];
1319        let forward = MultiResourcePolicyProvider::new(children.clone());
1320        let reversed = MultiResourcePolicyProvider::new({
1321            let mut v = children.clone();
1322            v.reverse();
1323            v
1324        });
1325        for (svc, arn) in [
1326            ("s3", "arn:aws:s3:::b"),
1327            ("sns", "arn:aws:sns:us-east-1:123:t"),
1328            ("lambda", "arn:aws:lambda:us-east-1:123:function:f"),
1329        ] {
1330            assert_eq!(
1331                forward.resource_policy(svc, arn),
1332                reversed.resource_policy(svc, arn),
1333                "service {svc}"
1334            );
1335        }
1336    }
1337
1338    #[test]
1339    fn multi_provider_returns_none_for_unhandled_service() {
1340        let m = MultiResourcePolicyProvider::new(vec![fake("s3", "arn:aws:s3:::b", "doc")]);
1341        assert_eq!(
1342            m.resource_policy("kms", "arn:aws:kms:us-east-1:123:key/k"),
1343            None
1344        );
1345        assert_eq!(m.resource_policy("iam", "arn:aws:iam::123:role/r"), None);
1346    }
1347
1348    #[test]
1349    fn multi_provider_shared_wraps_in_arc() {
1350        let arc = MultiResourcePolicyProvider::shared(vec![fake("s3", "arn:aws:s3:::b", "doc")]);
1351        assert_eq!(
1352            arc.resource_policy("s3", "arn:aws:s3:::b").as_deref(),
1353            Some("doc")
1354        );
1355    }
1356
1357    // --- ABAC tag condition key lookup ------------------------------------
1358
1359    #[test]
1360    fn lookup_mfa_present_emits_bool_string() {
1361        let ctx = ConditionContext {
1362            aws_mfa_present: Some(true),
1363            ..Default::default()
1364        };
1365        assert_eq!(
1366            ctx.lookup("aws:MultiFactorAuthPresent"),
1367            Some(vec!["true".to_string()])
1368        );
1369        let ctx = ConditionContext {
1370            aws_mfa_present: Some(false),
1371            ..Default::default()
1372        };
1373        assert_eq!(
1374            ctx.lookup("aws:multifactorauthpresent"),
1375            Some(vec!["false".to_string()])
1376        );
1377    }
1378
1379    #[test]
1380    fn lookup_mfa_age_emits_seconds() {
1381        let ctx = ConditionContext {
1382            aws_mfa_age_seconds: Some(900),
1383            ..Default::default()
1384        };
1385        assert_eq!(
1386            ctx.lookup("aws:MultiFactorAuthAge"),
1387            Some(vec!["900".to_string()])
1388        );
1389    }
1390
1391    #[test]
1392    fn lookup_called_via_returns_full_chain() {
1393        let ctx = ConditionContext {
1394            aws_called_via: vec![
1395                "cloudformation.amazonaws.com".to_string(),
1396                "lambda.amazonaws.com".to_string(),
1397            ],
1398            ..Default::default()
1399        };
1400        assert_eq!(
1401            ctx.lookup("aws:CalledVia"),
1402            Some(vec![
1403                "cloudformation.amazonaws.com".to_string(),
1404                "lambda.amazonaws.com".to_string(),
1405            ])
1406        );
1407    }
1408
1409    #[test]
1410    fn lookup_called_via_empty_returns_none() {
1411        let ctx = ConditionContext::default();
1412        assert_eq!(ctx.lookup("aws:CalledVia"), None);
1413    }
1414
1415    #[test]
1416    fn lookup_source_vpc_keys() {
1417        let ctx = ConditionContext {
1418            aws_source_vpc: Some("vpc-123".to_string()),
1419            aws_source_vpce: Some("vpce-456".to_string()),
1420            aws_vpc_source_ip: Some("10.0.1.5".parse::<IpAddr>().unwrap()),
1421            ..Default::default()
1422        };
1423        assert_eq!(
1424            ctx.lookup("aws:SourceVpc"),
1425            Some(vec!["vpc-123".to_string()])
1426        );
1427        assert_eq!(
1428            ctx.lookup("aws:SourceVpce"),
1429            Some(vec!["vpce-456".to_string()])
1430        );
1431        assert_eq!(
1432            ctx.lookup("aws:VpcSourceIp"),
1433            Some(vec!["10.0.1.5".to_string()])
1434        );
1435    }
1436
1437    #[test]
1438    fn lookup_federated_provider_and_token_issue_time() {
1439        use chrono::TimeZone;
1440        let ctx = ConditionContext {
1441            aws_federated_provider: Some("cognito-identity.amazonaws.com".to_string()),
1442            aws_token_issue_time: Some(
1443                chrono::Utc.with_ymd_and_hms(2026, 4, 30, 12, 0, 0).unwrap(),
1444            ),
1445            ..Default::default()
1446        };
1447        assert_eq!(
1448            ctx.lookup("aws:FederatedProvider"),
1449            Some(vec!["cognito-identity.amazonaws.com".to_string()])
1450        );
1451        assert_eq!(
1452            ctx.lookup("aws:TokenIssueTime"),
1453            Some(vec!["2026-04-30T12:00:00Z".to_string()])
1454        );
1455    }
1456
1457    fn abac_context() -> ConditionContext {
1458        ConditionContext {
1459            resource_tags: Some(
1460                [("Environment", "prod"), ("CostCenter", "42")]
1461                    .iter()
1462                    .map(|(k, v)| (k.to_string(), v.to_string()))
1463                    .collect(),
1464            ),
1465            request_tags: Some(
1466                [("Project", "web"), ("Team", "platform")]
1467                    .iter()
1468                    .map(|(k, v)| (k.to_string(), v.to_string()))
1469                    .collect(),
1470            ),
1471            principal_tags: Some(
1472                [("Department", "eng"), ("Role", "developer")]
1473                    .iter()
1474                    .map(|(k, v)| (k.to_string(), v.to_string()))
1475                    .collect(),
1476            ),
1477            ..Default::default()
1478        }
1479    }
1480
1481    #[test]
1482    fn lookup_resource_tag_case_sensitive_key() {
1483        let ctx = abac_context();
1484        assert_eq!(
1485            ctx.lookup("aws:ResourceTag/Environment"),
1486            Some(vec!["prod".to_string()])
1487        );
1488        // Different case -> different tag key -> None
1489        assert_eq!(ctx.lookup("aws:ResourceTag/environment"), None);
1490    }
1491
1492    #[test]
1493    fn lookup_resource_tag_prefix_case_insensitive() {
1494        let ctx = abac_context();
1495        // Prefix is case-insensitive per AWS
1496        assert_eq!(
1497            ctx.lookup("AWS:resourcetag/Environment"),
1498            Some(vec!["prod".to_string()])
1499        );
1500        assert_eq!(
1501            ctx.lookup("Aws:RESOURCETAG/CostCenter"),
1502            Some(vec!["42".to_string()])
1503        );
1504    }
1505
1506    #[test]
1507    fn lookup_request_tag() {
1508        let ctx = abac_context();
1509        assert_eq!(
1510            ctx.lookup("aws:RequestTag/Project"),
1511            Some(vec!["web".to_string()])
1512        );
1513        assert_eq!(ctx.lookup("aws:RequestTag/project"), None);
1514    }
1515
1516    #[test]
1517    fn lookup_principal_tag() {
1518        let ctx = abac_context();
1519        assert_eq!(
1520            ctx.lookup("aws:PrincipalTag/Department"),
1521            Some(vec!["eng".to_string()])
1522        );
1523        assert_eq!(ctx.lookup("aws:PrincipalTag/department"), None);
1524    }
1525
1526    #[test]
1527    fn lookup_tag_keys_returns_all_request_tag_keys() {
1528        let ctx = abac_context();
1529        let mut keys = ctx.lookup("aws:TagKeys").unwrap();
1530        keys.sort();
1531        assert_eq!(keys, vec!["Project", "Team"]);
1532    }
1533
1534    #[test]
1535    fn lookup_tag_keys_case_insensitive() {
1536        let ctx = abac_context();
1537        assert!(ctx.lookup("AWS:TAGKEYS").is_some());
1538        assert!(ctx.lookup("aws:tagkeys").is_some());
1539    }
1540
1541    #[test]
1542    fn lookup_tag_none_when_field_not_set() {
1543        let ctx = ConditionContext::default();
1544        assert_eq!(ctx.lookup("aws:ResourceTag/Foo"), None);
1545        assert_eq!(ctx.lookup("aws:RequestTag/Foo"), None);
1546        assert_eq!(ctx.lookup("aws:PrincipalTag/Foo"), None);
1547        assert_eq!(ctx.lookup("aws:TagKeys"), None);
1548    }
1549
1550    #[test]
1551    fn lookup_tag_missing_key_returns_none() {
1552        let ctx = abac_context();
1553        assert_eq!(ctx.lookup("aws:ResourceTag/NonExistent"), None);
1554        assert_eq!(ctx.lookup("aws:RequestTag/NonExistent"), None);
1555        assert_eq!(ctx.lookup("aws:PrincipalTag/NonExistent"), None);
1556    }
1557}