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