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