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`
10//! - `s3:PutObject`
11//! - `s3:DeleteObject`
12//! - `s3:ListBucket`
13//! - `s3:*` (wildcard, matches all of the above)
14//! - `*` (wildcard, matches everything)
15//!
16//! Supported Resource patterns (case-sensitive):
17//! - `arn:aws:s3:::<bucket>` — bucket-level ops (ListBucket etc.)
18//! - `arn:aws:s3:::<bucket>/<key>` — object-level ops
19//! - Trailing or interior `*` glob in the key portion
20//! - `arn:aws:s3:::*` — any bucket / any key
21//!
22//! Supported Principal forms:
23//! - `"Principal": "*"` — anyone authenticated by S4's auth layer
24//! - `"Principal": {"AWS": ["AKIA...", "AKIA..."]}` — match by SigV4 access
25//!   key ID. (Full IAM user/role ARN matching is a future extension once
26//!   STS integration lands.)
27//!
28//! Decision: **explicit Deny > explicit Allow > implicit Deny** — the
29//! standard AWS evaluation order.
30
31use std::collections::HashMap;
32use std::net::IpAddr;
33use std::path::Path;
34use std::sync::Arc;
35use std::time::SystemTime;
36
37use serde::Deserialize;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
40#[serde(rename_all = "PascalCase")]
41pub enum Effect {
42    Allow,
43    Deny,
44}
45
46#[derive(Debug, Clone, Deserialize)]
47#[serde(untagged)]
48enum StringOrVec {
49    Single(String),
50    Many(Vec<String>),
51}
52
53impl StringOrVec {
54    fn into_vec(self) -> Vec<String> {
55        match self {
56            Self::Single(s) => vec![s],
57            Self::Many(v) => v,
58        }
59    }
60}
61
62#[derive(Debug, Clone, Deserialize)]
63#[serde(untagged)]
64enum PrincipalSet {
65    /// `"Principal": "*"` — JSON string form. The string content is
66    /// untyped (only the string variant matters), so we accept any but
67    /// don't read the value.
68    Wildcard(#[allow(dead_code)] String),
69    Map {
70        #[serde(rename = "AWS", default)]
71        aws: Option<StringOrVec>,
72    },
73}
74
75#[derive(Debug, Clone, Deserialize)]
76struct StatementJson {
77    #[serde(rename = "Sid")]
78    sid: Option<String>,
79    #[serde(rename = "Effect")]
80    effect: Effect,
81    #[serde(rename = "Action")]
82    action: StringOrVec,
83    #[serde(rename = "Resource")]
84    resource: StringOrVec,
85    #[serde(rename = "Principal", default)]
86    principal: Option<PrincipalSet>,
87    /// Optional Condition map (v0.3 #13): operator → key → values.
88    /// `{"IpAddress": {"aws:SourceIp": ["10.0.0.0/8"]}, ...}`.
89    #[serde(rename = "Condition", default)]
90    condition: Option<HashMap<String, HashMap<String, StringOrVec>>>,
91}
92
93#[derive(Debug, Clone, Deserialize)]
94struct PolicyJson {
95    #[serde(rename = "Version")]
96    _version: Option<String>,
97    #[serde(rename = "Statement")]
98    statements: Vec<StatementJson>,
99}
100
101/// Compiled bucket policy ready to evaluate requests.
102#[derive(Debug, Clone)]
103pub struct Policy {
104    statements: Vec<Statement>,
105}
106
107#[derive(Debug, Clone)]
108struct Statement {
109    sid: Option<String>,
110    effect: Effect,
111    actions: Vec<String>,   // `s3:GetObject`, `s3:*`, `*`
112    resources: Vec<String>, // `arn:aws:s3:::bucket/key*`
113    /// `None` = no Principal field = match anyone (for resource-attached
114    /// bucket policies the convention is to require Principal, but for our
115    /// gateway we treat absence as "any authenticated caller").
116    /// `Some(vec![])` after parsing wildcard "*" = same effect.
117    /// `Some(vec!["AKIA..."])` = match those access key ids.
118    /// An empty `principals` vector means "wildcard (any principal)".
119    principals: Option<Vec<String>>,
120    /// Compiled Condition clauses; empty vec = no condition restriction
121    /// (statement always matches once Action / Resource / Principal pass).
122    conditions: Vec<Condition>,
123}
124
125/// Per-request context fed into the policy evaluator. Caller is expected to
126/// fill what's available; missing fields make any Condition that depends on
127/// them fail (= statement skipped, never silently allowed).
128#[derive(Debug, Clone, Default)]
129pub struct RequestContext {
130    pub source_ip: Option<IpAddr>,
131    pub user_agent: Option<String>,
132    pub request_time: Option<SystemTime>,
133    pub secure_transport: bool,
134    /// Generic key → value map for any aws:* or s3:* context key not
135    /// covered by the typed fields above (keeps the door open for things
136    /// like `aws:RequestTag/*`, `s3:RequestObjectTag/*` later).
137    pub extra: HashMap<String, String>,
138}
139
140/// One compiled Condition clause inside a Statement.
141#[derive(Debug, Clone)]
142struct Condition {
143    op: ConditionOp,
144    key: String,         // e.g. `aws:SourceIp`, `aws:UserAgent`, `aws:CurrentTime`
145    values: Vec<String>, // operator-specific (CIDR, glob, ISO-8601 timestamp, "true" / "false", ...)
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149enum ConditionOp {
150    IpAddress,
151    NotIpAddress,
152    StringEquals,
153    StringNotEquals,
154    StringLike,
155    StringNotLike,
156    DateGreaterThan,
157    DateLessThan,
158    Bool,
159}
160
161impl ConditionOp {
162    fn parse(s: &str) -> Option<Self> {
163        Some(match s {
164            "IpAddress" => Self::IpAddress,
165            "NotIpAddress" => Self::NotIpAddress,
166            "StringEquals" => Self::StringEquals,
167            "StringNotEquals" => Self::StringNotEquals,
168            "StringLike" => Self::StringLike,
169            "StringNotLike" => Self::StringNotLike,
170            "DateGreaterThan" => Self::DateGreaterThan,
171            "DateLessThan" => Self::DateLessThan,
172            "Bool" => Self::Bool,
173            _ => return None,
174        })
175    }
176}
177
178impl Policy {
179    pub fn from_json_str(s: &str) -> Result<Self, String> {
180        let raw: PolicyJson =
181            serde_json::from_str(s).map_err(|e| format!("policy JSON parse error: {e}"))?;
182        let mut statements = Vec::with_capacity(raw.statements.len());
183        for s in raw.statements {
184            let mut conditions = Vec::new();
185            if let Some(cond_map) = s.condition {
186                for (op_name, key_map) in cond_map {
187                    let op = ConditionOp::parse(&op_name).ok_or_else(|| {
188                        format!(
189                            "unsupported policy Condition operator: {op_name:?}. \
190                             v0.3 supports IpAddress / NotIpAddress / StringEquals / \
191                             StringNotEquals / StringLike / StringNotLike / \
192                             DateGreaterThan / DateLessThan / Bool."
193                        )
194                    })?;
195                    for (key, values) in key_map {
196                        conditions.push(Condition {
197                            op,
198                            key,
199                            values: values.into_vec(),
200                        });
201                    }
202                }
203            }
204            statements.push(Statement {
205                sid: s.sid,
206                effect: s.effect,
207                actions: s.action.into_vec(),
208                resources: s.resource.into_vec(),
209                principals: s.principal.map(|p| match p {
210                    PrincipalSet::Wildcard(_) => Vec::new(),
211                    PrincipalSet::Map { aws } => aws.map(|v| v.into_vec()).unwrap_or_default(),
212                }),
213                conditions,
214            });
215        }
216        Ok(Self { statements })
217    }
218
219    pub fn from_path(path: &Path) -> Result<Self, String> {
220        let txt = std::fs::read_to_string(path)
221            .map_err(|e| format!("failed to read {}: {e}", path.display()))?;
222        Self::from_json_str(&txt)
223    }
224
225    /// Evaluate a request against the policy.
226    ///
227    /// `principal_id` is typically the SigV4 access key id taken from the
228    /// authenticated request. Pass `None` for anonymous (will only match
229    /// statements with wildcard or absent Principal).
230    ///
231    /// Convenience for the common case with no Condition data; calls the
232    /// full [`Policy::evaluate_with`] with a default `RequestContext`.
233    pub fn evaluate(
234        &self,
235        action: &str,
236        bucket: &str,
237        key: Option<&str>,
238        principal_id: Option<&str>,
239    ) -> Decision {
240        self.evaluate_with(
241            action,
242            bucket,
243            key,
244            principal_id,
245            &RequestContext::default(),
246        )
247    }
248
249    /// Same as [`Policy::evaluate`] but lets the caller plumb a populated
250    /// [`RequestContext`] for v0.3 #13 IAM Conditions (IP allowlists,
251    /// user-agent restrictions, time windows, etc.).
252    pub fn evaluate_with(
253        &self,
254        action: &str,
255        bucket: &str,
256        key: Option<&str>,
257        principal_id: Option<&str>,
258        ctx: &RequestContext,
259    ) -> Decision {
260        let object_resource = match key {
261            Some(k) => format!("arn:aws:s3:::{bucket}/{k}"),
262            None => format!("arn:aws:s3:::{bucket}"),
263        };
264        let bucket_resource = format!("arn:aws:s3:::{bucket}");
265
266        let mut matched_allow: Option<Option<String>> = None;
267        let mut matched_deny: Option<Option<String>> = None;
268
269        for st in &self.statements {
270            if !st.actions.iter().any(|p| action_matches(p, action)) {
271                continue;
272            }
273            let any_resource_matches = st.resources.iter().any(|p| {
274                resource_matches(p, &object_resource) || resource_matches(p, &bucket_resource)
275            });
276            if !any_resource_matches {
277                continue;
278            }
279            if !principal_matches(&st.principals, principal_id) {
280                continue;
281            }
282            // v0.3 #13: Conditions are ALL-AND — a statement applies only
283            // when every Condition clause matches the request context.
284            // A clause failing simply skips the statement (no error).
285            if !st.conditions.iter().all(|c| condition_matches(c, ctx)) {
286                continue;
287            }
288            match st.effect {
289                Effect::Deny => {
290                    matched_deny = Some(st.sid.clone());
291                    // Any explicit Deny wins; no need to keep scanning, but
292                    // continue so the matched Sid reflects the LAST matching
293                    // Deny (deterministic for telemetry).
294                }
295                Effect::Allow => {
296                    if matched_allow.is_none() {
297                        matched_allow = Some(st.sid.clone());
298                    }
299                }
300            }
301        }
302
303        if let Some(sid) = matched_deny {
304            Decision::deny(sid)
305        } else if let Some(sid) = matched_allow {
306            Decision::allow(sid)
307        } else {
308            Decision::implicit_deny()
309        }
310    }
311}
312
313#[derive(Debug, Clone, PartialEq, Eq)]
314pub struct Decision {
315    pub allow: bool,
316    pub matched_sid: Option<String>,
317    /// `None` = implicit deny (no statement matched), `Some(Allow|Deny)` =
318    /// explicit decision.
319    pub matched_effect: Option<Effect>,
320}
321
322impl Decision {
323    fn allow(sid: Option<String>) -> Self {
324        Self {
325            allow: true,
326            matched_sid: sid,
327            matched_effect: Some(Effect::Allow),
328        }
329    }
330    fn deny(sid: Option<String>) -> Self {
331        Self {
332            allow: false,
333            matched_sid: sid,
334            matched_effect: Some(Effect::Deny),
335        }
336    }
337    fn implicit_deny() -> Self {
338        Self {
339            allow: false,
340            matched_sid: None,
341            matched_effect: None,
342        }
343    }
344}
345
346/// Match an action pattern against a concrete action.
347/// Patterns: `*`, `s3:*`, `s3:GetObject`. Case-sensitive (AWS is too).
348fn action_matches(pattern: &str, action: &str) -> bool {
349    if pattern == "*" {
350        return true;
351    }
352    if let Some(prefix) = pattern.strip_suffix(":*") {
353        return action.starts_with(prefix) && action[prefix.len()..].starts_with(':');
354    }
355    pattern == action
356}
357
358/// Match a resource ARN pattern against a concrete resource ARN. Supports
359/// `*` and `?` glob characters.
360fn resource_matches(pattern: &str, resource: &str) -> bool {
361    glob_match(pattern, resource)
362}
363
364/// Hand-rolled glob (`*` = any sequence, `?` = any single char) so we don't
365/// pull in the `globset` crate for a single use site.
366fn glob_match(pattern: &str, s: &str) -> bool {
367    let p_bytes = pattern.as_bytes();
368    let s_bytes = s.as_bytes();
369    glob_match_bytes(p_bytes, s_bytes)
370}
371
372fn glob_match_bytes(p: &[u8], s: &[u8]) -> bool {
373    let mut pi = 0;
374    let mut si = 0;
375    let mut star: Option<(usize, usize)> = None;
376    while si < s.len() {
377        if pi < p.len() && (p[pi] == b'?' || p[pi] == s[si]) {
378            pi += 1;
379            si += 1;
380        } else if pi < p.len() && p[pi] == b'*' {
381            star = Some((pi, si));
382            pi += 1;
383        } else if let Some((sp, ss)) = star {
384            pi = sp + 1;
385            si = ss + 1;
386            star = Some((sp, si));
387        } else {
388            return false;
389        }
390    }
391    while pi < p.len() && p[pi] == b'*' {
392        pi += 1;
393    }
394    pi == p.len()
395}
396
397fn principal_matches(allowed: &Option<Vec<String>>, principal_id: Option<&str>) -> bool {
398    match allowed {
399        // No Principal field on the statement → match any caller (incl. anonymous).
400        None => true,
401        Some(list) if list.is_empty() => true,
402        Some(list) => match principal_id {
403            None => false,
404            Some(id) => list.iter().any(|p| p == "*" || p == id),
405        },
406    }
407}
408
409/// v0.3 #13: evaluate one Condition clause against the request context.
410/// Returns `true` when the clause matches (statement may apply), `false`
411/// when it doesn't (statement is skipped).
412fn condition_matches(c: &Condition, ctx: &RequestContext) -> bool {
413    match c.op {
414        ConditionOp::IpAddress => match ctx.source_ip {
415            Some(ip) => c.values.iter().any(|cidr| ip_in_cidr(ip, cidr)),
416            None => false,
417        },
418        ConditionOp::NotIpAddress => match ctx.source_ip {
419            Some(ip) => !c.values.iter().any(|cidr| ip_in_cidr(ip, cidr)),
420            None => false,
421        },
422        ConditionOp::StringEquals => match context_value(&c.key, ctx) {
423            Some(v) => c.values.iter().any(|x| x == &v),
424            None => false,
425        },
426        ConditionOp::StringNotEquals => match context_value(&c.key, ctx) {
427            Some(v) => !c.values.iter().any(|x| x == &v),
428            None => false,
429        },
430        ConditionOp::StringLike => match context_value(&c.key, ctx) {
431            Some(v) => c.values.iter().any(|pat| glob_match(pat, &v)),
432            None => false,
433        },
434        ConditionOp::StringNotLike => match context_value(&c.key, ctx) {
435            Some(v) => !c.values.iter().any(|pat| glob_match(pat, &v)),
436            None => false,
437        },
438        ConditionOp::DateGreaterThan | ConditionOp::DateLessThan => {
439            // aws:CurrentTime is the only date key we materialise today.
440            let now = ctx.request_time.unwrap_or_else(SystemTime::now);
441            let now_unix = match now.duration_since(SystemTime::UNIX_EPOCH) {
442                Ok(d) => d.as_secs() as i64,
443                Err(_) => 0,
444            };
445            c.values.iter().any(|s| match parse_iso8601(s) {
446                Some(t) => match c.op {
447                    ConditionOp::DateGreaterThan => now_unix > t,
448                    ConditionOp::DateLessThan => now_unix < t,
449                    _ => unreachable!(),
450                },
451                None => false,
452            })
453        }
454        ConditionOp::Bool => match context_value(&c.key, ctx) {
455            Some(v) => c.values.iter().any(|x| x.eq_ignore_ascii_case(&v)),
456            None => false,
457        },
458    }
459}
460
461/// Resolve a Condition key against the request context. Handles the
462/// well-known `aws:SourceIp` / `aws:UserAgent` / `aws:CurrentTime` /
463/// `aws:SecureTransport` keys, plus any free-form key the caller stuffed
464/// into `ctx.extra`.
465fn context_value(key: &str, ctx: &RequestContext) -> Option<String> {
466    match key {
467        "aws:UserAgent" | "aws:userAgent" => ctx.user_agent.clone(),
468        "aws:SourceIp" | "aws:sourceIp" => ctx.source_ip.map(|ip| ip.to_string()),
469        "aws:SecureTransport" => Some(ctx.secure_transport.to_string()),
470        other => ctx.extra.get(other).cloned(),
471    }
472}
473
474/// Minimal CIDR-or-bare-IP membership test for `IpAddress`. Supports both
475/// IPv4 and IPv6, with or without the `/N` mask.
476fn ip_in_cidr(ip: IpAddr, cidr: &str) -> bool {
477    match cidr.split_once('/') {
478        None => cidr.parse::<IpAddr>().is_ok_and(|c| c == ip),
479        Some((net_str, mask_str)) => {
480            let Ok(net) = net_str.parse::<IpAddr>() else {
481                return false;
482            };
483            let Ok(mask_bits) = mask_str.parse::<u8>() else {
484                return false;
485            };
486            match (ip, net) {
487                (IpAddr::V4(ip4), IpAddr::V4(net4)) => {
488                    if mask_bits > 32 {
489                        return false;
490                    }
491                    if mask_bits == 0 {
492                        return true;
493                    }
494                    let shift = 32 - mask_bits;
495                    (u32::from(ip4) >> shift) == (u32::from(net4) >> shift)
496                }
497                (IpAddr::V6(ip6), IpAddr::V6(net6)) => {
498                    if mask_bits > 128 {
499                        return false;
500                    }
501                    if mask_bits == 0 {
502                        return true;
503                    }
504                    let shift = 128 - mask_bits;
505                    (u128::from(ip6) >> shift) == (u128::from(net6) >> shift)
506                }
507                _ => false, // IPv4 vs IPv6 mismatch
508            }
509        }
510    }
511}
512
513/// Minimal ISO-8601 parser tailored to the AWS bucket-policy
514/// `aws:CurrentTime` format: `YYYY-MM-DDTHH:MM:SSZ` (UTC, second
515/// granularity). Returns unix epoch seconds. AWS also accepts the
516/// `+00:00` offset variants and millisecond fractions — out of scope
517/// for v0.3, can be relaxed later if a real policy needs them.
518fn parse_iso8601(s: &str) -> Option<i64> {
519    // Accept `YYYY-MM-DDTHH:MM:SSZ` only; reject anything else.
520    let s = s.strip_suffix('Z')?;
521    let (date, time) = s.split_once('T')?;
522    let date_parts: Vec<&str> = date.split('-').collect();
523    if date_parts.len() != 3 {
524        return None;
525    }
526    let year: i64 = date_parts[0].parse().ok()?;
527    let month: i64 = date_parts[1].parse().ok()?;
528    let day: i64 = date_parts[2].parse().ok()?;
529    let time_parts: Vec<&str> = time.split(':').collect();
530    if time_parts.len() != 3 {
531        return None;
532    }
533    let h: i64 = time_parts[0].parse().ok()?;
534    let m: i64 = time_parts[1].parse().ok()?;
535    let s: i64 = time_parts[2].parse().ok()?;
536    // Days from 1970-01-01 via a quick civil-from-date algorithm
537    // (Howard Hinnant — public domain). Good for AD years.
538    let y = if month <= 2 { year - 1 } else { year };
539    let era = if y >= 0 { y } else { y - 399 } / 400;
540    let yoe = (y - era * 400) as u64;
541    let mp = if month > 2 { month - 3 } else { month + 9 };
542    let doy = (153 * mp + 2) / 5 + day - 1;
543    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy as u64;
544    let days_from_epoch = era * 146097 + doe as i64 - 719468;
545    Some(days_from_epoch * 86_400 + h * 3600 + m * 60 + s)
546}
547
548/// Wrap a Policy in an Arc so cloning the S4Service stays cheap.
549pub type SharedPolicy = Arc<Policy>;
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    fn p(s: &str) -> Policy {
556        Policy::from_json_str(s).expect("policy")
557    }
558
559    #[test]
560    fn allow_then_deny_explicit_deny_wins() {
561        let pol = p(r#"{
562            "Version": "2012-10-17",
563            "Statement": [
564              {"Sid": "AllowAll", "Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::b/*"},
565              {"Sid": "DenyDelete", "Effect": "Deny", "Action": "s3:DeleteObject", "Resource": "arn:aws:s3:::b/*"}
566            ]
567        }"#);
568        let d = pol.evaluate("s3:GetObject", "b", Some("k"), None);
569        assert!(d.allow);
570        assert_eq!(d.matched_sid.as_deref(), Some("AllowAll"));
571        let d = pol.evaluate("s3:DeleteObject", "b", Some("k"), None);
572        assert!(!d.allow);
573        assert_eq!(d.matched_effect, Some(Effect::Deny));
574        assert_eq!(d.matched_sid.as_deref(), Some("DenyDelete"));
575    }
576
577    #[test]
578    fn implicit_deny_when_no_statement_matches() {
579        let pol = p(r#"{
580            "Version": "2012-10-17",
581            "Statement": [
582              {"Effect": "Allow", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::other/*"}
583            ]
584        }"#);
585        let d = pol.evaluate("s3:GetObject", "mine", Some("k"), None);
586        assert!(!d.allow);
587        assert_eq!(d.matched_effect, None);
588    }
589
590    #[test]
591    fn resource_glob_matches_prefix() {
592        let pol = p(r#"{
593            "Version": "2012-10-17",
594            "Statement": [{
595              "Effect": "Allow",
596              "Action": "s3:GetObject",
597              "Resource": "arn:aws:s3:::b/data/*.parquet"
598            }]
599        }"#);
600        assert!(
601            pol.evaluate("s3:GetObject", "b", Some("data/foo.parquet"), None)
602                .allow
603        );
604        assert!(
605            pol.evaluate("s3:GetObject", "b", Some("data/sub/bar.parquet"), None)
606                .allow
607        );
608        assert!(
609            !pol.evaluate("s3:GetObject", "b", Some("data/foo.txt"), None)
610                .allow
611        );
612    }
613
614    #[test]
615    fn s3_action_wildcard() {
616        let pol = p(r#"{
617            "Version": "2012-10-17",
618            "Statement": [{"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::*"}]
619        }"#);
620        assert!(pol.evaluate("s3:GetObject", "any", Some("k"), None).allow);
621        assert!(pol.evaluate("s3:PutObject", "any", Some("k"), None).allow);
622        // Non-s3 action would not match (we don't generate any non-s3 actions
623        // from S4Service handlers, but verify the matcher behaves correctly)
624        assert!(!pol.evaluate("iam:ListUsers", "any", None, None).allow);
625    }
626
627    #[test]
628    fn principal_match_by_access_key_id() {
629        let pol = p(r#"{
630            "Version": "2012-10-17",
631            "Statement": [{
632              "Effect": "Allow",
633              "Action": "s3:*",
634              "Resource": "arn:aws:s3:::b/*",
635              "Principal": {"AWS": ["AKIATEST123"]}
636            }]
637        }"#);
638        assert!(
639            pol.evaluate("s3:GetObject", "b", Some("k"), Some("AKIATEST123"))
640                .allow
641        );
642        assert!(
643            !pol.evaluate("s3:GetObject", "b", Some("k"), Some("AKIAOTHER"))
644                .allow
645        );
646        assert!(!pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
647    }
648
649    #[test]
650    fn principal_wildcard_matches_anyone() {
651        let pol = p(r#"{
652            "Version": "2012-10-17",
653            "Statement": [{
654              "Effect": "Allow",
655              "Action": "s3:*",
656              "Resource": "arn:aws:s3:::b/*",
657              "Principal": "*"
658            }]
659        }"#);
660        assert!(
661            pol.evaluate("s3:GetObject", "b", Some("k"), Some("AKIAANY"))
662                .allow
663        );
664        assert!(pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
665    }
666
667    #[test]
668    fn resource_can_be_string_or_array() {
669        let single = p(r#"{
670            "Statement": [{"Effect": "Allow", "Action": "s3:GetObject",
671                          "Resource": "arn:aws:s3:::a/*"}]
672        }"#);
673        let multi = p(r#"{
674            "Statement": [{"Effect": "Allow", "Action": "s3:GetObject",
675                          "Resource": ["arn:aws:s3:::a/*", "arn:aws:s3:::b/*"]}]
676        }"#);
677        assert!(single.evaluate("s3:GetObject", "a", Some("k"), None).allow);
678        assert!(!single.evaluate("s3:GetObject", "b", Some("k"), None).allow);
679        assert!(multi.evaluate("s3:GetObject", "b", Some("k"), None).allow);
680    }
681
682    #[test]
683    fn bucket_level_resource_for_listbucket() {
684        let pol = p(r#"{
685            "Statement": [{"Effect": "Allow", "Action": "s3:ListBucket",
686                          "Resource": "arn:aws:s3:::b"}]
687        }"#);
688        // ListBucket uses a key=None resource, formatted as bucket-only ARN
689        assert!(pol.evaluate("s3:ListBucket", "b", None, None).allow);
690        assert!(!pol.evaluate("s3:ListBucket", "other", None, None).allow);
691    }
692
693    #[test]
694    fn glob_match_basics() {
695        assert!(glob_match("foo", "foo"));
696        assert!(!glob_match("foo", "bar"));
697        assert!(glob_match("*", "anything"));
698        assert!(glob_match("foo*", "foobar"));
699        assert!(glob_match("*bar", "foobar"));
700        assert!(glob_match("foo*bar", "fooXYZbar"));
701        assert!(glob_match("a?c", "abc"));
702        assert!(!glob_match("a?c", "abbc"));
703        assert!(glob_match("a*b*c", "axxxbyyyc"));
704    }
705
706    // ===== v0.3 #13 IAM Condition tests =====
707
708    fn ctx_ip(ip: &str) -> RequestContext {
709        RequestContext {
710            source_ip: Some(ip.parse().unwrap()),
711            ..Default::default()
712        }
713    }
714
715    #[test]
716    fn condition_ip_address_cidr_match() {
717        let pol = p(r#"{
718            "Statement": [{
719              "Effect": "Allow", "Action": "s3:GetObject",
720              "Resource": "arn:aws:s3:::b/*",
721              "Condition": {"IpAddress": {"aws:SourceIp": ["10.0.0.0/8", "192.168.1.0/24"]}}
722            }]
723        }"#);
724        assert!(
725            pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_ip("10.5.6.7"))
726                .allow
727        );
728        assert!(
729            pol.evaluate_with(
730                "s3:GetObject",
731                "b",
732                Some("k"),
733                None,
734                &ctx_ip("192.168.1.50")
735            )
736            .allow
737        );
738        assert!(
739            !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_ip("203.0.113.1"))
740                .allow
741        );
742        // No source IP in context → condition fails → statement skipped
743        assert!(
744            !pol.evaluate_with(
745                "s3:GetObject",
746                "b",
747                Some("k"),
748                None,
749                &RequestContext::default()
750            )
751            .allow
752        );
753    }
754
755    #[test]
756    fn condition_not_ip_address_negates() {
757        let pol = p(r#"{
758            "Statement": [{
759              "Effect": "Deny", "Action": "s3:DeleteObject",
760              "Resource": "arn:aws:s3:::b/*",
761              "Condition": {"NotIpAddress": {"aws:SourceIp": ["10.0.0.0/8"]}}
762            },
763            {"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::b/*"}]
764        }"#);
765        // Outside the trusted CIDR → Deny applies (NotIpAddress = true) → AccessDenied
766        assert!(
767            !pol.evaluate_with(
768                "s3:DeleteObject",
769                "b",
770                Some("k"),
771                None,
772                &ctx_ip("203.0.113.1")
773            )
774            .allow
775        );
776        // Inside the trusted CIDR → Deny condition fails → Allow remains
777        assert!(
778            pol.evaluate_with("s3:DeleteObject", "b", Some("k"), None, &ctx_ip("10.0.0.7"))
779                .allow
780        );
781    }
782
783    #[test]
784    fn condition_string_equals_user_agent() {
785        let pol = p(r#"{
786            "Statement": [{
787              "Effect": "Allow", "Action": "s3:GetObject",
788              "Resource": "arn:aws:s3:::b/*",
789              "Condition": {"StringEquals": {"aws:UserAgent": ["MyApp/1.0", "MyApp/2.0"]}}
790            }]
791        }"#);
792        let ua = |s: &str| RequestContext {
793            user_agent: Some(s.into()),
794            ..Default::default()
795        };
796        assert!(
797            pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("MyApp/1.0"))
798                .allow
799        );
800        assert!(
801            !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("OtherApp/1.0"))
802                .allow
803        );
804    }
805
806    #[test]
807    fn condition_string_like_glob() {
808        let pol = p(r#"{
809            "Statement": [{
810              "Effect": "Allow", "Action": "s3:GetObject",
811              "Resource": "arn:aws:s3:::b/*",
812              "Condition": {"StringLike": {"aws:UserAgent": ["MyApp/*", "boto3/*"]}}
813            }]
814        }"#);
815        let ua = |s: &str| RequestContext {
816            user_agent: Some(s.into()),
817            ..Default::default()
818        };
819        assert!(
820            pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("MyApp/3.14"))
821                .allow
822        );
823        assert!(
824            pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("boto3/1.34.5"))
825                .allow
826        );
827        assert!(
828            !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("curl/8"))
829                .allow
830        );
831    }
832
833    #[test]
834    fn condition_date_window() {
835        // Allow only requests between two dates.
836        let pol = p(r#"{
837            "Statement": [{
838              "Effect": "Allow", "Action": "s3:GetObject",
839              "Resource": "arn:aws:s3:::b/*",
840              "Condition": {
841                "DateGreaterThan": {"aws:CurrentTime": ["2026-01-01T00:00:00Z"]},
842                "DateLessThan":    {"aws:CurrentTime": ["2026-12-31T23:59:59Z"]}
843              }
844            }]
845        }"#);
846        let mid_year = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_780_000_000); // ~mid-2026
847        let after = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_800_000_000); // ~early-2027
848        let ctx_at = |t: SystemTime| RequestContext {
849            request_time: Some(t),
850            ..Default::default()
851        };
852        assert!(
853            pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_at(mid_year))
854                .allow
855        );
856        assert!(
857            !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_at(after))
858                .allow
859        );
860    }
861
862    #[test]
863    fn condition_bool_secure_transport() {
864        let pol = p(r#"{
865            "Statement": [{
866              "Effect": "Deny", "Action": "s3:*",
867              "Resource": "arn:aws:s3:::b/*",
868              "Condition": {"Bool": {"aws:SecureTransport": ["false"]}}
869            },
870            {"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::b/*"}]
871        }"#);
872        let plain = RequestContext {
873            secure_transport: false,
874            ..Default::default()
875        };
876        let tls = RequestContext {
877            secure_transport: true,
878            ..Default::default()
879        };
880        // Plain HTTP → SecureTransport=false → Deny matches
881        assert!(
882            !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &plain)
883                .allow
884        );
885        // TLS → SecureTransport=true → Deny condition fails → Allow remains
886        assert!(
887            pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &tls)
888                .allow
889        );
890    }
891
892    #[test]
893    fn condition_unknown_operator_rejected() {
894        let err = Policy::from_json_str(
895            r#"{
896            "Statement": [{"Effect": "Allow", "Action": "s3:*",
897              "Resource": "arn:aws:s3:::b/*",
898              "Condition": {"NumericGreaterThan": {"k": ["1"]}}
899            }]
900        }"#,
901        )
902        .expect_err("should reject unsupported operator");
903        assert!(err.contains("unsupported policy Condition operator"));
904        assert!(err.contains("NumericGreaterThan"));
905    }
906
907    #[test]
908    fn condition_legacy_evaluate_unchanged() {
909        // Old `evaluate` (no context) still works: a policy without
910        // Condition clauses is unaffected by the v0.3 changes.
911        let pol = p(r#"{
912            "Statement": [{"Effect": "Allow", "Action": "s3:*",
913              "Resource": "arn:aws:s3:::b/*"}]
914        }"#);
915        assert!(pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
916    }
917}