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