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