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::fmt;
14use std::str::FromStr;
15
16/// Kind of principal a set of credentials resolves to.
17///
18/// Used to drive IAM policy evaluation (Phase 2) and the `GetCallerIdentity`
19/// response shape. Inferred from the credential's storage path in
20/// [`IamState`] and — for STS temporary credentials — from the ARN form
21/// `arn:aws:sts::<account>:assumed-role/...` or `federated-user/...`.
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
23pub enum PrincipalType {
24    /// An IAM user access key (AKID created via `CreateAccessKey`).
25    User,
26    /// An assumed role session issued by `AssumeRole` /
27    /// `AssumeRoleWithWebIdentity` / `AssumeRoleWithSAML`.
28    AssumedRole,
29    /// Credentials issued by `GetFederationToken` — i.e. a federated user.
30    FederatedUser,
31    /// The account root identity. Reserved for explicit `...:root` ARNs
32    /// only; do not return this from a generic fallback because root
33    /// principals bypass IAM enforcement (see `Principal::is_root`).
34    Root,
35    /// The ARN didn't match any known shape. Treated as a non-root,
36    /// non-bypassable principal so a malformed or unexpected ARN can never
37    /// silently grant elevated permissions during IAM evaluation.
38    Unknown,
39}
40
41impl PrincipalType {
42    pub fn as_str(self) -> &'static str {
43        match self {
44            PrincipalType::User => "user",
45            PrincipalType::AssumedRole => "assumed-role",
46            PrincipalType::FederatedUser => "federated-user",
47            PrincipalType::Root => "root",
48            PrincipalType::Unknown => "unknown",
49        }
50    }
51
52    /// Classify a principal from its ARN. Returns [`PrincipalType::Unknown`]
53    /// for ARNs that don't match any of the well-known principal shapes —
54    /// **never** [`PrincipalType::Root`] as a fallback, because root
55    /// bypasses IAM enforcement and silently treating malformed ARNs as
56    /// root would let unexpected inputs grant elevated permissions
57    /// (identified by cubic in PR #391 review).
58    pub fn from_arn(arn: &str) -> Self {
59        if arn.ends_with(":root") {
60            PrincipalType::Root
61        } else if arn.contains(":user/") {
62            PrincipalType::User
63        } else if arn.contains(":assumed-role/") {
64            PrincipalType::AssumedRole
65        } else if arn.contains(":federated-user/") {
66            PrincipalType::FederatedUser
67        } else {
68            PrincipalType::Unknown
69        }
70    }
71}
72
73/// Identity of the caller making a request, once its credentials have been
74/// resolved. Attached to [`crate::service::AwsRequest::principal`] so
75/// handlers can make identity-based decisions without re-parsing the
76/// Authorization header.
77///
78/// `account_id` is always sourced from the credential itself (via
79/// [`CredentialResolver`]), never from global config — #381 note.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct Principal {
82    pub arn: String,
83    pub user_id: String,
84    pub account_id: String,
85    pub principal_type: PrincipalType,
86    /// Optional source identity string, carried through from
87    /// `AssumeRole`'s `SourceIdentity` parameter. Reserved for later
88    /// batches that wire session policies and auditing.
89    pub source_identity: Option<String>,
90}
91
92impl Principal {
93    /// Is this caller the account's root identity? Root bypasses IAM
94    /// evaluation, matching AWS.
95    pub fn is_root(&self) -> bool {
96        matches!(self.principal_type, PrincipalType::Root) || self.arn.ends_with(":root")
97    }
98}
99
100/// Credentials resolved from an access key ID.
101///
102/// Returned by [`CredentialResolver::resolve`]. Holds both the secret access
103/// key (needed for SigV4 verification) and the resolved [`Principal`]
104/// (needed for IAM enforcement and `GetCallerIdentity` consolidation).
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct ResolvedCredential {
107    pub secret_access_key: String,
108    pub session_token: Option<String>,
109    pub principal: Principal,
110}
111
112impl ResolvedCredential {
113    /// Convenience accessors for the flat fields batch 3 callers use. Kept
114    /// as methods rather than re-adding the fields to avoid making the
115    /// shape inconsistent with [`Principal`] itself.
116    pub fn principal_arn(&self) -> &str {
117        &self.principal.arn
118    }
119
120    pub fn user_id(&self) -> &str {
121        &self.principal.user_id
122    }
123
124    pub fn account_id(&self) -> &str {
125        &self.principal.account_id
126    }
127}
128
129/// Abstraction over "given an access key ID, return the secret and resolved
130/// principal." Implemented by the IAM crate against `IamState`; the core
131/// crate depends only on the trait so there's no circular dependency.
132///
133/// Implementations must be cheap to clone-share via `Arc` and must be
134/// thread-safe — dispatch calls them from an axum handler under a tokio
135/// worker.
136pub trait CredentialResolver: Send + Sync {
137    /// Resolve `access_key_id` to its secret access key and principal.
138    /// Returns `None` when the AKID is unknown or its underlying credential
139    /// has expired.
140    fn resolve(&self, access_key_id: &str) -> Option<ResolvedCredential>;
141}
142
143/// One IAM action that the dispatch layer should evaluate against the
144/// caller's effective policy set.
145///
146/// Produced by [`crate::service::AwsService::iam_action_for`] on services
147/// that opt into enforcement. The `resource` is a fully-qualified AWS ARN
148/// built from `request.principal.account_id` so multi-account isolation
149/// (#381) becomes a state-partitioning change rather than a cross-cutting
150/// rewrite.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct IamAction {
153    /// IAM service prefix, e.g. `"s3"`, `"sqs"`, `"iam"`.
154    pub service: &'static str,
155    /// AWS action name, e.g. `"GetObject"`, `"SendMessage"`.
156    pub action: &'static str,
157    /// Fully-qualified ARN of the target resource.
158    pub resource: String,
159}
160
161impl IamAction {
162    /// Compose the canonical `service:Action` string the evaluator
163    /// matches against.
164    pub fn action_string(&self) -> String {
165        format!("{}:{}", self.service, self.action)
166    }
167}
168
169/// Result of evaluating a request against an identity's effective policy
170/// set. Abstract over the concrete evaluator [`Decision`] in
171/// `fakecloud-iam::evaluator` so `fakecloud-core` can consume it without
172/// depending on `fakecloud-iam`.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum IamDecision {
175    Allow,
176    ImplicitDeny,
177    ExplicitDeny,
178}
179
180impl IamDecision {
181    pub fn is_allow(self) -> bool {
182        matches!(self, IamDecision::Allow)
183    }
184}
185
186/// Abstraction over "given a principal and an action, say Allow / Deny".
187/// Implemented by `fakecloud-iam` against `IamState` + the Phase 1
188/// evaluator. Dispatch calls this for every request when
189/// `FAKECLOUD_IAM != off` and the target service opts into enforcement.
190pub trait IamPolicyEvaluator: Send + Sync {
191    /// Evaluate `action` against the identity policies attached to
192    /// `principal`.
193    fn evaluate(&self, principal: &Principal, action: &IamAction) -> IamDecision;
194}
195
196/// How IAM identity policies are evaluated for incoming requests.
197///
198/// Default is [`IamMode::Off`] — existing behavior, policies are stored but
199/// never consulted. [`IamMode::Soft`] evaluates and logs denied decisions via
200/// the `fakecloud::iam::audit` tracing target without failing the request, and
201/// [`IamMode::Strict`] returns an `AccessDeniedException` in the protocol-
202/// correct shape.
203#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
204pub enum IamMode {
205    /// Do not evaluate IAM policies.
206    #[default]
207    Off,
208    /// Evaluate policies and log audit events for denied requests, but allow
209    /// the request to proceed.
210    Soft,
211    /// Evaluate policies and reject denied requests with `AccessDeniedException`.
212    Strict,
213}
214
215impl IamMode {
216    /// Returns true when policy evaluation should occur at all.
217    pub fn is_enabled(self) -> bool {
218        !matches!(self, IamMode::Off)
219    }
220
221    /// Returns true when denied decisions should fail the request.
222    pub fn is_strict(self) -> bool {
223        matches!(self, IamMode::Strict)
224    }
225
226    pub fn as_str(self) -> &'static str {
227        match self {
228            IamMode::Off => "off",
229            IamMode::Soft => "soft",
230            IamMode::Strict => "strict",
231        }
232    }
233}
234
235impl fmt::Display for IamMode {
236    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
237        f.write_str(self.as_str())
238    }
239}
240
241/// Parse error for [`IamMode`] from string.
242#[derive(Debug)]
243pub struct ParseIamModeError(String);
244
245impl fmt::Display for ParseIamModeError {
246    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247        write!(
248            f,
249            "invalid IAM mode `{}`; expected one of: off, soft, strict",
250            self.0
251        )
252    }
253}
254
255impl std::error::Error for ParseIamModeError {}
256
257impl FromStr for IamMode {
258    type Err = ParseIamModeError;
259
260    fn from_str(s: &str) -> Result<Self, Self::Err> {
261        match s.trim().to_ascii_lowercase().as_str() {
262            "off" | "none" | "disabled" => Ok(IamMode::Off),
263            "soft" | "audit" | "warn" => Ok(IamMode::Soft),
264            "strict" | "enforce" | "deny" => Ok(IamMode::Strict),
265            other => Err(ParseIamModeError(other.to_string())),
266        }
267    }
268}
269
270/// Reserved root-identity convention.
271///
272/// Any access key whose ID begins with `test` (case-insensitive) is treated as
273/// the de-facto root bypass. This matches the long-standing community
274/// convention used by LocalStack and Floci: `test`/`test` credentials should
275/// always "just work" for local development.
276///
277/// When SigV4 verification or IAM enforcement is enabled, callers using a
278/// bypass AKID skip both checks. We emit a one-time startup WARN whenever
279/// enforcement is turned on so users understand that unsigned `test` clients
280/// will silently receive positive results.
281pub fn is_root_bypass(access_key_id: &str) -> bool {
282    access_key_id
283        .trim()
284        .get(..4)
285        .is_some_and(|prefix| prefix.eq_ignore_ascii_case("test"))
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn iam_mode_default_is_off() {
294        assert_eq!(IamMode::default(), IamMode::Off);
295        assert!(!IamMode::default().is_enabled());
296    }
297
298    #[test]
299    fn iam_mode_from_str_accepts_primary_values() {
300        assert_eq!(IamMode::from_str("off").unwrap(), IamMode::Off);
301        assert_eq!(IamMode::from_str("soft").unwrap(), IamMode::Soft);
302        assert_eq!(IamMode::from_str("strict").unwrap(), IamMode::Strict);
303    }
304
305    #[test]
306    fn iam_mode_from_str_is_case_insensitive_and_trimmed() {
307        assert_eq!(IamMode::from_str(" OFF ").unwrap(), IamMode::Off);
308        assert_eq!(IamMode::from_str("Soft").unwrap(), IamMode::Soft);
309        assert_eq!(IamMode::from_str("STRICT").unwrap(), IamMode::Strict);
310    }
311
312    #[test]
313    fn iam_mode_from_str_accepts_aliases() {
314        assert_eq!(IamMode::from_str("disabled").unwrap(), IamMode::Off);
315        assert_eq!(IamMode::from_str("audit").unwrap(), IamMode::Soft);
316        assert_eq!(IamMode::from_str("enforce").unwrap(), IamMode::Strict);
317    }
318
319    #[test]
320    fn iam_mode_from_str_rejects_garbage() {
321        assert!(IamMode::from_str("").is_err());
322        assert!(IamMode::from_str("allow").is_err());
323        assert!(IamMode::from_str("yes").is_err());
324    }
325
326    #[test]
327    fn iam_mode_display_roundtrips() {
328        for mode in [IamMode::Off, IamMode::Soft, IamMode::Strict] {
329            assert_eq!(IamMode::from_str(&mode.to_string()).unwrap(), mode);
330        }
331    }
332
333    #[test]
334    fn iam_mode_flags() {
335        assert!(!IamMode::Off.is_enabled());
336        assert!(!IamMode::Off.is_strict());
337        assert!(IamMode::Soft.is_enabled());
338        assert!(!IamMode::Soft.is_strict());
339        assert!(IamMode::Strict.is_enabled());
340        assert!(IamMode::Strict.is_strict());
341    }
342
343    #[test]
344    fn root_bypass_matches_test_prefix() {
345        assert!(is_root_bypass("test"));
346        assert!(is_root_bypass("TEST"));
347        assert!(is_root_bypass("Test"));
348        assert!(is_root_bypass("testAccessKey"));
349        assert!(is_root_bypass("TESTAKIAIOSFODNN7EXAMPLE"));
350    }
351
352    #[test]
353    fn root_bypass_does_not_panic_on_multibyte_input() {
354        // Byte index 4 falls inside a multi-byte UTF-8 character; must not panic.
355        assert!(!is_root_bypass("té"));
356        assert!(!is_root_bypass("日本語キー"));
357        assert!(!is_root_bypass("🔑🔑"));
358    }
359
360    #[test]
361    fn principal_type_from_arn_classifies_known_shapes() {
362        assert_eq!(
363            PrincipalType::from_arn("arn:aws:iam::123456789012:user/alice"),
364            PrincipalType::User
365        );
366        assert_eq!(
367            PrincipalType::from_arn("arn:aws:sts::123456789012:assumed-role/R/s"),
368            PrincipalType::AssumedRole
369        );
370        assert_eq!(
371            PrincipalType::from_arn("arn:aws:sts::123456789012:federated-user/bob"),
372            PrincipalType::FederatedUser
373        );
374        assert_eq!(
375            PrincipalType::from_arn("arn:aws:iam::123456789012:root"),
376            PrincipalType::Root
377        );
378    }
379
380    #[test]
381    fn principal_type_unparseable_is_unknown_not_root() {
382        // Identified by cubic on PR #391: falling back to Root would let
383        // malformed or unexpected ARNs bypass IAM enforcement, since
384        // Principal::is_root short-circuits evaluation. The fallback must
385        // be the non-bypassable Unknown variant.
386        assert_eq!(
387            PrincipalType::from_arn("not-an-arn"),
388            PrincipalType::Unknown
389        );
390        assert_eq!(PrincipalType::from_arn(""), PrincipalType::Unknown);
391        assert_eq!(
392            PrincipalType::from_arn("arn:aws:iam::123456789012:something-weird"),
393            PrincipalType::Unknown
394        );
395
396        // And a Principal built from an Unknown ARN must not be treated
397        // as root for enforcement decisions.
398        let p = Principal {
399            arn: "garbage".to_string(),
400            user_id: "x".to_string(),
401            account_id: "123456789012".to_string(),
402            principal_type: PrincipalType::Unknown,
403            source_identity: None,
404        };
405        assert!(!p.is_root());
406    }
407
408    #[test]
409    fn principal_is_root_covers_root_type_and_arn_suffix() {
410        let p = Principal {
411            arn: "arn:aws:iam::123456789012:root".to_string(),
412            user_id: "AIDAROOT".to_string(),
413            account_id: "123456789012".to_string(),
414            principal_type: PrincipalType::Root,
415            source_identity: None,
416        };
417        assert!(p.is_root());
418
419        let user = Principal {
420            arn: "arn:aws:iam::123456789012:user/alice".to_string(),
421            user_id: "AIDAALICE".to_string(),
422            account_id: "123456789012".to_string(),
423            principal_type: PrincipalType::User,
424            source_identity: None,
425        };
426        assert!(!user.is_root());
427    }
428
429    #[test]
430    fn resolved_credential_accessors_forward_to_principal() {
431        let rc = ResolvedCredential {
432            secret_access_key: "s".into(),
433            session_token: None,
434            principal: Principal {
435                arn: "arn:aws:iam::123456789012:user/alice".into(),
436                user_id: "AIDAALICE".into(),
437                account_id: "123456789012".into(),
438                principal_type: PrincipalType::User,
439                source_identity: None,
440            },
441        };
442        assert_eq!(rc.principal_arn(), "arn:aws:iam::123456789012:user/alice");
443        assert_eq!(rc.user_id(), "AIDAALICE");
444        assert_eq!(rc.account_id(), "123456789012");
445    }
446
447    #[test]
448    fn root_bypass_rejects_non_test_keys() {
449        assert!(!is_root_bypass(""));
450        assert!(!is_root_bypass("   "));
451        assert!(!is_root_bypass("AKIAIOSFODNN7EXAMPLE"));
452        assert!(!is_root_bypass("FKIA123456"));
453        assert!(!is_root_bypass("tes"));
454        assert!(!is_root_bypass("tst"));
455    }
456}