Skip to main content

s4_server/
policy.rs

1//! Bucket policy / IAM enforcement at the gateway (v0.2 #7).
2//!
3//! Loads a subset of AWS bucket policy JSON and evaluates incoming requests
4//! against it before delegating to the backend. Out of scope for v0.2:
5//! full IAM Conditions, STS / AssumeRole chains, cross-account delegation,
6//! resource-based ACLs.
7//!
8//! Supported AWS S3 actions:
9//! - `s3:GetObject` / `s3:PutObject` / `s3:DeleteObject` (object-level)
10//! - `s3:GetObjectTagging` / `s3:PutObjectTagging` / `s3:DeleteObjectTagging`
11//! - `s3:GetObjectAcl` / `s3:PutObjectAcl`
12//! - `s3:RestoreObject` / `s3:GetObjectVersion` / `s3:DeleteObjectVersion`
13//! - `s3:GetObjectRetention` / `s3:PutObjectRetention`
14//! - `s3:GetObjectLegalHold` / `s3:PutObjectLegalHold`
15//! - `s3:BypassGovernanceRetention` / `s3:AbortMultipartUpload`
16//! - `s3:ListBucket` / `s3:GetBucketLocation` / `s3:GetBucketAcl` (bucket-level)
17//! - `s3:GetBucketCors` / `s3:PutBucketCors` / `s3:DeleteBucketCors`
18//! - `s3:GetBucketVersioning` / `s3:PutBucketVersioning`
19//! - `s3:GetBucketTagging` / `s3:PutBucketTagging` / `s3:DeleteBucketTagging`
20//! - `s3:GetBucketReplication` / `s3:PutBucketReplication` / `s3:DeleteBucketReplication`
21//! - `s3:GetBucketLifecycleConfiguration` / `s3:PutBucketLifecycleConfiguration`
22//! - `s3:GetBucketNotification` / `s3:PutBucketNotification`
23//! - `s3:GetInventoryConfiguration` / `s3:PutInventoryConfiguration`
24//! - `s3:GetObjectLockConfiguration` / `s3:PutObjectLockConfiguration`
25//! - `s3:CreateBucket` / `s3:DeleteBucket` / `s3:ListMultipartUploads`
26//! - `s3:*` (wildcard, matches all of the above)
27//! - `*` (wildcard, matches everything)
28//!
29//! Supported Resource patterns (case-sensitive):
30//! - `arn:aws:s3:::<bucket>` — bucket-level ops (ListBucket etc.)
31//! - `arn:aws:s3:::<bucket>/<key>` — object-level ops
32//! - Trailing or interior `*` glob in the key portion
33//! - `arn:aws:s3:::*` — any bucket / any key
34//!
35//! v0.8.4 #75 (audit H-4): Resource ARN scoping is now namespace-aware. A
36//! bucket-form ARN (`arn:aws:s3:::b`) only matches bucket-level actions; an
37//! object-form ARN (`arn:aws:s3:::b/k`) only matches object-level actions.
38//! Pre-v0.8.4 a single bucket-form ARN with a `s3:GetObject` Action would
39//! silently grant the object op — this is now correctly rejected so a
40//! mis-typed policy fails closed instead of escalating privilege.
41//!
42//! Supported Principal forms:
43//! - `"Principal": "*"` — anyone authenticated by S4's auth layer. The
44//!   string MUST be exactly `"*"` (v0.8.4 #75 / audit H-5); any other
45//!   bare-string form (e.g. `"AKIA..."`) is rejected at parse time.
46//! - `"Principal": {"AWS": ["AKIA...", "AKIA..."]}` — match by SigV4
47//!   access key ID. The list MUST be non-empty (an empty list previously
48//!   widened to "match anyone", which is unsafe). `Service`, `Federated`
49//!   and `CanonicalUser` Principal types are NOT supported and are
50//!   rejected at parse time so silent acceptance can't hide a policy gap.
51//!   (Full IAM user/role ARN matching is a future extension once STS
52//!   integration lands.)
53//!
54//! Decision: **explicit Deny > explicit Allow > implicit Deny** — the
55//! standard AWS evaluation order.
56
57use std::collections::HashMap;
58use std::net::IpAddr;
59use std::path::Path;
60use std::sync::Arc;
61use std::time::SystemTime;
62
63use serde::Deserialize;
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
66#[serde(rename_all = "PascalCase")]
67pub enum Effect {
68    Allow,
69    Deny,
70}
71
72#[derive(Debug, Clone, Deserialize)]
73#[serde(untagged)]
74enum StringOrVec {
75    Single(String),
76    Many(Vec<String>),
77}
78
79impl StringOrVec {
80    fn into_vec(self) -> Vec<String> {
81        match self {
82            Self::Single(s) => vec![s],
83            Self::Many(v) => v,
84        }
85    }
86}
87
88/// v0.8.4 #75 (audit H-5): Principal set is parsed by hand from
89/// `serde_json::Value` so we can reject silently-permissive shapes (a
90/// bare string that isn't `"*"`, an empty `{"AWS": []}` list, or any
91/// `Service` / `Federated` / `CanonicalUser` Principal type that we
92/// don't support).
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub enum PrincipalSet {
95    /// `"Principal": "*"` — match any caller (incl. anonymous / unauth).
96    /// The bare string must be exactly `"*"`; any other string form is a
97    /// parse error.
98    Wildcard,
99    /// `"Principal": {"AWS": ["AKIA...", ...]}` — match those access key
100    /// ids. Guaranteed non-empty after parse.
101    Specific(Vec<String>),
102}
103
104impl PrincipalSet {
105    /// Parse the JSON value attached to a Statement's `Principal` field.
106    pub fn parse(value: &serde_json::Value) -> Result<Self, PolicyParseError> {
107        match value {
108            serde_json::Value::String(s) if s == "*" => Ok(PrincipalSet::Wildcard),
109            serde_json::Value::String(other) => {
110                Err(PolicyParseError::InvalidWildcard(other.clone()))
111            }
112            serde_json::Value::Object(map) => {
113                if map.len() != 1 || !map.contains_key("AWS") {
114                    return Err(PolicyParseError::UnsupportedPrincipalType);
115                }
116                let aws = &map["AWS"];
117                let principals: Vec<String> = match aws {
118                    serde_json::Value::String(s) => vec![s.clone()],
119                    serde_json::Value::Array(arr) => {
120                        let mut out = Vec::with_capacity(arr.len());
121                        for v in arr {
122                            match v {
123                                serde_json::Value::String(s) => out.push(s.clone()),
124                                _ => return Err(PolicyParseError::InvalidPrincipalShape),
125                            }
126                        }
127                        out
128                    }
129                    _ => return Err(PolicyParseError::InvalidPrincipalShape),
130                };
131                if principals.is_empty() {
132                    return Err(PolicyParseError::EmptyPrincipalList);
133                }
134                Ok(PrincipalSet::Specific(principals))
135            }
136            _ => Err(PolicyParseError::InvalidPrincipalShape),
137        }
138    }
139}
140
141#[derive(Debug, Clone, Deserialize)]
142struct StatementJson {
143    #[serde(rename = "Sid")]
144    sid: Option<String>,
145    #[serde(rename = "Effect")]
146    effect: Effect,
147    #[serde(rename = "Action")]
148    action: StringOrVec,
149    #[serde(rename = "Resource")]
150    resource: StringOrVec,
151    /// Captured as a raw JSON value so [`PrincipalSet::parse`] can apply
152    /// the v0.8.4 #75 strict-shape validation (bare `"*"` only, non-empty
153    /// `AWS` list, no `Service` / `Federated` / `CanonicalUser`).
154    #[serde(rename = "Principal", default)]
155    principal: Option<serde_json::Value>,
156    /// Optional Condition map (v0.3 #13): operator → key → values.
157    /// `{"IpAddress": {"aws:SourceIp": ["10.0.0.0/8"]}, ...}`.
158    #[serde(rename = "Condition", default)]
159    condition: Option<HashMap<String, HashMap<String, StringOrVec>>>,
160}
161
162#[derive(Debug, Clone, Deserialize)]
163struct PolicyJson {
164    #[serde(rename = "Version")]
165    _version: Option<String>,
166    #[serde(rename = "Statement")]
167    statements: Vec<StatementJson>,
168}
169
170/// v0.8.4 #75: a parsed Resource ARN with namespace tag (bucket-form vs
171/// object-form). Built once at policy-parse time so statement evaluation
172/// can route bucket-only / object-only actions to the right ARN shape
173/// without re-parsing on every request.
174#[derive(Clone, Debug, PartialEq, Eq)]
175pub enum ResourceArn {
176    /// `arn:aws:s3:::<bucket>` — matches bucket-level actions only.
177    Bucket(String),
178    /// `arn:aws:s3:::<bucket>/<key-pattern>` — matches object-level
179    /// actions only. `key_pattern` may carry `*` / `?` glob characters.
180    Object { bucket: String, key_pattern: String },
181}
182
183/// v0.8.4 #75: which resource ARN shape an Action accepts.
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185enum ResourceKind {
186    /// Action operates on an object — only `Object{...}` ARNs match.
187    ObjectOnly,
188    /// Action operates on a bucket — only `Bucket(...)` ARNs match.
189    BucketOnly,
190    /// Action's namespace is ambiguous (e.g. `s3:*`, or an unknown
191    /// forward-compat action) — both ARN forms may match.
192    Either,
193}
194
195/// v0.8.4 #75: structured parse / validation errors. Surfaced via
196/// [`Display`] for the public `Result<_, String>` boundary so existing
197/// CLI / test call sites that string-match on the message keep working.
198#[derive(Debug, thiserror::Error)]
199pub enum PolicyParseError {
200    #[error("policy JSON parse error: {0}")]
201    Json(#[from] serde_json::Error),
202    #[error("Resource ARN must start with \"arn:aws:s3:::\" — got {0:?}")]
203    InvalidResourceArn(String),
204    #[error("Resource ARN bucket name is empty: {0:?}")]
205    EmptyBucketInArn(String),
206    #[error("Principal wildcard must be exact \"*\" — got {0:?}")]
207    InvalidWildcard(String),
208    #[error(
209        "unsupported Principal type (only AWS principals are supported, no Service / Federated / CanonicalUser)"
210    )]
211    UnsupportedPrincipalType,
212    #[error("Principal AWS list must not be empty")]
213    EmptyPrincipalList,
214    #[error("Principal value must be the string \"*\" or a {{AWS: ...}} object")]
215    InvalidPrincipalShape,
216    #[error(
217        "unsupported policy Condition operator: {op:?}. v0.3 supports IpAddress / NotIpAddress / StringEquals / StringNotEquals / StringLike / StringNotLike / DateGreaterThan / DateLessThan / Bool."
218    )]
219    UnsupportedConditionOperator { op: String },
220}
221
222/// v0.8.4 #75: parse a Resource string into the typed [`ResourceArn`].
223/// Accepts `arn:aws:s3:::<bucket>` and `arn:aws:s3:::<bucket>/<key>`
224/// (with optional glob chars in `<key>`). The all-buckets wildcard
225/// `arn:aws:s3:::*` parses as `Bucket("*")` for the bare form and
226/// `Object{bucket="*", ..}` when a slash follows.
227pub fn parse_resource_arn(s: &str) -> Result<ResourceArn, PolicyParseError> {
228    const PREFIX: &str = "arn:aws:s3:::";
229    let rest = s
230        .strip_prefix(PREFIX)
231        .ok_or_else(|| PolicyParseError::InvalidResourceArn(s.to_owned()))?;
232    match rest.split_once('/') {
233        None => {
234            if rest.is_empty() {
235                return Err(PolicyParseError::EmptyBucketInArn(s.to_owned()));
236            }
237            Ok(ResourceArn::Bucket(rest.to_owned()))
238        }
239        Some((bucket, key_pattern)) => {
240            if bucket.is_empty() {
241                return Err(PolicyParseError::EmptyBucketInArn(s.to_owned()));
242            }
243            Ok(ResourceArn::Object {
244                bucket: bucket.to_owned(),
245                key_pattern: key_pattern.to_owned(),
246            })
247        }
248    }
249}
250
251/// v0.8.4 #75: map an Action verb to the ARN namespace it operates on.
252/// Unknown actions resolve to [`ResourceKind::Either`] to stay forward-
253/// compatible with future S3 actions a policy author wants to gate
254/// today.
255fn action_resource_kind(action: &str) -> ResourceKind {
256    match action {
257        // ----- Object-level -----
258        "s3:GetObject"
259        | "s3:PutObject"
260        | "s3:DeleteObject"
261        | "s3:GetObjectTagging"
262        | "s3:PutObjectTagging"
263        | "s3:DeleteObjectTagging"
264        | "s3:GetObjectAcl"
265        | "s3:PutObjectAcl"
266        | "s3:RestoreObject"
267        | "s3:GetObjectVersion"
268        | "s3:DeleteObjectVersion"
269        | "s3:GetObjectRetention"
270        | "s3:PutObjectRetention"
271        | "s3:GetObjectLegalHold"
272        | "s3:PutObjectLegalHold"
273        | "s3:BypassGovernanceRetention"
274        | "s3:AbortMultipartUpload" => ResourceKind::ObjectOnly,
275        // ----- Bucket-level -----
276        "s3:ListBucket"
277        | "s3:GetBucketLocation"
278        | "s3:GetBucketAcl"
279        | "s3:GetBucketCors"
280        | "s3:PutBucketCors"
281        | "s3:DeleteBucketCors"
282        | "s3:GetBucketVersioning"
283        | "s3:PutBucketVersioning"
284        | "s3:GetBucketTagging"
285        | "s3:PutBucketTagging"
286        | "s3:DeleteBucketTagging"
287        | "s3:GetBucketReplication"
288        | "s3:PutBucketReplication"
289        | "s3:DeleteBucketReplication"
290        | "s3:GetBucketLifecycleConfiguration"
291        | "s3:PutBucketLifecycleConfiguration"
292        | "s3:GetBucketNotification"
293        | "s3:PutBucketNotification"
294        | "s3:GetInventoryConfiguration"
295        | "s3:PutInventoryConfiguration"
296        | "s3:GetObjectLockConfiguration"
297        | "s3:PutObjectLockConfiguration"
298        | "s3:CreateBucket"
299        | "s3:DeleteBucket"
300        | "s3:ListMultipartUploads" => ResourceKind::BucketOnly,
301        // `s3:*`, `*`, and any unknown verb stay permissive so a policy
302        // gating a future action keeps working without a server upgrade.
303        _ => ResourceKind::Either,
304    }
305}
306
307/// Compiled bucket policy ready to evaluate requests.
308#[derive(Debug, Clone)]
309pub struct Policy {
310    statements: Vec<Statement>,
311}
312
313#[derive(Debug, Clone)]
314struct Statement {
315    sid: Option<String>,
316    effect: Effect,
317    actions: Vec<String>, // `s3:GetObject`, `s3:*`, `*`
318    /// v0.8.4 #75: pre-parsed Resource ARNs, tagged by namespace so
319    /// statement evaluation can route bucket-only / object-only Actions
320    /// to the correct ARN shape (audit H-4).
321    resources: Vec<ResourceArn>,
322    /// `None` = no Principal field on the statement → match anyone
323    /// (incl. anonymous). `Some(PrincipalSet::Wildcard)` (the literal
324    /// `"*"` form) is semantically the same. `Some(Specific(vec))` =
325    /// only those access key ids. Validated at parse time by
326    /// [`PrincipalSet::parse`] (v0.8.4 #75 / audit H-5).
327    principals: Option<PrincipalSet>,
328    /// Compiled Condition clauses; empty vec = no condition restriction
329    /// (statement always matches once Action / Resource / Principal pass).
330    conditions: Vec<Condition>,
331}
332
333/// Per-request context fed into the policy evaluator. Caller is expected to
334/// fill what's available; missing fields make any Condition that depends on
335/// them fail (= statement skipped, never silently allowed).
336#[derive(Debug, Clone, Default)]
337pub struct RequestContext {
338    pub source_ip: Option<IpAddr>,
339    pub user_agent: Option<String>,
340    pub request_time: Option<SystemTime>,
341    pub secure_transport: bool,
342    /// v0.6 #39: tags currently attached to the object the request
343    /// targets (resolved by the caller via `TagManager` ahead of
344    /// `evaluate_with`). Surfaced to policy via the
345    /// `s3:ExistingObjectTag/<key>` condition key. `None` here is
346    /// treated identically to "no tags exist" — every
347    /// `ExistingObjectTag` clause then fails.
348    pub existing_object_tags: Option<crate::tagging::TagSet>,
349    /// v0.6 #39: tags carried in the *request* itself (PutObject's
350    /// `x-amz-tagging` URL-encoded header, or PutObjectTagging's
351    /// `Tagging` body). Surfaced to policy via the
352    /// `s3:RequestObjectTag/<key>` condition key.
353    pub request_object_tags: Option<crate::tagging::TagSet>,
354    /// Generic key → value map for any aws:* or s3:* context key not
355    /// covered by the typed fields above (keeps the door open for any
356    /// key the caller wants to plumb without changing the struct).
357    pub extra: HashMap<String, String>,
358}
359
360/// One compiled Condition clause inside a Statement.
361#[derive(Debug, Clone)]
362struct Condition {
363    op: ConditionOp,
364    key: String,         // e.g. `aws:SourceIp`, `aws:UserAgent`, `aws:CurrentTime`
365    values: Vec<String>, // operator-specific (CIDR, glob, ISO-8601 timestamp, "true" / "false", ...)
366}
367
368#[derive(Debug, Clone, Copy, PartialEq, Eq)]
369enum ConditionOp {
370    IpAddress,
371    NotIpAddress,
372    StringEquals,
373    StringNotEquals,
374    StringLike,
375    StringNotLike,
376    DateGreaterThan,
377    DateLessThan,
378    Bool,
379}
380
381impl ConditionOp {
382    fn parse(s: &str) -> Option<Self> {
383        Some(match s {
384            "IpAddress" => Self::IpAddress,
385            "NotIpAddress" => Self::NotIpAddress,
386            "StringEquals" => Self::StringEquals,
387            "StringNotEquals" => Self::StringNotEquals,
388            "StringLike" => Self::StringLike,
389            "StringNotLike" => Self::StringNotLike,
390            "DateGreaterThan" => Self::DateGreaterThan,
391            "DateLessThan" => Self::DateLessThan,
392            "Bool" => Self::Bool,
393            _ => return None,
394        })
395    }
396}
397
398impl Policy {
399    /// Parse a JSON bucket policy. Returns the human-readable [`Display`]
400    /// of the underlying [`PolicyParseError`] for backward compatibility
401    /// with the pre-v0.8.4 `Result<_, String>` callers (CLI flag handler,
402    /// existing E2E tests). Use [`Policy::from_json_str_typed`] when you
403    /// need to inspect the error variant programmatically.
404    pub fn from_json_str(s: &str) -> Result<Self, String> {
405        Self::from_json_str_typed(s).map_err(|e| e.to_string())
406    }
407
408    /// v0.8.4 #75: typed-error variant of [`Policy::from_json_str`]. Lets
409    /// new callers (and the unit tests for audit H-4 / H-5) match on
410    /// [`PolicyParseError`] directly instead of grepping a String.
411    pub fn from_json_str_typed(s: &str) -> Result<Self, PolicyParseError> {
412        let raw: PolicyJson = serde_json::from_str(s)?;
413        let mut statements = Vec::with_capacity(raw.statements.len());
414        for stmt in raw.statements {
415            let mut conditions = Vec::new();
416            if let Some(cond_map) = stmt.condition {
417                for (op_name, key_map) in cond_map {
418                    let op = ConditionOp::parse(&op_name).ok_or(
419                        PolicyParseError::UnsupportedConditionOperator {
420                            op: op_name.clone(),
421                        },
422                    )?;
423                    for (key, values) in key_map {
424                        conditions.push(Condition {
425                            op,
426                            key,
427                            values: values.into_vec(),
428                        });
429                    }
430                }
431            }
432            // v0.8.4 #75 / audit H-4: pre-parse every Resource ARN here
433            // so statement_matches_resource() can dispatch on namespace
434            // without re-parsing on the hot path.
435            let mut resources = Vec::with_capacity(stmt.resource.clone().into_vec().len());
436            for raw_arn in stmt.resource.into_vec() {
437                resources.push(parse_resource_arn(&raw_arn)?);
438            }
439            // v0.8.4 #75 / audit H-5: validate Principal shape strictly.
440            let principals = match stmt.principal {
441                None => None,
442                Some(value) => Some(PrincipalSet::parse(&value)?),
443            };
444            statements.push(Statement {
445                sid: stmt.sid,
446                effect: stmt.effect,
447                actions: stmt.action.into_vec(),
448                resources,
449                principals,
450                conditions,
451            });
452        }
453        Ok(Self { statements })
454    }
455
456    pub fn from_path(path: &Path) -> Result<Self, String> {
457        let txt = std::fs::read_to_string(path)
458            .map_err(|e| format!("failed to read {}: {e}", path.display()))?;
459        Self::from_json_str(&txt)
460    }
461
462    /// Evaluate a request against the policy.
463    ///
464    /// `principal_id` is typically the SigV4 access key id taken from the
465    /// authenticated request. Pass `None` for anonymous (will only match
466    /// statements with wildcard or absent Principal).
467    ///
468    /// Convenience for the common case with no Condition data; calls the
469    /// full [`Policy::evaluate_with`] with a default `RequestContext`.
470    pub fn evaluate(
471        &self,
472        action: &str,
473        bucket: &str,
474        key: Option<&str>,
475        principal_id: Option<&str>,
476    ) -> Decision {
477        self.evaluate_with(
478            action,
479            bucket,
480            key,
481            principal_id,
482            &RequestContext::default(),
483        )
484    }
485
486    /// Same as [`Policy::evaluate`] but lets the caller plumb a populated
487    /// [`RequestContext`] for v0.3 #13 IAM Conditions (IP allowlists,
488    /// user-agent restrictions, time windows, etc.).
489    pub fn evaluate_with(
490        &self,
491        action: &str,
492        bucket: &str,
493        key: Option<&str>,
494        principal_id: Option<&str>,
495        ctx: &RequestContext,
496    ) -> Decision {
497        let mut matched_allow: Option<Option<String>> = None;
498        let mut matched_deny: Option<Option<String>> = None;
499
500        for st in &self.statements {
501            if !st.actions.iter().any(|p| action_matches(p, action)) {
502                continue;
503            }
504            if !Self::statement_matches_resource(st, action, bucket, key) {
505                continue;
506            }
507            if !principal_matches(st.principals.as_ref(), principal_id) {
508                continue;
509            }
510            // v0.3 #13: Conditions are ALL-AND — a statement applies only
511            // when every Condition clause matches the request context.
512            // A clause failing simply skips the statement (no error).
513            if !st.conditions.iter().all(|c| condition_matches(c, ctx)) {
514                continue;
515            }
516            match st.effect {
517                Effect::Deny => {
518                    matched_deny = Some(st.sid.clone());
519                    // Any explicit Deny wins; no need to keep scanning, but
520                    // continue so the matched Sid reflects the LAST matching
521                    // Deny (deterministic for telemetry).
522                }
523                Effect::Allow => {
524                    if matched_allow.is_none() {
525                        matched_allow = Some(st.sid.clone());
526                    }
527                }
528            }
529        }
530
531        if let Some(sid) = matched_deny {
532            Decision::deny(sid)
533        } else if let Some(sid) = matched_allow {
534            Decision::allow(sid)
535        } else {
536            Decision::implicit_deny()
537        }
538    }
539
540    /// v0.8.4 #75 (audit H-4): namespace-aware Resource match.
541    ///
542    /// * `BucketOnly` actions (e.g. `s3:ListBucket`) only match
543    ///   `Bucket(...)` ARNs.
544    /// * `ObjectOnly` actions (e.g. `s3:GetObject`) only match
545    ///   `Object{...}` ARNs, whose `key_pattern` is glob-matched against
546    ///   the request key.
547    /// * `Either` (the catch-all for `s3:*`, `*`, and any unknown
548    ///   forward-compat verb) accepts whichever ARN form the policy
549    ///   carries — this preserves the existing `{"Action":"s3:*"}`
550    ///   ergonomics where a single statement covers an entire bucket.
551    ///
552    /// Pre-v0.8.4 behaviour conflated bucket-form and object-form ARNs
553    /// for **every** action, which silently widened any bucket-only
554    /// statement into object grants. That conflation is removed; mis-
555    /// typed policies now fail closed.
556    fn statement_matches_resource(
557        stmt: &Statement,
558        action: &str,
559        bucket: &str,
560        key: Option<&str>,
561    ) -> bool {
562        let kind = action_resource_kind(action);
563        for parsed in &stmt.resources {
564            match (parsed, kind) {
565                // ----- bucket-only actions -----
566                (ResourceArn::Bucket(b), ResourceKind::BucketOnly) => {
567                    if glob_match(b, bucket) {
568                        return true;
569                    }
570                }
571                // ----- object-only actions -----
572                (
573                    ResourceArn::Object {
574                        bucket: b,
575                        key_pattern: kp,
576                    },
577                    ResourceKind::ObjectOnly,
578                ) => {
579                    if !glob_match(b, bucket) {
580                        continue;
581                    }
582                    if let Some(k) = key
583                        && glob_match(kp, k)
584                    {
585                        return true;
586                    }
587                }
588                // ----- Either: forward-compat permissive branch -----
589                (ResourceArn::Bucket(b), ResourceKind::Either) => {
590                    if glob_match(b, bucket) {
591                        return true;
592                    }
593                }
594                (
595                    ResourceArn::Object {
596                        bucket: b,
597                        key_pattern: kp,
598                    },
599                    ResourceKind::Either,
600                ) => {
601                    if !glob_match(b, bucket) {
602                        continue;
603                    }
604                    match key {
605                        Some(k) => {
606                            if glob_match(kp, k) {
607                                return true;
608                            }
609                        }
610                        None => {
611                            // No key in the request and the ARN is
612                            // object-form — only the all-objects glob
613                            // counts as covering "the bucket itself".
614                            if kp == "*" {
615                                return true;
616                            }
617                        }
618                    }
619                }
620                // ----- mismatched namespace → skip (the H-4 fix) -----
621                (ResourceArn::Bucket(_), ResourceKind::ObjectOnly)
622                | (ResourceArn::Object { .. }, ResourceKind::BucketOnly) => continue,
623            }
624        }
625        false
626    }
627}
628
629#[derive(Debug, Clone, PartialEq, Eq)]
630pub struct Decision {
631    pub allow: bool,
632    pub matched_sid: Option<String>,
633    /// `None` = implicit deny (no statement matched), `Some(Allow|Deny)` =
634    /// explicit decision.
635    pub matched_effect: Option<Effect>,
636}
637
638impl Decision {
639    fn allow(sid: Option<String>) -> Self {
640        Self {
641            allow: true,
642            matched_sid: sid,
643            matched_effect: Some(Effect::Allow),
644        }
645    }
646    fn deny(sid: Option<String>) -> Self {
647        Self {
648            allow: false,
649            matched_sid: sid,
650            matched_effect: Some(Effect::Deny),
651        }
652    }
653    fn implicit_deny() -> Self {
654        Self {
655            allow: false,
656            matched_sid: None,
657            matched_effect: None,
658        }
659    }
660}
661
662/// Match an action pattern against a concrete action.
663/// Patterns: `*`, `s3:*`, `s3:GetObject`. Case-sensitive (AWS is too).
664fn action_matches(pattern: &str, action: &str) -> bool {
665    if pattern == "*" {
666        return true;
667    }
668    if let Some(prefix) = pattern.strip_suffix(":*") {
669        return action.starts_with(prefix) && action[prefix.len()..].starts_with(':');
670    }
671    pattern == action
672}
673
674/// Hand-rolled glob (`*` = any sequence, `?` = any single char) so we don't
675/// pull in the `globset` crate for a single use site.
676fn glob_match(pattern: &str, s: &str) -> bool {
677    let p_bytes = pattern.as_bytes();
678    let s_bytes = s.as_bytes();
679    glob_match_bytes(p_bytes, s_bytes)
680}
681
682fn glob_match_bytes(p: &[u8], s: &[u8]) -> bool {
683    let mut pi = 0;
684    let mut si = 0;
685    let mut star: Option<(usize, usize)> = None;
686    while si < s.len() {
687        if pi < p.len() && (p[pi] == b'?' || p[pi] == s[si]) {
688            pi += 1;
689            si += 1;
690        } else if pi < p.len() && p[pi] == b'*' {
691            star = Some((pi, si));
692            pi += 1;
693        } else if let Some((sp, ss)) = star {
694            pi = sp + 1;
695            si = ss + 1;
696            star = Some((sp, si));
697        } else {
698            return false;
699        }
700    }
701    while pi < p.len() && p[pi] == b'*' {
702        pi += 1;
703    }
704    pi == p.len()
705}
706
707fn principal_matches(allowed: Option<&PrincipalSet>, principal_id: Option<&str>) -> bool {
708    match allowed {
709        // No Principal field on the statement → match any caller (incl. anonymous).
710        None => true,
711        // `"Principal": "*"` → match any caller (incl. anonymous).
712        Some(PrincipalSet::Wildcard) => true,
713        // `"Principal": {"AWS": [...]}` → match by access-key-id only;
714        // anonymous callers (`principal_id = None`) are rejected here so
715        // an unauth request can never satisfy a Specific list (closes a
716        // sibling of audit H-5: silent anonymous widening).
717        Some(PrincipalSet::Specific(list)) => match principal_id {
718            None => false,
719            Some(id) => list.iter().any(|p| p == "*" || p == id),
720        },
721    }
722}
723
724/// v0.3 #13: evaluate one Condition clause against the request context.
725/// Returns `true` when the clause matches (statement may apply), `false`
726/// when it doesn't (statement is skipped).
727fn condition_matches(c: &Condition, ctx: &RequestContext) -> bool {
728    match c.op {
729        ConditionOp::IpAddress => match ctx.source_ip {
730            Some(ip) => c.values.iter().any(|cidr| ip_in_cidr(ip, cidr)),
731            None => false,
732        },
733        ConditionOp::NotIpAddress => match ctx.source_ip {
734            Some(ip) => !c.values.iter().any(|cidr| ip_in_cidr(ip, cidr)),
735            None => false,
736        },
737        ConditionOp::StringEquals => match context_value(&c.key, ctx) {
738            Some(v) => c.values.iter().any(|x| x == &v),
739            None => false,
740        },
741        ConditionOp::StringNotEquals => match context_value(&c.key, ctx) {
742            Some(v) => !c.values.iter().any(|x| x == &v),
743            None => false,
744        },
745        ConditionOp::StringLike => match context_value(&c.key, ctx) {
746            Some(v) => c.values.iter().any(|pat| glob_match(pat, &v)),
747            None => false,
748        },
749        ConditionOp::StringNotLike => match context_value(&c.key, ctx) {
750            Some(v) => !c.values.iter().any(|pat| glob_match(pat, &v)),
751            None => false,
752        },
753        ConditionOp::DateGreaterThan | ConditionOp::DateLessThan => {
754            // aws:CurrentTime is the only date key we materialise today.
755            let now = ctx.request_time.unwrap_or_else(SystemTime::now);
756            let now_unix = match now.duration_since(SystemTime::UNIX_EPOCH) {
757                Ok(d) => d.as_secs() as i64,
758                Err(_) => 0,
759            };
760            c.values.iter().any(|s| match parse_iso8601(s) {
761                Some(t) => match c.op {
762                    ConditionOp::DateGreaterThan => now_unix > t,
763                    ConditionOp::DateLessThan => now_unix < t,
764                    _ => unreachable!(),
765                },
766                None => false,
767            })
768        }
769        ConditionOp::Bool => match context_value(&c.key, ctx) {
770            Some(v) => c.values.iter().any(|x| x.eq_ignore_ascii_case(&v)),
771            None => false,
772        },
773    }
774}
775
776/// Resolve a Condition key against the request context. Handles the
777/// well-known `aws:SourceIp` / `aws:UserAgent` / `aws:CurrentTime` /
778/// `aws:SecureTransport` keys, the v0.6 #39 `s3:ExistingObjectTag/*` /
779/// `s3:RequestObjectTag/*` tag keys, plus any free-form key the caller
780/// stuffed into `ctx.extra`.
781fn context_value(key: &str, ctx: &RequestContext) -> Option<String> {
782    match key {
783        "aws:UserAgent" | "aws:userAgent" => ctx.user_agent.clone(),
784        "aws:SourceIp" | "aws:sourceIp" => ctx.source_ip.map(|ip| ip.to_string()),
785        "aws:SecureTransport" => Some(ctx.secure_transport.to_string()),
786        other => {
787            // v0.6 #39: tag-based condition keys are slash-suffixed
788            // (`s3:ExistingObjectTag/<tag-key>` /
789            // `s3:RequestObjectTag/<tag-key>`). Resolve to the named
790            // tag's value if present in the relevant set; `None`
791            // otherwise — which makes the clause fail (statement
792            // skipped) for both `StringEquals` and `StringNotEquals`.
793            if let Some(tag_key) = other.strip_prefix("s3:ExistingObjectTag/") {
794                return ctx
795                    .existing_object_tags
796                    .as_ref()
797                    .and_then(|s| s.get(tag_key).map(str::to_owned));
798            }
799            if let Some(tag_key) = other.strip_prefix("s3:RequestObjectTag/") {
800                return ctx
801                    .request_object_tags
802                    .as_ref()
803                    .and_then(|s| s.get(tag_key).map(str::to_owned));
804            }
805            ctx.extra.get(other).cloned()
806        }
807    }
808}
809
810/// Minimal CIDR-or-bare-IP membership test for `IpAddress`. Supports both
811/// IPv4 and IPv6, with or without the `/N` mask.
812fn ip_in_cidr(ip: IpAddr, cidr: &str) -> bool {
813    match cidr.split_once('/') {
814        None => cidr.parse::<IpAddr>().is_ok_and(|c| c == ip),
815        Some((net_str, mask_str)) => {
816            let Ok(net) = net_str.parse::<IpAddr>() else {
817                return false;
818            };
819            let Ok(mask_bits) = mask_str.parse::<u8>() else {
820                return false;
821            };
822            match (ip, net) {
823                (IpAddr::V4(ip4), IpAddr::V4(net4)) => {
824                    if mask_bits > 32 {
825                        return false;
826                    }
827                    if mask_bits == 0 {
828                        return true;
829                    }
830                    let shift = 32 - mask_bits;
831                    (u32::from(ip4) >> shift) == (u32::from(net4) >> shift)
832                }
833                (IpAddr::V6(ip6), IpAddr::V6(net6)) => {
834                    if mask_bits > 128 {
835                        return false;
836                    }
837                    if mask_bits == 0 {
838                        return true;
839                    }
840                    let shift = 128 - mask_bits;
841                    (u128::from(ip6) >> shift) == (u128::from(net6) >> shift)
842                }
843                _ => false, // IPv4 vs IPv6 mismatch
844            }
845        }
846    }
847}
848
849/// Minimal ISO-8601 parser tailored to the AWS bucket-policy
850/// `aws:CurrentTime` format: `YYYY-MM-DDTHH:MM:SSZ` (UTC, second
851/// granularity). Returns unix epoch seconds. AWS also accepts the
852/// `+00:00` offset variants and millisecond fractions — out of scope
853/// for v0.3, can be relaxed later if a real policy needs them.
854fn parse_iso8601(s: &str) -> Option<i64> {
855    // Accept `YYYY-MM-DDTHH:MM:SSZ` only; reject anything else.
856    let s = s.strip_suffix('Z')?;
857    let (date, time) = s.split_once('T')?;
858    let date_parts: Vec<&str> = date.split('-').collect();
859    if date_parts.len() != 3 {
860        return None;
861    }
862    let year: i64 = date_parts[0].parse().ok()?;
863    let month: i64 = date_parts[1].parse().ok()?;
864    let day: i64 = date_parts[2].parse().ok()?;
865    let time_parts: Vec<&str> = time.split(':').collect();
866    if time_parts.len() != 3 {
867        return None;
868    }
869    let h: i64 = time_parts[0].parse().ok()?;
870    let m: i64 = time_parts[1].parse().ok()?;
871    let s: i64 = time_parts[2].parse().ok()?;
872    // Days from 1970-01-01 via a quick civil-from-date algorithm
873    // (Howard Hinnant — public domain). Good for AD years.
874    let y = if month <= 2 { year - 1 } else { year };
875    let era = if y >= 0 { y } else { y - 399 } / 400;
876    let yoe = (y - era * 400) as u64;
877    let mp = if month > 2 { month - 3 } else { month + 9 };
878    let doy = (153 * mp + 2) / 5 + day - 1;
879    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy as u64;
880    let days_from_epoch = era * 146097 + doe as i64 - 719468;
881    Some(days_from_epoch * 86_400 + h * 3600 + m * 60 + s)
882}
883
884/// Wrap a Policy in an Arc so cloning the S4Service stays cheap.
885pub type SharedPolicy = Arc<Policy>;
886
887#[cfg(test)]
888mod tests {
889    use super::*;
890
891    fn p(s: &str) -> Policy {
892        Policy::from_json_str(s).expect("policy")
893    }
894
895    #[test]
896    fn allow_then_deny_explicit_deny_wins() {
897        let pol = p(r#"{
898            "Version": "2012-10-17",
899            "Statement": [
900              {"Sid": "AllowAll", "Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::b/*"},
901              {"Sid": "DenyDelete", "Effect": "Deny", "Action": "s3:DeleteObject", "Resource": "arn:aws:s3:::b/*"}
902            ]
903        }"#);
904        let d = pol.evaluate("s3:GetObject", "b", Some("k"), None);
905        assert!(d.allow);
906        assert_eq!(d.matched_sid.as_deref(), Some("AllowAll"));
907        let d = pol.evaluate("s3:DeleteObject", "b", Some("k"), None);
908        assert!(!d.allow);
909        assert_eq!(d.matched_effect, Some(Effect::Deny));
910        assert_eq!(d.matched_sid.as_deref(), Some("DenyDelete"));
911    }
912
913    #[test]
914    fn implicit_deny_when_no_statement_matches() {
915        let pol = p(r#"{
916            "Version": "2012-10-17",
917            "Statement": [
918              {"Effect": "Allow", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::other/*"}
919            ]
920        }"#);
921        let d = pol.evaluate("s3:GetObject", "mine", Some("k"), None);
922        assert!(!d.allow);
923        assert_eq!(d.matched_effect, None);
924    }
925
926    #[test]
927    fn resource_glob_matches_prefix() {
928        let pol = p(r#"{
929            "Version": "2012-10-17",
930            "Statement": [{
931              "Effect": "Allow",
932              "Action": "s3:GetObject",
933              "Resource": "arn:aws:s3:::b/data/*.parquet"
934            }]
935        }"#);
936        assert!(
937            pol.evaluate("s3:GetObject", "b", Some("data/foo.parquet"), None)
938                .allow
939        );
940        assert!(
941            pol.evaluate("s3:GetObject", "b", Some("data/sub/bar.parquet"), None)
942                .allow
943        );
944        assert!(
945            !pol.evaluate("s3:GetObject", "b", Some("data/foo.txt"), None)
946                .allow
947        );
948    }
949
950    #[test]
951    fn s3_action_wildcard() {
952        // v0.8.4 #75: a bare `arn:aws:s3:::*` is bucket-form and only
953        // covers bucket-level actions; object reach requires the
954        // explicit `/*` glob in the key portion. Use a two-statement
955        // policy that grants both ARN namespaces.
956        let pol = p(r#"{
957            "Version": "2012-10-17",
958            "Statement": [
959              {"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::*"},
960              {"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::*/*"}
961            ]
962        }"#);
963        assert!(pol.evaluate("s3:GetObject", "any", Some("k"), None).allow);
964        assert!(pol.evaluate("s3:PutObject", "any", Some("k"), None).allow);
965        assert!(pol.evaluate("s3:ListBucket", "any", None, None).allow);
966        // Non-s3 action would not match (we don't generate any non-s3 actions
967        // from S4Service handlers, but verify the matcher behaves correctly)
968        assert!(!pol.evaluate("iam:ListUsers", "any", None, None).allow);
969    }
970
971    #[test]
972    fn principal_match_by_access_key_id() {
973        let pol = p(r#"{
974            "Version": "2012-10-17",
975            "Statement": [{
976              "Effect": "Allow",
977              "Action": "s3:*",
978              "Resource": "arn:aws:s3:::b/*",
979              "Principal": {"AWS": ["AKIATEST123"]}
980            }]
981        }"#);
982        assert!(
983            pol.evaluate("s3:GetObject", "b", Some("k"), Some("AKIATEST123"))
984                .allow
985        );
986        assert!(
987            !pol.evaluate("s3:GetObject", "b", Some("k"), Some("AKIAOTHER"))
988                .allow
989        );
990        assert!(!pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
991    }
992
993    #[test]
994    fn principal_wildcard_matches_anyone() {
995        let pol = p(r#"{
996            "Version": "2012-10-17",
997            "Statement": [{
998              "Effect": "Allow",
999              "Action": "s3:*",
1000              "Resource": "arn:aws:s3:::b/*",
1001              "Principal": "*"
1002            }]
1003        }"#);
1004        assert!(
1005            pol.evaluate("s3:GetObject", "b", Some("k"), Some("AKIAANY"))
1006                .allow
1007        );
1008        assert!(pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
1009    }
1010
1011    #[test]
1012    fn resource_can_be_string_or_array() {
1013        let single = p(r#"{
1014            "Statement": [{"Effect": "Allow", "Action": "s3:GetObject",
1015                          "Resource": "arn:aws:s3:::a/*"}]
1016        }"#);
1017        let multi = p(r#"{
1018            "Statement": [{"Effect": "Allow", "Action": "s3:GetObject",
1019                          "Resource": ["arn:aws:s3:::a/*", "arn:aws:s3:::b/*"]}]
1020        }"#);
1021        assert!(single.evaluate("s3:GetObject", "a", Some("k"), None).allow);
1022        assert!(!single.evaluate("s3:GetObject", "b", Some("k"), None).allow);
1023        assert!(multi.evaluate("s3:GetObject", "b", Some("k"), None).allow);
1024    }
1025
1026    #[test]
1027    fn bucket_level_resource_for_listbucket() {
1028        let pol = p(r#"{
1029            "Statement": [{"Effect": "Allow", "Action": "s3:ListBucket",
1030                          "Resource": "arn:aws:s3:::b"}]
1031        }"#);
1032        // ListBucket uses a key=None resource, formatted as bucket-only ARN
1033        assert!(pol.evaluate("s3:ListBucket", "b", None, None).allow);
1034        assert!(!pol.evaluate("s3:ListBucket", "other", None, None).allow);
1035    }
1036
1037    #[test]
1038    fn glob_match_basics() {
1039        assert!(glob_match("foo", "foo"));
1040        assert!(!glob_match("foo", "bar"));
1041        assert!(glob_match("*", "anything"));
1042        assert!(glob_match("foo*", "foobar"));
1043        assert!(glob_match("*bar", "foobar"));
1044        assert!(glob_match("foo*bar", "fooXYZbar"));
1045        assert!(glob_match("a?c", "abc"));
1046        assert!(!glob_match("a?c", "abbc"));
1047        assert!(glob_match("a*b*c", "axxxbyyyc"));
1048    }
1049
1050    // ===== v0.3 #13 IAM Condition tests =====
1051
1052    fn ctx_ip(ip: &str) -> RequestContext {
1053        RequestContext {
1054            source_ip: Some(ip.parse().unwrap()),
1055            ..Default::default()
1056        }
1057    }
1058
1059    #[test]
1060    fn condition_ip_address_cidr_match() {
1061        let pol = p(r#"{
1062            "Statement": [{
1063              "Effect": "Allow", "Action": "s3:GetObject",
1064              "Resource": "arn:aws:s3:::b/*",
1065              "Condition": {"IpAddress": {"aws:SourceIp": ["10.0.0.0/8", "192.168.1.0/24"]}}
1066            }]
1067        }"#);
1068        assert!(
1069            pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_ip("10.5.6.7"))
1070                .allow
1071        );
1072        assert!(
1073            pol.evaluate_with(
1074                "s3:GetObject",
1075                "b",
1076                Some("k"),
1077                None,
1078                &ctx_ip("192.168.1.50")
1079            )
1080            .allow
1081        );
1082        assert!(
1083            !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_ip("203.0.113.1"))
1084                .allow
1085        );
1086        // No source IP in context → condition fails → statement skipped
1087        assert!(
1088            !pol.evaluate_with(
1089                "s3:GetObject",
1090                "b",
1091                Some("k"),
1092                None,
1093                &RequestContext::default()
1094            )
1095            .allow
1096        );
1097    }
1098
1099    #[test]
1100    fn condition_not_ip_address_negates() {
1101        let pol = p(r#"{
1102            "Statement": [{
1103              "Effect": "Deny", "Action": "s3:DeleteObject",
1104              "Resource": "arn:aws:s3:::b/*",
1105              "Condition": {"NotIpAddress": {"aws:SourceIp": ["10.0.0.0/8"]}}
1106            },
1107            {"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::b/*"}]
1108        }"#);
1109        // Outside the trusted CIDR → Deny applies (NotIpAddress = true) → AccessDenied
1110        assert!(
1111            !pol.evaluate_with(
1112                "s3:DeleteObject",
1113                "b",
1114                Some("k"),
1115                None,
1116                &ctx_ip("203.0.113.1")
1117            )
1118            .allow
1119        );
1120        // Inside the trusted CIDR → Deny condition fails → Allow remains
1121        assert!(
1122            pol.evaluate_with("s3:DeleteObject", "b", Some("k"), None, &ctx_ip("10.0.0.7"))
1123                .allow
1124        );
1125    }
1126
1127    #[test]
1128    fn condition_string_equals_user_agent() {
1129        let pol = p(r#"{
1130            "Statement": [{
1131              "Effect": "Allow", "Action": "s3:GetObject",
1132              "Resource": "arn:aws:s3:::b/*",
1133              "Condition": {"StringEquals": {"aws:UserAgent": ["MyApp/1.0", "MyApp/2.0"]}}
1134            }]
1135        }"#);
1136        let ua = |s: &str| RequestContext {
1137            user_agent: Some(s.into()),
1138            ..Default::default()
1139        };
1140        assert!(
1141            pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("MyApp/1.0"))
1142                .allow
1143        );
1144        assert!(
1145            !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("OtherApp/1.0"))
1146                .allow
1147        );
1148    }
1149
1150    #[test]
1151    fn condition_string_like_glob() {
1152        let pol = p(r#"{
1153            "Statement": [{
1154              "Effect": "Allow", "Action": "s3:GetObject",
1155              "Resource": "arn:aws:s3:::b/*",
1156              "Condition": {"StringLike": {"aws:UserAgent": ["MyApp/*", "boto3/*"]}}
1157            }]
1158        }"#);
1159        let ua = |s: &str| RequestContext {
1160            user_agent: Some(s.into()),
1161            ..Default::default()
1162        };
1163        assert!(
1164            pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("MyApp/3.14"))
1165                .allow
1166        );
1167        assert!(
1168            pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("boto3/1.34.5"))
1169                .allow
1170        );
1171        assert!(
1172            !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("curl/8"))
1173                .allow
1174        );
1175    }
1176
1177    #[test]
1178    fn condition_date_window() {
1179        // Allow only requests between two dates.
1180        let pol = p(r#"{
1181            "Statement": [{
1182              "Effect": "Allow", "Action": "s3:GetObject",
1183              "Resource": "arn:aws:s3:::b/*",
1184              "Condition": {
1185                "DateGreaterThan": {"aws:CurrentTime": ["2026-01-01T00:00:00Z"]},
1186                "DateLessThan":    {"aws:CurrentTime": ["2026-12-31T23:59:59Z"]}
1187              }
1188            }]
1189        }"#);
1190        let mid_year = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_780_000_000); // ~mid-2026
1191        let after = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_800_000_000); // ~early-2027
1192        let ctx_at = |t: SystemTime| RequestContext {
1193            request_time: Some(t),
1194            ..Default::default()
1195        };
1196        assert!(
1197            pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_at(mid_year))
1198                .allow
1199        );
1200        assert!(
1201            !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_at(after))
1202                .allow
1203        );
1204    }
1205
1206    #[test]
1207    fn condition_bool_secure_transport() {
1208        let pol = p(r#"{
1209            "Statement": [{
1210              "Effect": "Deny", "Action": "s3:*",
1211              "Resource": "arn:aws:s3:::b/*",
1212              "Condition": {"Bool": {"aws:SecureTransport": ["false"]}}
1213            },
1214            {"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::b/*"}]
1215        }"#);
1216        let plain = RequestContext {
1217            secure_transport: false,
1218            ..Default::default()
1219        };
1220        let tls = RequestContext {
1221            secure_transport: true,
1222            ..Default::default()
1223        };
1224        // Plain HTTP → SecureTransport=false → Deny matches
1225        assert!(
1226            !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &plain)
1227                .allow
1228        );
1229        // TLS → SecureTransport=true → Deny condition fails → Allow remains
1230        assert!(
1231            pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &tls)
1232                .allow
1233        );
1234    }
1235
1236    #[test]
1237    fn condition_unknown_operator_rejected() {
1238        let err = Policy::from_json_str(
1239            r#"{
1240            "Statement": [{"Effect": "Allow", "Action": "s3:*",
1241              "Resource": "arn:aws:s3:::b/*",
1242              "Condition": {"NumericGreaterThan": {"k": ["1"]}}
1243            }]
1244        }"#,
1245        )
1246        .expect_err("should reject unsupported operator");
1247        assert!(err.contains("unsupported policy Condition operator"));
1248        assert!(err.contains("NumericGreaterThan"));
1249    }
1250
1251    // ===== v0.6 #39 tag-based condition tests =====
1252
1253    #[test]
1254    fn condition_existing_object_tag_matches_via_tagmanager_state() {
1255        let pol = p(r#"{
1256            "Statement": [{
1257              "Effect": "Allow", "Action": "s3:GetObject",
1258              "Resource": "arn:aws:s3:::b/*",
1259              "Condition": {
1260                "StringEquals": {"s3:ExistingObjectTag/Project": ["Phoenix"]}
1261              }
1262            }]
1263        }"#);
1264        let with_tag = RequestContext {
1265            existing_object_tags: Some(
1266                crate::tagging::TagSet::from_pairs(vec![
1267                    ("Project".into(), "Phoenix".into()),
1268                    ("Env".into(), "prod".into()),
1269                ])
1270                .unwrap(),
1271            ),
1272            ..Default::default()
1273        };
1274        let other_tag = RequestContext {
1275            existing_object_tags: Some(
1276                crate::tagging::TagSet::from_pairs(vec![("Project".into(), "Other".into())])
1277                    .unwrap(),
1278            ),
1279            ..Default::default()
1280        };
1281        // Tag matches → Allow.
1282        assert!(
1283            pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &with_tag)
1284                .allow
1285        );
1286        // Tag value mismatched → implicit deny.
1287        assert!(
1288            !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &other_tag)
1289                .allow
1290        );
1291    }
1292
1293    #[test]
1294    fn condition_request_object_tag_matches_via_x_amz_tagging() {
1295        let pol = p(r#"{
1296            "Statement": [{
1297              "Effect": "Allow", "Action": "s3:PutObject",
1298              "Resource": "arn:aws:s3:::b/*",
1299              "Condition": {
1300                "StringEquals": {"s3:RequestObjectTag/Env": ["prod", "staging"]}
1301              }
1302            }]
1303        }"#);
1304        let req_tags = |v: &str| RequestContext {
1305            request_object_tags: Some(
1306                crate::tagging::TagSet::from_pairs(vec![("Env".into(), v.into())]).unwrap(),
1307            ),
1308            ..Default::default()
1309        };
1310        assert!(
1311            pol.evaluate_with("s3:PutObject", "b", Some("k"), None, &req_tags("prod"))
1312                .allow
1313        );
1314        assert!(
1315            pol.evaluate_with("s3:PutObject", "b", Some("k"), None, &req_tags("staging"))
1316                .allow
1317        );
1318        assert!(
1319            !pol.evaluate_with("s3:PutObject", "b", Some("k"), None, &req_tags("dev"))
1320                .allow
1321        );
1322    }
1323
1324    #[test]
1325    fn condition_tag_not_present_fails_closed() {
1326        // Statement gates on a tag the request doesn't carry → the
1327        // clause must fail (not silently match), so the only Allow is
1328        // skipped and we get implicit deny.
1329        let pol = p(r#"{
1330            "Statement": [{
1331              "Effect": "Allow", "Action": "s3:GetObject",
1332              "Resource": "arn:aws:s3:::b/*",
1333              "Condition": {
1334                "StringEquals": {"s3:ExistingObjectTag/Owner": ["alice"]}
1335              }
1336            }]
1337        }"#);
1338        // No `existing_object_tags` at all → tag look-up returns None
1339        // → clause fails → statement skipped.
1340        let none_ctx = RequestContext::default();
1341        assert!(
1342            !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &none_ctx)
1343                .allow
1344        );
1345        // Tag set exists but lacks the named key → also fails.
1346        let other_only = RequestContext {
1347            existing_object_tags: Some(
1348                crate::tagging::TagSet::from_pairs(vec![("Project".into(), "X".into())]).unwrap(),
1349            ),
1350            ..Default::default()
1351        };
1352        assert!(
1353            !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &other_only)
1354                .allow
1355        );
1356    }
1357
1358    #[test]
1359    fn condition_legacy_evaluate_unchanged() {
1360        // Old `evaluate` (no context) still works: a policy without
1361        // Condition clauses is unaffected by the v0.3 changes.
1362        let pol = p(r#"{
1363            "Statement": [{"Effect": "Allow", "Action": "s3:*",
1364              "Resource": "arn:aws:s3:::b/*"}]
1365        }"#);
1366        assert!(pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
1367    }
1368
1369    // ===== v0.8.4 #75 (audit H-4 + H-5) =====
1370
1371    #[test]
1372    fn parse_resource_arn_bucket_form() {
1373        let arn = parse_resource_arn("arn:aws:s3:::mybucket").expect("parse");
1374        assert_eq!(arn, ResourceArn::Bucket("mybucket".into()));
1375    }
1376
1377    #[test]
1378    fn parse_resource_arn_object_form() {
1379        let arn = parse_resource_arn("arn:aws:s3:::mybucket/some/key").expect("parse");
1380        assert_eq!(
1381            arn,
1382            ResourceArn::Object {
1383                bucket: "mybucket".into(),
1384                key_pattern: "some/key".into(),
1385            }
1386        );
1387    }
1388
1389    #[test]
1390    fn parse_resource_arn_object_wildcard() {
1391        let arn = parse_resource_arn("arn:aws:s3:::mybucket/*").expect("parse");
1392        assert_eq!(
1393            arn,
1394            ResourceArn::Object {
1395                bucket: "mybucket".into(),
1396                key_pattern: "*".into(),
1397            }
1398        );
1399        // Trailing-glob in key portion stays in `key_pattern` verbatim.
1400        let pre = parse_resource_arn("arn:aws:s3:::b/data/*.parquet").expect("parse");
1401        assert_eq!(
1402            pre,
1403            ResourceArn::Object {
1404                bucket: "b".into(),
1405                key_pattern: "data/*.parquet".into(),
1406            }
1407        );
1408        // Bad ARN: missing prefix.
1409        assert!(matches!(
1410            parse_resource_arn("not-an-arn"),
1411            Err(PolicyParseError::InvalidResourceArn(_))
1412        ));
1413        // Bad ARN: empty bucket.
1414        assert!(matches!(
1415            parse_resource_arn("arn:aws:s3:::"),
1416            Err(PolicyParseError::EmptyBucketInArn(_))
1417        ));
1418        assert!(matches!(
1419            parse_resource_arn("arn:aws:s3:::/key"),
1420            Err(PolicyParseError::EmptyBucketInArn(_))
1421        ));
1422    }
1423
1424    #[test]
1425    fn bucket_only_arn_does_not_grant_object_action() {
1426        // Audit H-4: a bare `arn:aws:s3:::b` Resource MUST NOT grant
1427        // `s3:GetObject` (or any other object-level action). Pre-v0.8.4
1428        // it did, silently widening privilege.
1429        let pol = p(r#"{
1430            "Statement": [{
1431              "Effect": "Allow",
1432              "Principal": "*",
1433              "Action": "s3:GetObject",
1434              "Resource": "arn:aws:s3:::mybucket"
1435            }]
1436        }"#);
1437        let d = pol.evaluate("s3:GetObject", "mybucket", Some("k"), None);
1438        assert!(!d.allow, "bucket-form ARN must not grant s3:GetObject");
1439        assert_eq!(d.matched_effect, None, "should be implicit deny");
1440        // Sanity: an object-form ARN against the same bucket DOES grant.
1441        let pol_ok = p(r#"{
1442            "Statement": [{
1443              "Effect": "Allow",
1444              "Principal": "*",
1445              "Action": "s3:GetObject",
1446              "Resource": "arn:aws:s3:::mybucket/*"
1447            }]
1448        }"#);
1449        assert!(
1450            pol_ok
1451                .evaluate("s3:GetObject", "mybucket", Some("k"), None)
1452                .allow
1453        );
1454    }
1455
1456    #[test]
1457    fn object_arn_does_not_grant_bucket_action() {
1458        // Audit H-4 (other direction): an `arn:aws:s3:::b/k` Resource
1459        // MUST NOT grant `s3:ListBucket` (a bucket-level action).
1460        let pol = p(r#"{
1461            "Statement": [{
1462              "Effect": "Allow",
1463              "Principal": "*",
1464              "Action": "s3:ListBucket",
1465              "Resource": "arn:aws:s3:::b/k"
1466            }]
1467        }"#);
1468        let d = pol.evaluate("s3:ListBucket", "b", None, None);
1469        assert!(!d.allow, "object-form ARN must not grant s3:ListBucket");
1470        assert_eq!(d.matched_effect, None);
1471    }
1472
1473    #[test]
1474    fn principal_wildcard_only_accepts_literal_star() {
1475        // Audit H-5: a bare `"Principal": "AKIA..."` must be rejected
1476        // at parse time — pre-v0.8.4 it deserialized to
1477        // `PrincipalSet::Wildcard("AKIA...")` and silently matched any
1478        // caller (incl. anonymous).
1479        let err = Policy::from_json_str_typed(
1480            r#"{"Statement": [{
1481              "Effect": "Allow", "Action": "s3:GetObject",
1482              "Resource": "arn:aws:s3:::b/*",
1483              "Principal": "AKIATESTNOTAWILDCARD"
1484            }]}"#,
1485        )
1486        .expect_err("non-* string principal must be rejected");
1487        assert!(
1488            matches!(err, PolicyParseError::InvalidWildcard(ref s) if s == "AKIATESTNOTAWILDCARD"),
1489            "expected InvalidWildcard, got {err:?}"
1490        );
1491        // The literal "*" still parses fine.
1492        let ok = PrincipalSet::parse(&serde_json::Value::String("*".into())).expect("ok");
1493        assert_eq!(ok, PrincipalSet::Wildcard);
1494    }
1495
1496    #[test]
1497    fn principal_unsupported_service_type_rejected() {
1498        // Audit H-5: `{"Service": "..."}` (and other non-AWS principal
1499        // types) must be rejected at parse time so a policy author
1500        // can't think they granted access to a Lambda role when in
1501        // fact the field was silently dropped to "match anyone".
1502        let err = Policy::from_json_str_typed(
1503            r#"{"Statement": [{
1504              "Effect": "Allow", "Action": "s3:GetObject",
1505              "Resource": "arn:aws:s3:::b/*",
1506              "Principal": {"Service": "lambda.amazonaws.com"}
1507            }]}"#,
1508        )
1509        .expect_err("Service principal must be rejected");
1510        assert!(
1511            matches!(err, PolicyParseError::UnsupportedPrincipalType),
1512            "expected UnsupportedPrincipalType, got {err:?}"
1513        );
1514        // Federated / CanonicalUser also rejected.
1515        for shape in [
1516            r#"{"Federated": "cognito-identity.amazonaws.com"}"#,
1517            r#"{"CanonicalUser": "abcdef"}"#,
1518            r#"{"AWS": "AKIA", "Service": "x"}"#,
1519        ] {
1520            let v: serde_json::Value = serde_json::from_str(shape).unwrap();
1521            assert!(
1522                matches!(
1523                    PrincipalSet::parse(&v),
1524                    Err(PolicyParseError::UnsupportedPrincipalType)
1525                ),
1526                "expected UnsupportedPrincipalType for {shape}"
1527            );
1528        }
1529    }
1530
1531    #[test]
1532    fn principal_empty_aws_list_rejected() {
1533        // Audit H-5: `{"AWS": []}` must be rejected — pre-v0.8.4 the
1534        // empty list flowed through `principals: Some(vec![])` which
1535        // the matcher treated as "any caller", silently widening.
1536        let err = Policy::from_json_str_typed(
1537            r#"{"Statement": [{
1538              "Effect": "Allow", "Action": "s3:GetObject",
1539              "Resource": "arn:aws:s3:::b/*",
1540              "Principal": {"AWS": []}
1541            }]}"#,
1542        )
1543        .expect_err("empty AWS principal list must be rejected");
1544        assert!(
1545            matches!(err, PolicyParseError::EmptyPrincipalList),
1546            "expected EmptyPrincipalList, got {err:?}"
1547        );
1548        // Sanity: a single-element list parses to Specific.
1549        let v: serde_json::Value = serde_json::from_str(r#"{"AWS": "AKIAONE"}"#).unwrap();
1550        assert_eq!(
1551            PrincipalSet::parse(&v).unwrap(),
1552            PrincipalSet::Specific(vec!["AKIAONE".into()])
1553        );
1554        // Anonymous caller against a Specific list → no match.
1555        let pol = p(r#"{"Statement": [{
1556            "Effect": "Allow", "Action": "s3:GetObject",
1557            "Resource": "arn:aws:s3:::b/*",
1558            "Principal": {"AWS": ["AKIAONE"]}
1559        }]}"#);
1560        assert!(!pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
1561    }
1562}