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    /// v0.6 #39: tags currently attached to the object the request
135    /// targets (resolved by the caller via `TagManager` ahead of
136    /// `evaluate_with`). Surfaced to policy via the
137    /// `s3:ExistingObjectTag/<key>` condition key. `None` here is
138    /// treated identically to "no tags exist" — every
139    /// `ExistingObjectTag` clause then fails.
140    pub existing_object_tags: Option<crate::tagging::TagSet>,
141    /// v0.6 #39: tags carried in the *request* itself (PutObject's
142    /// `x-amz-tagging` URL-encoded header, or PutObjectTagging's
143    /// `Tagging` body). Surfaced to policy via the
144    /// `s3:RequestObjectTag/<key>` condition key.
145    pub request_object_tags: Option<crate::tagging::TagSet>,
146    /// Generic key → value map for any aws:* or s3:* context key not
147    /// covered by the typed fields above (keeps the door open for any
148    /// key the caller wants to plumb without changing the struct).
149    pub extra: HashMap<String, String>,
150}
151
152/// One compiled Condition clause inside a Statement.
153#[derive(Debug, Clone)]
154struct Condition {
155    op: ConditionOp,
156    key: String,         // e.g. `aws:SourceIp`, `aws:UserAgent`, `aws:CurrentTime`
157    values: Vec<String>, // operator-specific (CIDR, glob, ISO-8601 timestamp, "true" / "false", ...)
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161enum ConditionOp {
162    IpAddress,
163    NotIpAddress,
164    StringEquals,
165    StringNotEquals,
166    StringLike,
167    StringNotLike,
168    DateGreaterThan,
169    DateLessThan,
170    Bool,
171}
172
173impl ConditionOp {
174    fn parse(s: &str) -> Option<Self> {
175        Some(match s {
176            "IpAddress" => Self::IpAddress,
177            "NotIpAddress" => Self::NotIpAddress,
178            "StringEquals" => Self::StringEquals,
179            "StringNotEquals" => Self::StringNotEquals,
180            "StringLike" => Self::StringLike,
181            "StringNotLike" => Self::StringNotLike,
182            "DateGreaterThan" => Self::DateGreaterThan,
183            "DateLessThan" => Self::DateLessThan,
184            "Bool" => Self::Bool,
185            _ => return None,
186        })
187    }
188}
189
190impl Policy {
191    pub fn from_json_str(s: &str) -> Result<Self, String> {
192        let raw: PolicyJson =
193            serde_json::from_str(s).map_err(|e| format!("policy JSON parse error: {e}"))?;
194        let mut statements = Vec::with_capacity(raw.statements.len());
195        for s in raw.statements {
196            let mut conditions = Vec::new();
197            if let Some(cond_map) = s.condition {
198                for (op_name, key_map) in cond_map {
199                    let op = ConditionOp::parse(&op_name).ok_or_else(|| {
200                        format!(
201                            "unsupported policy Condition operator: {op_name:?}. \
202                             v0.3 supports IpAddress / NotIpAddress / StringEquals / \
203                             StringNotEquals / StringLike / StringNotLike / \
204                             DateGreaterThan / DateLessThan / Bool."
205                        )
206                    })?;
207                    for (key, values) in key_map {
208                        conditions.push(Condition {
209                            op,
210                            key,
211                            values: values.into_vec(),
212                        });
213                    }
214                }
215            }
216            statements.push(Statement {
217                sid: s.sid,
218                effect: s.effect,
219                actions: s.action.into_vec(),
220                resources: s.resource.into_vec(),
221                principals: s.principal.map(|p| match p {
222                    PrincipalSet::Wildcard(_) => Vec::new(),
223                    PrincipalSet::Map { aws } => aws.map(|v| v.into_vec()).unwrap_or_default(),
224                }),
225                conditions,
226            });
227        }
228        Ok(Self { statements })
229    }
230
231    pub fn from_path(path: &Path) -> Result<Self, String> {
232        let txt = std::fs::read_to_string(path)
233            .map_err(|e| format!("failed to read {}: {e}", path.display()))?;
234        Self::from_json_str(&txt)
235    }
236
237    /// Evaluate a request against the policy.
238    ///
239    /// `principal_id` is typically the SigV4 access key id taken from the
240    /// authenticated request. Pass `None` for anonymous (will only match
241    /// statements with wildcard or absent Principal).
242    ///
243    /// Convenience for the common case with no Condition data; calls the
244    /// full [`Policy::evaluate_with`] with a default `RequestContext`.
245    pub fn evaluate(
246        &self,
247        action: &str,
248        bucket: &str,
249        key: Option<&str>,
250        principal_id: Option<&str>,
251    ) -> Decision {
252        self.evaluate_with(
253            action,
254            bucket,
255            key,
256            principal_id,
257            &RequestContext::default(),
258        )
259    }
260
261    /// Same as [`Policy::evaluate`] but lets the caller plumb a populated
262    /// [`RequestContext`] for v0.3 #13 IAM Conditions (IP allowlists,
263    /// user-agent restrictions, time windows, etc.).
264    pub fn evaluate_with(
265        &self,
266        action: &str,
267        bucket: &str,
268        key: Option<&str>,
269        principal_id: Option<&str>,
270        ctx: &RequestContext,
271    ) -> Decision {
272        let object_resource = match key {
273            Some(k) => format!("arn:aws:s3:::{bucket}/{k}"),
274            None => format!("arn:aws:s3:::{bucket}"),
275        };
276        let bucket_resource = format!("arn:aws:s3:::{bucket}");
277
278        let mut matched_allow: Option<Option<String>> = None;
279        let mut matched_deny: Option<Option<String>> = None;
280
281        for st in &self.statements {
282            if !st.actions.iter().any(|p| action_matches(p, action)) {
283                continue;
284            }
285            let any_resource_matches = st.resources.iter().any(|p| {
286                resource_matches(p, &object_resource) || resource_matches(p, &bucket_resource)
287            });
288            if !any_resource_matches {
289                continue;
290            }
291            if !principal_matches(&st.principals, principal_id) {
292                continue;
293            }
294            // v0.3 #13: Conditions are ALL-AND — a statement applies only
295            // when every Condition clause matches the request context.
296            // A clause failing simply skips the statement (no error).
297            if !st.conditions.iter().all(|c| condition_matches(c, ctx)) {
298                continue;
299            }
300            match st.effect {
301                Effect::Deny => {
302                    matched_deny = Some(st.sid.clone());
303                    // Any explicit Deny wins; no need to keep scanning, but
304                    // continue so the matched Sid reflects the LAST matching
305                    // Deny (deterministic for telemetry).
306                }
307                Effect::Allow => {
308                    if matched_allow.is_none() {
309                        matched_allow = Some(st.sid.clone());
310                    }
311                }
312            }
313        }
314
315        if let Some(sid) = matched_deny {
316            Decision::deny(sid)
317        } else if let Some(sid) = matched_allow {
318            Decision::allow(sid)
319        } else {
320            Decision::implicit_deny()
321        }
322    }
323}
324
325#[derive(Debug, Clone, PartialEq, Eq)]
326pub struct Decision {
327    pub allow: bool,
328    pub matched_sid: Option<String>,
329    /// `None` = implicit deny (no statement matched), `Some(Allow|Deny)` =
330    /// explicit decision.
331    pub matched_effect: Option<Effect>,
332}
333
334impl Decision {
335    fn allow(sid: Option<String>) -> Self {
336        Self {
337            allow: true,
338            matched_sid: sid,
339            matched_effect: Some(Effect::Allow),
340        }
341    }
342    fn deny(sid: Option<String>) -> Self {
343        Self {
344            allow: false,
345            matched_sid: sid,
346            matched_effect: Some(Effect::Deny),
347        }
348    }
349    fn implicit_deny() -> Self {
350        Self {
351            allow: false,
352            matched_sid: None,
353            matched_effect: None,
354        }
355    }
356}
357
358/// Match an action pattern against a concrete action.
359/// Patterns: `*`, `s3:*`, `s3:GetObject`. Case-sensitive (AWS is too).
360fn action_matches(pattern: &str, action: &str) -> bool {
361    if pattern == "*" {
362        return true;
363    }
364    if let Some(prefix) = pattern.strip_suffix(":*") {
365        return action.starts_with(prefix) && action[prefix.len()..].starts_with(':');
366    }
367    pattern == action
368}
369
370/// Match a resource ARN pattern against a concrete resource ARN. Supports
371/// `*` and `?` glob characters.
372fn resource_matches(pattern: &str, resource: &str) -> bool {
373    glob_match(pattern, resource)
374}
375
376/// Hand-rolled glob (`*` = any sequence, `?` = any single char) so we don't
377/// pull in the `globset` crate for a single use site.
378fn glob_match(pattern: &str, s: &str) -> bool {
379    let p_bytes = pattern.as_bytes();
380    let s_bytes = s.as_bytes();
381    glob_match_bytes(p_bytes, s_bytes)
382}
383
384fn glob_match_bytes(p: &[u8], s: &[u8]) -> bool {
385    let mut pi = 0;
386    let mut si = 0;
387    let mut star: Option<(usize, usize)> = None;
388    while si < s.len() {
389        if pi < p.len() && (p[pi] == b'?' || p[pi] == s[si]) {
390            pi += 1;
391            si += 1;
392        } else if pi < p.len() && p[pi] == b'*' {
393            star = Some((pi, si));
394            pi += 1;
395        } else if let Some((sp, ss)) = star {
396            pi = sp + 1;
397            si = ss + 1;
398            star = Some((sp, si));
399        } else {
400            return false;
401        }
402    }
403    while pi < p.len() && p[pi] == b'*' {
404        pi += 1;
405    }
406    pi == p.len()
407}
408
409fn principal_matches(allowed: &Option<Vec<String>>, principal_id: Option<&str>) -> bool {
410    match allowed {
411        // No Principal field on the statement → match any caller (incl. anonymous).
412        None => true,
413        Some(list) if list.is_empty() => true,
414        Some(list) => match principal_id {
415            None => false,
416            Some(id) => list.iter().any(|p| p == "*" || p == id),
417        },
418    }
419}
420
421/// v0.3 #13: evaluate one Condition clause against the request context.
422/// Returns `true` when the clause matches (statement may apply), `false`
423/// when it doesn't (statement is skipped).
424fn condition_matches(c: &Condition, ctx: &RequestContext) -> bool {
425    match c.op {
426        ConditionOp::IpAddress => match ctx.source_ip {
427            Some(ip) => c.values.iter().any(|cidr| ip_in_cidr(ip, cidr)),
428            None => false,
429        },
430        ConditionOp::NotIpAddress => match ctx.source_ip {
431            Some(ip) => !c.values.iter().any(|cidr| ip_in_cidr(ip, cidr)),
432            None => false,
433        },
434        ConditionOp::StringEquals => match context_value(&c.key, ctx) {
435            Some(v) => c.values.iter().any(|x| x == &v),
436            None => false,
437        },
438        ConditionOp::StringNotEquals => match context_value(&c.key, ctx) {
439            Some(v) => !c.values.iter().any(|x| x == &v),
440            None => false,
441        },
442        ConditionOp::StringLike => match context_value(&c.key, ctx) {
443            Some(v) => c.values.iter().any(|pat| glob_match(pat, &v)),
444            None => false,
445        },
446        ConditionOp::StringNotLike => match context_value(&c.key, ctx) {
447            Some(v) => !c.values.iter().any(|pat| glob_match(pat, &v)),
448            None => false,
449        },
450        ConditionOp::DateGreaterThan | ConditionOp::DateLessThan => {
451            // aws:CurrentTime is the only date key we materialise today.
452            let now = ctx.request_time.unwrap_or_else(SystemTime::now);
453            let now_unix = match now.duration_since(SystemTime::UNIX_EPOCH) {
454                Ok(d) => d.as_secs() as i64,
455                Err(_) => 0,
456            };
457            c.values.iter().any(|s| match parse_iso8601(s) {
458                Some(t) => match c.op {
459                    ConditionOp::DateGreaterThan => now_unix > t,
460                    ConditionOp::DateLessThan => now_unix < t,
461                    _ => unreachable!(),
462                },
463                None => false,
464            })
465        }
466        ConditionOp::Bool => match context_value(&c.key, ctx) {
467            Some(v) => c.values.iter().any(|x| x.eq_ignore_ascii_case(&v)),
468            None => false,
469        },
470    }
471}
472
473/// Resolve a Condition key against the request context. Handles the
474/// well-known `aws:SourceIp` / `aws:UserAgent` / `aws:CurrentTime` /
475/// `aws:SecureTransport` keys, the v0.6 #39 `s3:ExistingObjectTag/*` /
476/// `s3:RequestObjectTag/*` tag keys, plus any free-form key the caller
477/// stuffed into `ctx.extra`.
478fn context_value(key: &str, ctx: &RequestContext) -> Option<String> {
479    match key {
480        "aws:UserAgent" | "aws:userAgent" => ctx.user_agent.clone(),
481        "aws:SourceIp" | "aws:sourceIp" => ctx.source_ip.map(|ip| ip.to_string()),
482        "aws:SecureTransport" => Some(ctx.secure_transport.to_string()),
483        other => {
484            // v0.6 #39: tag-based condition keys are slash-suffixed
485            // (`s3:ExistingObjectTag/<tag-key>` /
486            // `s3:RequestObjectTag/<tag-key>`). Resolve to the named
487            // tag's value if present in the relevant set; `None`
488            // otherwise — which makes the clause fail (statement
489            // skipped) for both `StringEquals` and `StringNotEquals`.
490            if let Some(tag_key) = other.strip_prefix("s3:ExistingObjectTag/") {
491                return ctx
492                    .existing_object_tags
493                    .as_ref()
494                    .and_then(|s| s.get(tag_key).map(str::to_owned));
495            }
496            if let Some(tag_key) = other.strip_prefix("s3:RequestObjectTag/") {
497                return ctx
498                    .request_object_tags
499                    .as_ref()
500                    .and_then(|s| s.get(tag_key).map(str::to_owned));
501            }
502            ctx.extra.get(other).cloned()
503        }
504    }
505}
506
507/// Minimal CIDR-or-bare-IP membership test for `IpAddress`. Supports both
508/// IPv4 and IPv6, with or without the `/N` mask.
509fn ip_in_cidr(ip: IpAddr, cidr: &str) -> bool {
510    match cidr.split_once('/') {
511        None => cidr.parse::<IpAddr>().is_ok_and(|c| c == ip),
512        Some((net_str, mask_str)) => {
513            let Ok(net) = net_str.parse::<IpAddr>() else {
514                return false;
515            };
516            let Ok(mask_bits) = mask_str.parse::<u8>() else {
517                return false;
518            };
519            match (ip, net) {
520                (IpAddr::V4(ip4), IpAddr::V4(net4)) => {
521                    if mask_bits > 32 {
522                        return false;
523                    }
524                    if mask_bits == 0 {
525                        return true;
526                    }
527                    let shift = 32 - mask_bits;
528                    (u32::from(ip4) >> shift) == (u32::from(net4) >> shift)
529                }
530                (IpAddr::V6(ip6), IpAddr::V6(net6)) => {
531                    if mask_bits > 128 {
532                        return false;
533                    }
534                    if mask_bits == 0 {
535                        return true;
536                    }
537                    let shift = 128 - mask_bits;
538                    (u128::from(ip6) >> shift) == (u128::from(net6) >> shift)
539                }
540                _ => false, // IPv4 vs IPv6 mismatch
541            }
542        }
543    }
544}
545
546/// Minimal ISO-8601 parser tailored to the AWS bucket-policy
547/// `aws:CurrentTime` format: `YYYY-MM-DDTHH:MM:SSZ` (UTC, second
548/// granularity). Returns unix epoch seconds. AWS also accepts the
549/// `+00:00` offset variants and millisecond fractions — out of scope
550/// for v0.3, can be relaxed later if a real policy needs them.
551fn parse_iso8601(s: &str) -> Option<i64> {
552    // Accept `YYYY-MM-DDTHH:MM:SSZ` only; reject anything else.
553    let s = s.strip_suffix('Z')?;
554    let (date, time) = s.split_once('T')?;
555    let date_parts: Vec<&str> = date.split('-').collect();
556    if date_parts.len() != 3 {
557        return None;
558    }
559    let year: i64 = date_parts[0].parse().ok()?;
560    let month: i64 = date_parts[1].parse().ok()?;
561    let day: i64 = date_parts[2].parse().ok()?;
562    let time_parts: Vec<&str> = time.split(':').collect();
563    if time_parts.len() != 3 {
564        return None;
565    }
566    let h: i64 = time_parts[0].parse().ok()?;
567    let m: i64 = time_parts[1].parse().ok()?;
568    let s: i64 = time_parts[2].parse().ok()?;
569    // Days from 1970-01-01 via a quick civil-from-date algorithm
570    // (Howard Hinnant — public domain). Good for AD years.
571    let y = if month <= 2 { year - 1 } else { year };
572    let era = if y >= 0 { y } else { y - 399 } / 400;
573    let yoe = (y - era * 400) as u64;
574    let mp = if month > 2 { month - 3 } else { month + 9 };
575    let doy = (153 * mp + 2) / 5 + day - 1;
576    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy as u64;
577    let days_from_epoch = era * 146097 + doe as i64 - 719468;
578    Some(days_from_epoch * 86_400 + h * 3600 + m * 60 + s)
579}
580
581/// Wrap a Policy in an Arc so cloning the S4Service stays cheap.
582pub type SharedPolicy = Arc<Policy>;
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587
588    fn p(s: &str) -> Policy {
589        Policy::from_json_str(s).expect("policy")
590    }
591
592    #[test]
593    fn allow_then_deny_explicit_deny_wins() {
594        let pol = p(r#"{
595            "Version": "2012-10-17",
596            "Statement": [
597              {"Sid": "AllowAll", "Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::b/*"},
598              {"Sid": "DenyDelete", "Effect": "Deny", "Action": "s3:DeleteObject", "Resource": "arn:aws:s3:::b/*"}
599            ]
600        }"#);
601        let d = pol.evaluate("s3:GetObject", "b", Some("k"), None);
602        assert!(d.allow);
603        assert_eq!(d.matched_sid.as_deref(), Some("AllowAll"));
604        let d = pol.evaluate("s3:DeleteObject", "b", Some("k"), None);
605        assert!(!d.allow);
606        assert_eq!(d.matched_effect, Some(Effect::Deny));
607        assert_eq!(d.matched_sid.as_deref(), Some("DenyDelete"));
608    }
609
610    #[test]
611    fn implicit_deny_when_no_statement_matches() {
612        let pol = p(r#"{
613            "Version": "2012-10-17",
614            "Statement": [
615              {"Effect": "Allow", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::other/*"}
616            ]
617        }"#);
618        let d = pol.evaluate("s3:GetObject", "mine", Some("k"), None);
619        assert!(!d.allow);
620        assert_eq!(d.matched_effect, None);
621    }
622
623    #[test]
624    fn resource_glob_matches_prefix() {
625        let pol = p(r#"{
626            "Version": "2012-10-17",
627            "Statement": [{
628              "Effect": "Allow",
629              "Action": "s3:GetObject",
630              "Resource": "arn:aws:s3:::b/data/*.parquet"
631            }]
632        }"#);
633        assert!(
634            pol.evaluate("s3:GetObject", "b", Some("data/foo.parquet"), None)
635                .allow
636        );
637        assert!(
638            pol.evaluate("s3:GetObject", "b", Some("data/sub/bar.parquet"), None)
639                .allow
640        );
641        assert!(
642            !pol.evaluate("s3:GetObject", "b", Some("data/foo.txt"), None)
643                .allow
644        );
645    }
646
647    #[test]
648    fn s3_action_wildcard() {
649        let pol = p(r#"{
650            "Version": "2012-10-17",
651            "Statement": [{"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::*"}]
652        }"#);
653        assert!(pol.evaluate("s3:GetObject", "any", Some("k"), None).allow);
654        assert!(pol.evaluate("s3:PutObject", "any", Some("k"), None).allow);
655        // Non-s3 action would not match (we don't generate any non-s3 actions
656        // from S4Service handlers, but verify the matcher behaves correctly)
657        assert!(!pol.evaluate("iam:ListUsers", "any", None, None).allow);
658    }
659
660    #[test]
661    fn principal_match_by_access_key_id() {
662        let pol = p(r#"{
663            "Version": "2012-10-17",
664            "Statement": [{
665              "Effect": "Allow",
666              "Action": "s3:*",
667              "Resource": "arn:aws:s3:::b/*",
668              "Principal": {"AWS": ["AKIATEST123"]}
669            }]
670        }"#);
671        assert!(
672            pol.evaluate("s3:GetObject", "b", Some("k"), Some("AKIATEST123"))
673                .allow
674        );
675        assert!(
676            !pol.evaluate("s3:GetObject", "b", Some("k"), Some("AKIAOTHER"))
677                .allow
678        );
679        assert!(!pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
680    }
681
682    #[test]
683    fn principal_wildcard_matches_anyone() {
684        let pol = p(r#"{
685            "Version": "2012-10-17",
686            "Statement": [{
687              "Effect": "Allow",
688              "Action": "s3:*",
689              "Resource": "arn:aws:s3:::b/*",
690              "Principal": "*"
691            }]
692        }"#);
693        assert!(
694            pol.evaluate("s3:GetObject", "b", Some("k"), Some("AKIAANY"))
695                .allow
696        );
697        assert!(pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
698    }
699
700    #[test]
701    fn resource_can_be_string_or_array() {
702        let single = p(r#"{
703            "Statement": [{"Effect": "Allow", "Action": "s3:GetObject",
704                          "Resource": "arn:aws:s3:::a/*"}]
705        }"#);
706        let multi = p(r#"{
707            "Statement": [{"Effect": "Allow", "Action": "s3:GetObject",
708                          "Resource": ["arn:aws:s3:::a/*", "arn:aws:s3:::b/*"]}]
709        }"#);
710        assert!(single.evaluate("s3:GetObject", "a", Some("k"), None).allow);
711        assert!(!single.evaluate("s3:GetObject", "b", Some("k"), None).allow);
712        assert!(multi.evaluate("s3:GetObject", "b", Some("k"), None).allow);
713    }
714
715    #[test]
716    fn bucket_level_resource_for_listbucket() {
717        let pol = p(r#"{
718            "Statement": [{"Effect": "Allow", "Action": "s3:ListBucket",
719                          "Resource": "arn:aws:s3:::b"}]
720        }"#);
721        // ListBucket uses a key=None resource, formatted as bucket-only ARN
722        assert!(pol.evaluate("s3:ListBucket", "b", None, None).allow);
723        assert!(!pol.evaluate("s3:ListBucket", "other", None, None).allow);
724    }
725
726    #[test]
727    fn glob_match_basics() {
728        assert!(glob_match("foo", "foo"));
729        assert!(!glob_match("foo", "bar"));
730        assert!(glob_match("*", "anything"));
731        assert!(glob_match("foo*", "foobar"));
732        assert!(glob_match("*bar", "foobar"));
733        assert!(glob_match("foo*bar", "fooXYZbar"));
734        assert!(glob_match("a?c", "abc"));
735        assert!(!glob_match("a?c", "abbc"));
736        assert!(glob_match("a*b*c", "axxxbyyyc"));
737    }
738
739    // ===== v0.3 #13 IAM Condition tests =====
740
741    fn ctx_ip(ip: &str) -> RequestContext {
742        RequestContext {
743            source_ip: Some(ip.parse().unwrap()),
744            ..Default::default()
745        }
746    }
747
748    #[test]
749    fn condition_ip_address_cidr_match() {
750        let pol = p(r#"{
751            "Statement": [{
752              "Effect": "Allow", "Action": "s3:GetObject",
753              "Resource": "arn:aws:s3:::b/*",
754              "Condition": {"IpAddress": {"aws:SourceIp": ["10.0.0.0/8", "192.168.1.0/24"]}}
755            }]
756        }"#);
757        assert!(
758            pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_ip("10.5.6.7"))
759                .allow
760        );
761        assert!(
762            pol.evaluate_with(
763                "s3:GetObject",
764                "b",
765                Some("k"),
766                None,
767                &ctx_ip("192.168.1.50")
768            )
769            .allow
770        );
771        assert!(
772            !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_ip("203.0.113.1"))
773                .allow
774        );
775        // No source IP in context → condition fails → statement skipped
776        assert!(
777            !pol.evaluate_with(
778                "s3:GetObject",
779                "b",
780                Some("k"),
781                None,
782                &RequestContext::default()
783            )
784            .allow
785        );
786    }
787
788    #[test]
789    fn condition_not_ip_address_negates() {
790        let pol = p(r#"{
791            "Statement": [{
792              "Effect": "Deny", "Action": "s3:DeleteObject",
793              "Resource": "arn:aws:s3:::b/*",
794              "Condition": {"NotIpAddress": {"aws:SourceIp": ["10.0.0.0/8"]}}
795            },
796            {"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::b/*"}]
797        }"#);
798        // Outside the trusted CIDR → Deny applies (NotIpAddress = true) → AccessDenied
799        assert!(
800            !pol.evaluate_with(
801                "s3:DeleteObject",
802                "b",
803                Some("k"),
804                None,
805                &ctx_ip("203.0.113.1")
806            )
807            .allow
808        );
809        // Inside the trusted CIDR → Deny condition fails → Allow remains
810        assert!(
811            pol.evaluate_with("s3:DeleteObject", "b", Some("k"), None, &ctx_ip("10.0.0.7"))
812                .allow
813        );
814    }
815
816    #[test]
817    fn condition_string_equals_user_agent() {
818        let pol = p(r#"{
819            "Statement": [{
820              "Effect": "Allow", "Action": "s3:GetObject",
821              "Resource": "arn:aws:s3:::b/*",
822              "Condition": {"StringEquals": {"aws:UserAgent": ["MyApp/1.0", "MyApp/2.0"]}}
823            }]
824        }"#);
825        let ua = |s: &str| RequestContext {
826            user_agent: Some(s.into()),
827            ..Default::default()
828        };
829        assert!(
830            pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("MyApp/1.0"))
831                .allow
832        );
833        assert!(
834            !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("OtherApp/1.0"))
835                .allow
836        );
837    }
838
839    #[test]
840    fn condition_string_like_glob() {
841        let pol = p(r#"{
842            "Statement": [{
843              "Effect": "Allow", "Action": "s3:GetObject",
844              "Resource": "arn:aws:s3:::b/*",
845              "Condition": {"StringLike": {"aws:UserAgent": ["MyApp/*", "boto3/*"]}}
846            }]
847        }"#);
848        let ua = |s: &str| RequestContext {
849            user_agent: Some(s.into()),
850            ..Default::default()
851        };
852        assert!(
853            pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("MyApp/3.14"))
854                .allow
855        );
856        assert!(
857            pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("boto3/1.34.5"))
858                .allow
859        );
860        assert!(
861            !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("curl/8"))
862                .allow
863        );
864    }
865
866    #[test]
867    fn condition_date_window() {
868        // Allow only requests between two dates.
869        let pol = p(r#"{
870            "Statement": [{
871              "Effect": "Allow", "Action": "s3:GetObject",
872              "Resource": "arn:aws:s3:::b/*",
873              "Condition": {
874                "DateGreaterThan": {"aws:CurrentTime": ["2026-01-01T00:00:00Z"]},
875                "DateLessThan":    {"aws:CurrentTime": ["2026-12-31T23:59:59Z"]}
876              }
877            }]
878        }"#);
879        let mid_year = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_780_000_000); // ~mid-2026
880        let after = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_800_000_000); // ~early-2027
881        let ctx_at = |t: SystemTime| RequestContext {
882            request_time: Some(t),
883            ..Default::default()
884        };
885        assert!(
886            pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_at(mid_year))
887                .allow
888        );
889        assert!(
890            !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_at(after))
891                .allow
892        );
893    }
894
895    #[test]
896    fn condition_bool_secure_transport() {
897        let pol = p(r#"{
898            "Statement": [{
899              "Effect": "Deny", "Action": "s3:*",
900              "Resource": "arn:aws:s3:::b/*",
901              "Condition": {"Bool": {"aws:SecureTransport": ["false"]}}
902            },
903            {"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::b/*"}]
904        }"#);
905        let plain = RequestContext {
906            secure_transport: false,
907            ..Default::default()
908        };
909        let tls = RequestContext {
910            secure_transport: true,
911            ..Default::default()
912        };
913        // Plain HTTP → SecureTransport=false → Deny matches
914        assert!(
915            !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &plain)
916                .allow
917        );
918        // TLS → SecureTransport=true → Deny condition fails → Allow remains
919        assert!(
920            pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &tls)
921                .allow
922        );
923    }
924
925    #[test]
926    fn condition_unknown_operator_rejected() {
927        let err = Policy::from_json_str(
928            r#"{
929            "Statement": [{"Effect": "Allow", "Action": "s3:*",
930              "Resource": "arn:aws:s3:::b/*",
931              "Condition": {"NumericGreaterThan": {"k": ["1"]}}
932            }]
933        }"#,
934        )
935        .expect_err("should reject unsupported operator");
936        assert!(err.contains("unsupported policy Condition operator"));
937        assert!(err.contains("NumericGreaterThan"));
938    }
939
940    // ===== v0.6 #39 tag-based condition tests =====
941
942    #[test]
943    fn condition_existing_object_tag_matches_via_tagmanager_state() {
944        let pol = p(r#"{
945            "Statement": [{
946              "Effect": "Allow", "Action": "s3:GetObject",
947              "Resource": "arn:aws:s3:::b/*",
948              "Condition": {
949                "StringEquals": {"s3:ExistingObjectTag/Project": ["Phoenix"]}
950              }
951            }]
952        }"#);
953        let with_tag = RequestContext {
954            existing_object_tags: Some(
955                crate::tagging::TagSet::from_pairs(vec![
956                    ("Project".into(), "Phoenix".into()),
957                    ("Env".into(), "prod".into()),
958                ])
959                .unwrap(),
960            ),
961            ..Default::default()
962        };
963        let other_tag = RequestContext {
964            existing_object_tags: Some(
965                crate::tagging::TagSet::from_pairs(vec![("Project".into(), "Other".into())])
966                    .unwrap(),
967            ),
968            ..Default::default()
969        };
970        // Tag matches → Allow.
971        assert!(
972            pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &with_tag)
973                .allow
974        );
975        // Tag value mismatched → implicit deny.
976        assert!(
977            !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &other_tag)
978                .allow
979        );
980    }
981
982    #[test]
983    fn condition_request_object_tag_matches_via_x_amz_tagging() {
984        let pol = p(r#"{
985            "Statement": [{
986              "Effect": "Allow", "Action": "s3:PutObject",
987              "Resource": "arn:aws:s3:::b/*",
988              "Condition": {
989                "StringEquals": {"s3:RequestObjectTag/Env": ["prod", "staging"]}
990              }
991            }]
992        }"#);
993        let req_tags = |v: &str| RequestContext {
994            request_object_tags: Some(
995                crate::tagging::TagSet::from_pairs(vec![("Env".into(), v.into())]).unwrap(),
996            ),
997            ..Default::default()
998        };
999        assert!(
1000            pol.evaluate_with("s3:PutObject", "b", Some("k"), None, &req_tags("prod"))
1001                .allow
1002        );
1003        assert!(
1004            pol.evaluate_with(
1005                "s3:PutObject",
1006                "b",
1007                Some("k"),
1008                None,
1009                &req_tags("staging")
1010            )
1011            .allow
1012        );
1013        assert!(
1014            !pol.evaluate_with("s3:PutObject", "b", Some("k"), None, &req_tags("dev"))
1015                .allow
1016        );
1017    }
1018
1019    #[test]
1020    fn condition_tag_not_present_fails_closed() {
1021        // Statement gates on a tag the request doesn't carry → the
1022        // clause must fail (not silently match), so the only Allow is
1023        // skipped and we get implicit deny.
1024        let pol = p(r#"{
1025            "Statement": [{
1026              "Effect": "Allow", "Action": "s3:GetObject",
1027              "Resource": "arn:aws:s3:::b/*",
1028              "Condition": {
1029                "StringEquals": {"s3:ExistingObjectTag/Owner": ["alice"]}
1030              }
1031            }]
1032        }"#);
1033        // No `existing_object_tags` at all → tag look-up returns None
1034        // → clause fails → statement skipped.
1035        let none_ctx = RequestContext::default();
1036        assert!(
1037            !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &none_ctx)
1038                .allow
1039        );
1040        // Tag set exists but lacks the named key → also fails.
1041        let other_only = RequestContext {
1042            existing_object_tags: Some(
1043                crate::tagging::TagSet::from_pairs(vec![("Project".into(), "X".into())])
1044                    .unwrap(),
1045            ),
1046            ..Default::default()
1047        };
1048        assert!(
1049            !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &other_only)
1050                .allow
1051        );
1052    }
1053
1054    #[test]
1055    fn condition_legacy_evaluate_unchanged() {
1056        // Old `evaluate` (no context) still works: a policy without
1057        // Condition clauses is unaffected by the v0.3 changes.
1058        let pol = p(r#"{
1059            "Statement": [{"Effect": "Allow", "Action": "s3:*",
1060              "Resource": "arn:aws:s3:::b/*"}]
1061        }"#);
1062        assert!(pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
1063    }
1064}