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.
869fn parse_iso8601(s: &str) -> Option<i64> {
870    // Accept `YYYY-MM-DDTHH:MM:SSZ` only; reject anything else.
871    let s = s.strip_suffix('Z')?;
872    let (date, time) = s.split_once('T')?;
873    let date_parts: Vec<&str> = date.split('-').collect();
874    if date_parts.len() != 3 {
875        return None;
876    }
877    let year: i64 = date_parts[0].parse().ok()?;
878    let month: i64 = date_parts[1].parse().ok()?;
879    let day: i64 = date_parts[2].parse().ok()?;
880    let time_parts: Vec<&str> = time.split(':').collect();
881    if time_parts.len() != 3 {
882        return None;
883    }
884    let h: i64 = time_parts[0].parse().ok()?;
885    let m: i64 = time_parts[1].parse().ok()?;
886    let s: i64 = time_parts[2].parse().ok()?;
887    // Days from 1970-01-01 via a quick civil-from-date algorithm
888    // (Howard Hinnant — public domain). Good for AD years.
889    let y = if month <= 2 { year - 1 } else { year };
890    let era = if y >= 0 { y } else { y - 399 } / 400;
891    let yoe = (y - era * 400) as u64;
892    let mp = if month > 2 { month - 3 } else { month + 9 };
893    let doy = (153 * mp + 2) / 5 + day - 1;
894    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy as u64;
895    let days_from_epoch = era * 146097 + doe as i64 - 719468;
896    Some(days_from_epoch * 86_400 + h * 3600 + m * 60 + s)
897}
898
899/// Wrap a Policy in an Arc so cloning the S4Service stays cheap.
900pub type SharedPolicy = Arc<Policy>;
901
902#[cfg(test)]
903mod tests {
904    use super::*;
905
906    fn p(s: &str) -> Policy {
907        Policy::from_json_str(s).expect("policy")
908    }
909
910    #[test]
911    fn allow_then_deny_explicit_deny_wins() {
912        let pol = p(r#"{
913            "Version": "2012-10-17",
914            "Statement": [
915              {"Sid": "AllowAll", "Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::b/*"},
916              {"Sid": "DenyDelete", "Effect": "Deny", "Action": "s3:DeleteObject", "Resource": "arn:aws:s3:::b/*"}
917            ]
918        }"#);
919        let d = pol.evaluate("s3:GetObject", "b", Some("k"), None);
920        assert!(d.allow);
921        assert_eq!(d.matched_sid.as_deref(), Some("AllowAll"));
922        let d = pol.evaluate("s3:DeleteObject", "b", Some("k"), None);
923        assert!(!d.allow);
924        assert_eq!(d.matched_effect, Some(Effect::Deny));
925        assert_eq!(d.matched_sid.as_deref(), Some("DenyDelete"));
926    }
927
928    #[test]
929    fn implicit_deny_when_no_statement_matches() {
930        let pol = p(r#"{
931            "Version": "2012-10-17",
932            "Statement": [
933              {"Effect": "Allow", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::other/*"}
934            ]
935        }"#);
936        let d = pol.evaluate("s3:GetObject", "mine", Some("k"), None);
937        assert!(!d.allow);
938        assert_eq!(d.matched_effect, None);
939    }
940
941    #[test]
942    fn resource_glob_matches_prefix() {
943        let pol = p(r#"{
944            "Version": "2012-10-17",
945            "Statement": [{
946              "Effect": "Allow",
947              "Action": "s3:GetObject",
948              "Resource": "arn:aws:s3:::b/data/*.parquet"
949            }]
950        }"#);
951        assert!(
952            pol.evaluate("s3:GetObject", "b", Some("data/foo.parquet"), None)
953                .allow
954        );
955        assert!(
956            pol.evaluate("s3:GetObject", "b", Some("data/sub/bar.parquet"), None)
957                .allow
958        );
959        assert!(
960            !pol.evaluate("s3:GetObject", "b", Some("data/foo.txt"), None)
961                .allow
962        );
963    }
964
965    #[test]
966    fn s3_action_wildcard() {
967        // v0.8.4 #75: a bare `arn:aws:s3:::*` is bucket-form and only
968        // covers bucket-level actions; object reach requires the
969        // explicit `/*` glob in the key portion. Use a two-statement
970        // policy that grants both ARN namespaces.
971        let pol = p(r#"{
972            "Version": "2012-10-17",
973            "Statement": [
974              {"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::*"},
975              {"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::*/*"}
976            ]
977        }"#);
978        assert!(pol.evaluate("s3:GetObject", "any", Some("k"), None).allow);
979        assert!(pol.evaluate("s3:PutObject", "any", Some("k"), None).allow);
980        assert!(pol.evaluate("s3:ListBucket", "any", None, None).allow);
981        // Non-s3 action would not match (we don't generate any non-s3 actions
982        // from S4Service handlers, but verify the matcher behaves correctly)
983        assert!(!pol.evaluate("iam:ListUsers", "any", None, None).allow);
984    }
985
986    #[test]
987    fn principal_match_by_access_key_id() {
988        let pol = p(r#"{
989            "Version": "2012-10-17",
990            "Statement": [{
991              "Effect": "Allow",
992              "Action": "s3:*",
993              "Resource": "arn:aws:s3:::b/*",
994              "Principal": {"AWS": ["AKIATEST123"]}
995            }]
996        }"#);
997        assert!(
998            pol.evaluate("s3:GetObject", "b", Some("k"), Some("AKIATEST123"))
999                .allow
1000        );
1001        assert!(
1002            !pol.evaluate("s3:GetObject", "b", Some("k"), Some("AKIAOTHER"))
1003                .allow
1004        );
1005        assert!(!pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
1006    }
1007
1008    #[test]
1009    fn principal_wildcard_matches_anyone() {
1010        let pol = p(r#"{
1011            "Version": "2012-10-17",
1012            "Statement": [{
1013              "Effect": "Allow",
1014              "Action": "s3:*",
1015              "Resource": "arn:aws:s3:::b/*",
1016              "Principal": "*"
1017            }]
1018        }"#);
1019        assert!(
1020            pol.evaluate("s3:GetObject", "b", Some("k"), Some("AKIAANY"))
1021                .allow
1022        );
1023        assert!(pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
1024    }
1025
1026    #[test]
1027    fn resource_can_be_string_or_array() {
1028        let single = p(r#"{
1029            "Statement": [{"Effect": "Allow", "Action": "s3:GetObject",
1030                          "Resource": "arn:aws:s3:::a/*"}]
1031        }"#);
1032        let multi = p(r#"{
1033            "Statement": [{"Effect": "Allow", "Action": "s3:GetObject",
1034                          "Resource": ["arn:aws:s3:::a/*", "arn:aws:s3:::b/*"]}]
1035        }"#);
1036        assert!(single.evaluate("s3:GetObject", "a", Some("k"), None).allow);
1037        assert!(!single.evaluate("s3:GetObject", "b", Some("k"), None).allow);
1038        assert!(multi.evaluate("s3:GetObject", "b", Some("k"), None).allow);
1039    }
1040
1041    #[test]
1042    fn bucket_level_resource_for_listbucket() {
1043        let pol = p(r#"{
1044            "Statement": [{"Effect": "Allow", "Action": "s3:ListBucket",
1045                          "Resource": "arn:aws:s3:::b"}]
1046        }"#);
1047        // ListBucket uses a key=None resource, formatted as bucket-only ARN
1048        assert!(pol.evaluate("s3:ListBucket", "b", None, None).allow);
1049        assert!(!pol.evaluate("s3:ListBucket", "other", None, None).allow);
1050    }
1051
1052    #[test]
1053    fn glob_match_basics() {
1054        assert!(glob_match("foo", "foo"));
1055        assert!(!glob_match("foo", "bar"));
1056        assert!(glob_match("*", "anything"));
1057        assert!(glob_match("foo*", "foobar"));
1058        assert!(glob_match("*bar", "foobar"));
1059        assert!(glob_match("foo*bar", "fooXYZbar"));
1060        assert!(glob_match("a?c", "abc"));
1061        assert!(!glob_match("a?c", "abbc"));
1062        assert!(glob_match("a*b*c", "axxxbyyyc"));
1063    }
1064
1065    // ===== v0.3 #13 IAM Condition tests =====
1066
1067    fn ctx_ip(ip: &str) -> RequestContext {
1068        RequestContext {
1069            source_ip: Some(ip.parse().unwrap()),
1070            ..Default::default()
1071        }
1072    }
1073
1074    #[test]
1075    fn condition_ip_address_cidr_match() {
1076        let pol = p(r#"{
1077            "Statement": [{
1078              "Effect": "Allow", "Action": "s3:GetObject",
1079              "Resource": "arn:aws:s3:::b/*",
1080              "Condition": {"IpAddress": {"aws:SourceIp": ["10.0.0.0/8", "192.168.1.0/24"]}}
1081            }]
1082        }"#);
1083        assert!(
1084            pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_ip("10.5.6.7"))
1085                .allow
1086        );
1087        assert!(
1088            pol.evaluate_with(
1089                "s3:GetObject",
1090                "b",
1091                Some("k"),
1092                None,
1093                &ctx_ip("192.168.1.50")
1094            )
1095            .allow
1096        );
1097        assert!(
1098            !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_ip("203.0.113.1"))
1099                .allow
1100        );
1101        // No source IP in context → condition fails → statement skipped
1102        assert!(
1103            !pol.evaluate_with(
1104                "s3:GetObject",
1105                "b",
1106                Some("k"),
1107                None,
1108                &RequestContext::default()
1109            )
1110            .allow
1111        );
1112    }
1113
1114    #[test]
1115    fn condition_not_ip_address_negates() {
1116        let pol = p(r#"{
1117            "Statement": [{
1118              "Effect": "Deny", "Action": "s3:DeleteObject",
1119              "Resource": "arn:aws:s3:::b/*",
1120              "Condition": {"NotIpAddress": {"aws:SourceIp": ["10.0.0.0/8"]}}
1121            },
1122            {"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::b/*"}]
1123        }"#);
1124        // Outside the trusted CIDR → Deny applies (NotIpAddress = true) → AccessDenied
1125        assert!(
1126            !pol.evaluate_with(
1127                "s3:DeleteObject",
1128                "b",
1129                Some("k"),
1130                None,
1131                &ctx_ip("203.0.113.1")
1132            )
1133            .allow
1134        );
1135        // Inside the trusted CIDR → Deny condition fails → Allow remains
1136        assert!(
1137            pol.evaluate_with("s3:DeleteObject", "b", Some("k"), None, &ctx_ip("10.0.0.7"))
1138                .allow
1139        );
1140    }
1141
1142    #[test]
1143    fn condition_string_equals_user_agent() {
1144        let pol = p(r#"{
1145            "Statement": [{
1146              "Effect": "Allow", "Action": "s3:GetObject",
1147              "Resource": "arn:aws:s3:::b/*",
1148              "Condition": {"StringEquals": {"aws:UserAgent": ["MyApp/1.0", "MyApp/2.0"]}}
1149            }]
1150        }"#);
1151        let ua = |s: &str| RequestContext {
1152            user_agent: Some(s.into()),
1153            ..Default::default()
1154        };
1155        assert!(
1156            pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("MyApp/1.0"))
1157                .allow
1158        );
1159        assert!(
1160            !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("OtherApp/1.0"))
1161                .allow
1162        );
1163    }
1164
1165    #[test]
1166    fn condition_string_like_glob() {
1167        let pol = p(r#"{
1168            "Statement": [{
1169              "Effect": "Allow", "Action": "s3:GetObject",
1170              "Resource": "arn:aws:s3:::b/*",
1171              "Condition": {"StringLike": {"aws:UserAgent": ["MyApp/*", "boto3/*"]}}
1172            }]
1173        }"#);
1174        let ua = |s: &str| RequestContext {
1175            user_agent: Some(s.into()),
1176            ..Default::default()
1177        };
1178        assert!(
1179            pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("MyApp/3.14"))
1180                .allow
1181        );
1182        assert!(
1183            pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("boto3/1.34.5"))
1184                .allow
1185        );
1186        assert!(
1187            !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("curl/8"))
1188                .allow
1189        );
1190    }
1191
1192    #[test]
1193    fn condition_date_window() {
1194        // Allow only requests between two dates.
1195        let pol = p(r#"{
1196            "Statement": [{
1197              "Effect": "Allow", "Action": "s3:GetObject",
1198              "Resource": "arn:aws:s3:::b/*",
1199              "Condition": {
1200                "DateGreaterThan": {"aws:CurrentTime": ["2026-01-01T00:00:00Z"]},
1201                "DateLessThan":    {"aws:CurrentTime": ["2026-12-31T23:59:59Z"]}
1202              }
1203            }]
1204        }"#);
1205        let mid_year = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_780_000_000); // ~mid-2026
1206        let after = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_800_000_000); // ~early-2027
1207        let ctx_at = |t: SystemTime| RequestContext {
1208            request_time: Some(t),
1209            ..Default::default()
1210        };
1211        assert!(
1212            pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_at(mid_year))
1213                .allow
1214        );
1215        assert!(
1216            !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_at(after))
1217                .allow
1218        );
1219    }
1220
1221    #[test]
1222    fn condition_bool_secure_transport() {
1223        let pol = p(r#"{
1224            "Statement": [{
1225              "Effect": "Deny", "Action": "s3:*",
1226              "Resource": "arn:aws:s3:::b/*",
1227              "Condition": {"Bool": {"aws:SecureTransport": ["false"]}}
1228            },
1229            {"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::b/*"}]
1230        }"#);
1231        let plain = RequestContext {
1232            secure_transport: false,
1233            ..Default::default()
1234        };
1235        let tls = RequestContext {
1236            secure_transport: true,
1237            ..Default::default()
1238        };
1239        // Plain HTTP → SecureTransport=false → Deny matches
1240        assert!(
1241            !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &plain)
1242                .allow
1243        );
1244        // TLS → SecureTransport=true → Deny condition fails → Allow remains
1245        assert!(
1246            pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &tls)
1247                .allow
1248        );
1249    }
1250
1251    #[test]
1252    fn condition_unknown_operator_rejected() {
1253        let err = Policy::from_json_str(
1254            r#"{
1255            "Statement": [{"Effect": "Allow", "Action": "s3:*",
1256              "Resource": "arn:aws:s3:::b/*",
1257              "Condition": {"NumericGreaterThan": {"k": ["1"]}}
1258            }]
1259        }"#,
1260        )
1261        .expect_err("should reject unsupported operator");
1262        assert!(err.contains("unsupported policy Condition operator"));
1263        assert!(err.contains("NumericGreaterThan"));
1264    }
1265
1266    // ===== v0.6 #39 tag-based condition tests =====
1267
1268    #[test]
1269    fn condition_existing_object_tag_matches_via_tagmanager_state() {
1270        let pol = p(r#"{
1271            "Statement": [{
1272              "Effect": "Allow", "Action": "s3:GetObject",
1273              "Resource": "arn:aws:s3:::b/*",
1274              "Condition": {
1275                "StringEquals": {"s3:ExistingObjectTag/Project": ["Phoenix"]}
1276              }
1277            }]
1278        }"#);
1279        let with_tag = RequestContext {
1280            existing_object_tags: Some(
1281                crate::tagging::TagSet::from_pairs(vec![
1282                    ("Project".into(), "Phoenix".into()),
1283                    ("Env".into(), "prod".into()),
1284                ])
1285                .unwrap(),
1286            ),
1287            ..Default::default()
1288        };
1289        let other_tag = RequestContext {
1290            existing_object_tags: Some(
1291                crate::tagging::TagSet::from_pairs(vec![("Project".into(), "Other".into())])
1292                    .unwrap(),
1293            ),
1294            ..Default::default()
1295        };
1296        // Tag matches → Allow.
1297        assert!(
1298            pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &with_tag)
1299                .allow
1300        );
1301        // Tag value mismatched → implicit deny.
1302        assert!(
1303            !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &other_tag)
1304                .allow
1305        );
1306    }
1307
1308    #[test]
1309    fn condition_request_object_tag_matches_via_x_amz_tagging() {
1310        let pol = p(r#"{
1311            "Statement": [{
1312              "Effect": "Allow", "Action": "s3:PutObject",
1313              "Resource": "arn:aws:s3:::b/*",
1314              "Condition": {
1315                "StringEquals": {"s3:RequestObjectTag/Env": ["prod", "staging"]}
1316              }
1317            }]
1318        }"#);
1319        let req_tags = |v: &str| RequestContext {
1320            request_object_tags: Some(
1321                crate::tagging::TagSet::from_pairs(vec![("Env".into(), v.into())]).unwrap(),
1322            ),
1323            ..Default::default()
1324        };
1325        assert!(
1326            pol.evaluate_with("s3:PutObject", "b", Some("k"), None, &req_tags("prod"))
1327                .allow
1328        );
1329        assert!(
1330            pol.evaluate_with("s3:PutObject", "b", Some("k"), None, &req_tags("staging"))
1331                .allow
1332        );
1333        assert!(
1334            !pol.evaluate_with("s3:PutObject", "b", Some("k"), None, &req_tags("dev"))
1335                .allow
1336        );
1337    }
1338
1339    #[test]
1340    fn condition_tag_not_present_fails_closed() {
1341        // Statement gates on a tag the request doesn't carry → the
1342        // clause must fail (not silently match), so the only Allow is
1343        // skipped and we get implicit deny.
1344        let pol = p(r#"{
1345            "Statement": [{
1346              "Effect": "Allow", "Action": "s3:GetObject",
1347              "Resource": "arn:aws:s3:::b/*",
1348              "Condition": {
1349                "StringEquals": {"s3:ExistingObjectTag/Owner": ["alice"]}
1350              }
1351            }]
1352        }"#);
1353        // No `existing_object_tags` at all → tag look-up returns None
1354        // → clause fails → statement skipped.
1355        let none_ctx = RequestContext::default();
1356        assert!(
1357            !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &none_ctx)
1358                .allow
1359        );
1360        // Tag set exists but lacks the named key → also fails.
1361        let other_only = RequestContext {
1362            existing_object_tags: Some(
1363                crate::tagging::TagSet::from_pairs(vec![("Project".into(), "X".into())]).unwrap(),
1364            ),
1365            ..Default::default()
1366        };
1367        assert!(
1368            !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &other_only)
1369                .allow
1370        );
1371    }
1372
1373    #[test]
1374    fn condition_legacy_evaluate_unchanged() {
1375        // Old `evaluate` (no context) still works: a policy without
1376        // Condition clauses is unaffected by the v0.3 changes.
1377        let pol = p(r#"{
1378            "Statement": [{"Effect": "Allow", "Action": "s3:*",
1379              "Resource": "arn:aws:s3:::b/*"}]
1380        }"#);
1381        assert!(pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
1382    }
1383
1384    // ===== v0.8.4 #75 (audit H-4 + H-5) =====
1385
1386    #[test]
1387    fn parse_resource_arn_bucket_form() {
1388        let arn = parse_resource_arn("arn:aws:s3:::mybucket").expect("parse");
1389        assert_eq!(arn, ResourceArn::Bucket("mybucket".into()));
1390    }
1391
1392    #[test]
1393    fn parse_resource_arn_object_form() {
1394        let arn = parse_resource_arn("arn:aws:s3:::mybucket/some/key").expect("parse");
1395        assert_eq!(
1396            arn,
1397            ResourceArn::Object {
1398                bucket: "mybucket".into(),
1399                key_pattern: "some/key".into(),
1400            }
1401        );
1402    }
1403
1404    #[test]
1405    fn parse_resource_arn_object_wildcard() {
1406        let arn = parse_resource_arn("arn:aws:s3:::mybucket/*").expect("parse");
1407        assert_eq!(
1408            arn,
1409            ResourceArn::Object {
1410                bucket: "mybucket".into(),
1411                key_pattern: "*".into(),
1412            }
1413        );
1414        // Trailing-glob in key portion stays in `key_pattern` verbatim.
1415        let pre = parse_resource_arn("arn:aws:s3:::b/data/*.parquet").expect("parse");
1416        assert_eq!(
1417            pre,
1418            ResourceArn::Object {
1419                bucket: "b".into(),
1420                key_pattern: "data/*.parquet".into(),
1421            }
1422        );
1423        // Bad ARN: missing prefix.
1424        assert!(matches!(
1425            parse_resource_arn("not-an-arn"),
1426            Err(PolicyParseError::InvalidResourceArn(_))
1427        ));
1428        // Bad ARN: empty bucket.
1429        assert!(matches!(
1430            parse_resource_arn("arn:aws:s3:::"),
1431            Err(PolicyParseError::EmptyBucketInArn(_))
1432        ));
1433        assert!(matches!(
1434            parse_resource_arn("arn:aws:s3:::/key"),
1435            Err(PolicyParseError::EmptyBucketInArn(_))
1436        ));
1437    }
1438
1439    #[test]
1440    fn bucket_only_arn_does_not_grant_object_action() {
1441        // Audit H-4: a bare `arn:aws:s3:::b` Resource MUST NOT grant
1442        // `s3:GetObject` (or any other object-level action). Pre-v0.8.4
1443        // it did, silently widening privilege.
1444        let pol = p(r#"{
1445            "Statement": [{
1446              "Effect": "Allow",
1447              "Principal": "*",
1448              "Action": "s3:GetObject",
1449              "Resource": "arn:aws:s3:::mybucket"
1450            }]
1451        }"#);
1452        let d = pol.evaluate("s3:GetObject", "mybucket", Some("k"), None);
1453        assert!(!d.allow, "bucket-form ARN must not grant s3:GetObject");
1454        assert_eq!(d.matched_effect, None, "should be implicit deny");
1455        // Sanity: an object-form ARN against the same bucket DOES grant.
1456        let pol_ok = p(r#"{
1457            "Statement": [{
1458              "Effect": "Allow",
1459              "Principal": "*",
1460              "Action": "s3:GetObject",
1461              "Resource": "arn:aws:s3:::mybucket/*"
1462            }]
1463        }"#);
1464        assert!(
1465            pol_ok
1466                .evaluate("s3:GetObject", "mybucket", Some("k"), None)
1467                .allow
1468        );
1469    }
1470
1471    #[test]
1472    fn object_arn_does_not_grant_bucket_action() {
1473        // Audit H-4 (other direction): an `arn:aws:s3:::b/k` Resource
1474        // MUST NOT grant `s3:ListBucket` (a bucket-level action).
1475        let pol = p(r#"{
1476            "Statement": [{
1477              "Effect": "Allow",
1478              "Principal": "*",
1479              "Action": "s3:ListBucket",
1480              "Resource": "arn:aws:s3:::b/k"
1481            }]
1482        }"#);
1483        let d = pol.evaluate("s3:ListBucket", "b", None, None);
1484        assert!(!d.allow, "object-form ARN must not grant s3:ListBucket");
1485        assert_eq!(d.matched_effect, None);
1486    }
1487
1488    #[test]
1489    fn principal_wildcard_only_accepts_literal_star() {
1490        // Audit H-5: a bare `"Principal": "AKIA..."` must be rejected
1491        // at parse time — pre-v0.8.4 it deserialized to
1492        // `PrincipalSet::Wildcard("AKIA...")` and silently matched any
1493        // caller (incl. anonymous).
1494        let err = Policy::from_json_str_typed(
1495            r#"{"Statement": [{
1496              "Effect": "Allow", "Action": "s3:GetObject",
1497              "Resource": "arn:aws:s3:::b/*",
1498              "Principal": "AKIATESTNOTAWILDCARD"
1499            }]}"#,
1500        )
1501        .expect_err("non-* string principal must be rejected");
1502        assert!(
1503            matches!(err, PolicyParseError::InvalidWildcard(ref s) if s == "AKIATESTNOTAWILDCARD"),
1504            "expected InvalidWildcard, got {err:?}"
1505        );
1506        // The literal "*" still parses fine.
1507        let ok = PrincipalSet::parse(&serde_json::Value::String("*".into())).expect("ok");
1508        assert_eq!(ok, PrincipalSet::Wildcard);
1509    }
1510
1511    #[test]
1512    fn principal_unsupported_service_type_rejected() {
1513        // Audit H-5: `{"Service": "..."}` (and other non-AWS principal
1514        // types) must be rejected at parse time so a policy author
1515        // can't think they granted access to a Lambda role when in
1516        // fact the field was silently dropped to "match anyone".
1517        let err = Policy::from_json_str_typed(
1518            r#"{"Statement": [{
1519              "Effect": "Allow", "Action": "s3:GetObject",
1520              "Resource": "arn:aws:s3:::b/*",
1521              "Principal": {"Service": "lambda.amazonaws.com"}
1522            }]}"#,
1523        )
1524        .expect_err("Service principal must be rejected");
1525        assert!(
1526            matches!(err, PolicyParseError::UnsupportedPrincipalType),
1527            "expected UnsupportedPrincipalType, got {err:?}"
1528        );
1529        // Federated / CanonicalUser also rejected.
1530        for shape in [
1531            r#"{"Federated": "cognito-identity.amazonaws.com"}"#,
1532            r#"{"CanonicalUser": "abcdef"}"#,
1533            r#"{"AWS": "AKIA", "Service": "x"}"#,
1534        ] {
1535            let v: serde_json::Value = serde_json::from_str(shape).unwrap();
1536            assert!(
1537                matches!(
1538                    PrincipalSet::parse(&v),
1539                    Err(PolicyParseError::UnsupportedPrincipalType)
1540                ),
1541                "expected UnsupportedPrincipalType for {shape}"
1542            );
1543        }
1544    }
1545
1546    #[test]
1547    fn principal_empty_aws_list_rejected() {
1548        // Audit H-5: `{"AWS": []}` must be rejected — pre-v0.8.4 the
1549        // empty list flowed through `principals: Some(vec![])` which
1550        // the matcher treated as "any caller", silently widening.
1551        let err = Policy::from_json_str_typed(
1552            r#"{"Statement": [{
1553              "Effect": "Allow", "Action": "s3:GetObject",
1554              "Resource": "arn:aws:s3:::b/*",
1555              "Principal": {"AWS": []}
1556            }]}"#,
1557        )
1558        .expect_err("empty AWS principal list must be rejected");
1559        assert!(
1560            matches!(err, PolicyParseError::EmptyPrincipalList),
1561            "expected EmptyPrincipalList, got {err:?}"
1562        );
1563        // Sanity: a single-element list parses to Specific.
1564        let v: serde_json::Value = serde_json::from_str(r#"{"AWS": "AKIAONE"}"#).unwrap();
1565        assert_eq!(
1566            PrincipalSet::parse(&v).unwrap(),
1567            PrincipalSet::Specific(vec!["AKIAONE".into()])
1568        );
1569        // Anonymous caller against a Specific list → no match.
1570        let pol = p(r#"{"Statement": [{
1571            "Effect": "Allow", "Action": "s3:GetObject",
1572            "Resource": "arn:aws:s3:::b/*",
1573            "Principal": {"AWS": ["AKIAONE"]}
1574        }]}"#);
1575        assert!(!pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
1576    }
1577}