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