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