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