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::path::Path;
32use std::sync::Arc;
33
34use serde::Deserialize;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
37#[serde(rename_all = "PascalCase")]
38pub enum Effect {
39    Allow,
40    Deny,
41}
42
43#[derive(Debug, Clone, Deserialize)]
44#[serde(untagged)]
45enum StringOrVec {
46    Single(String),
47    Many(Vec<String>),
48}
49
50impl StringOrVec {
51    fn into_vec(self) -> Vec<String> {
52        match self {
53            Self::Single(s) => vec![s],
54            Self::Many(v) => v,
55        }
56    }
57}
58
59#[derive(Debug, Clone, Deserialize)]
60#[serde(untagged)]
61enum PrincipalSet {
62    /// `"Principal": "*"` — JSON string form. The string content is
63    /// untyped (only the string variant matters), so we accept any but
64    /// don't read the value.
65    Wildcard(#[allow(dead_code)] String),
66    Map {
67        #[serde(rename = "AWS", default)]
68        aws: Option<StringOrVec>,
69    },
70}
71
72#[derive(Debug, Clone, Deserialize)]
73struct StatementJson {
74    #[serde(rename = "Sid")]
75    sid: Option<String>,
76    #[serde(rename = "Effect")]
77    effect: Effect,
78    #[serde(rename = "Action")]
79    action: StringOrVec,
80    #[serde(rename = "Resource")]
81    resource: StringOrVec,
82    #[serde(rename = "Principal", default)]
83    principal: Option<PrincipalSet>,
84}
85
86#[derive(Debug, Clone, Deserialize)]
87struct PolicyJson {
88    #[serde(rename = "Version")]
89    _version: Option<String>,
90    #[serde(rename = "Statement")]
91    statements: Vec<StatementJson>,
92}
93
94/// Compiled bucket policy ready to evaluate requests.
95#[derive(Debug, Clone)]
96pub struct Policy {
97    statements: Vec<Statement>,
98}
99
100#[derive(Debug, Clone)]
101struct Statement {
102    sid: Option<String>,
103    effect: Effect,
104    actions: Vec<String>,   // `s3:GetObject`, `s3:*`, `*`
105    resources: Vec<String>, // `arn:aws:s3:::bucket/key*`
106    /// `None` = no Principal field = match anyone (for resource-attached
107    /// bucket policies the convention is to require Principal, but for our
108    /// gateway we treat absence as "any authenticated caller").
109    /// `Some(vec![])` after parsing wildcard "*" = same effect.
110    /// `Some(vec!["AKIA..."])` = match those access key ids.
111    /// An empty `principals` vector means "wildcard (any principal)".
112    principals: Option<Vec<String>>,
113}
114
115impl Policy {
116    pub fn from_json_str(s: &str) -> Result<Self, String> {
117        let raw: PolicyJson =
118            serde_json::from_str(s).map_err(|e| format!("policy JSON parse error: {e}"))?;
119        let mut statements = Vec::with_capacity(raw.statements.len());
120        for s in raw.statements {
121            statements.push(Statement {
122                sid: s.sid,
123                effect: s.effect,
124                actions: s.action.into_vec(),
125                resources: s.resource.into_vec(),
126                principals: s.principal.map(|p| match p {
127                    PrincipalSet::Wildcard(_) => Vec::new(),
128                    PrincipalSet::Map { aws } => aws.map(|v| v.into_vec()).unwrap_or_default(),
129                }),
130            });
131        }
132        Ok(Self { statements })
133    }
134
135    pub fn from_path(path: &Path) -> Result<Self, String> {
136        let txt = std::fs::read_to_string(path)
137            .map_err(|e| format!("failed to read {}: {e}", path.display()))?;
138        Self::from_json_str(&txt)
139    }
140
141    /// Evaluate a request against the policy.
142    ///
143    /// `principal_id` is typically the SigV4 access key id taken from the
144    /// authenticated request. Pass `None` for anonymous (will only match
145    /// statements with wildcard or absent Principal).
146    pub fn evaluate(
147        &self,
148        action: &str,
149        bucket: &str,
150        key: Option<&str>,
151        principal_id: Option<&str>,
152    ) -> Decision {
153        let object_resource = match key {
154            Some(k) => format!("arn:aws:s3:::{bucket}/{k}"),
155            None => format!("arn:aws:s3:::{bucket}"),
156        };
157        let bucket_resource = format!("arn:aws:s3:::{bucket}");
158
159        let mut matched_allow: Option<Option<String>> = None;
160        let mut matched_deny: Option<Option<String>> = None;
161
162        for st in &self.statements {
163            if !st.actions.iter().any(|p| action_matches(p, action)) {
164                continue;
165            }
166            let any_resource_matches = st.resources.iter().any(|p| {
167                resource_matches(p, &object_resource) || resource_matches(p, &bucket_resource)
168            });
169            if !any_resource_matches {
170                continue;
171            }
172            if !principal_matches(&st.principals, principal_id) {
173                continue;
174            }
175            match st.effect {
176                Effect::Deny => {
177                    matched_deny = Some(st.sid.clone());
178                    // Any explicit Deny wins; no need to keep scanning, but
179                    // continue so the matched Sid reflects the LAST matching
180                    // Deny (deterministic for telemetry).
181                }
182                Effect::Allow => {
183                    if matched_allow.is_none() {
184                        matched_allow = Some(st.sid.clone());
185                    }
186                }
187            }
188        }
189
190        if let Some(sid) = matched_deny {
191            Decision::deny(sid)
192        } else if let Some(sid) = matched_allow {
193            Decision::allow(sid)
194        } else {
195            Decision::implicit_deny()
196        }
197    }
198}
199
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct Decision {
202    pub allow: bool,
203    pub matched_sid: Option<String>,
204    /// `None` = implicit deny (no statement matched), `Some(Allow|Deny)` =
205    /// explicit decision.
206    pub matched_effect: Option<Effect>,
207}
208
209impl Decision {
210    fn allow(sid: Option<String>) -> Self {
211        Self {
212            allow: true,
213            matched_sid: sid,
214            matched_effect: Some(Effect::Allow),
215        }
216    }
217    fn deny(sid: Option<String>) -> Self {
218        Self {
219            allow: false,
220            matched_sid: sid,
221            matched_effect: Some(Effect::Deny),
222        }
223    }
224    fn implicit_deny() -> Self {
225        Self {
226            allow: false,
227            matched_sid: None,
228            matched_effect: None,
229        }
230    }
231}
232
233/// Match an action pattern against a concrete action.
234/// Patterns: `*`, `s3:*`, `s3:GetObject`. Case-sensitive (AWS is too).
235fn action_matches(pattern: &str, action: &str) -> bool {
236    if pattern == "*" {
237        return true;
238    }
239    if let Some(prefix) = pattern.strip_suffix(":*") {
240        return action.starts_with(prefix) && action[prefix.len()..].starts_with(':');
241    }
242    pattern == action
243}
244
245/// Match a resource ARN pattern against a concrete resource ARN. Supports
246/// `*` and `?` glob characters.
247fn resource_matches(pattern: &str, resource: &str) -> bool {
248    glob_match(pattern, resource)
249}
250
251/// Hand-rolled glob (`*` = any sequence, `?` = any single char) so we don't
252/// pull in the `globset` crate for a single use site.
253fn glob_match(pattern: &str, s: &str) -> bool {
254    let p_bytes = pattern.as_bytes();
255    let s_bytes = s.as_bytes();
256    glob_match_bytes(p_bytes, s_bytes)
257}
258
259fn glob_match_bytes(p: &[u8], s: &[u8]) -> bool {
260    let mut pi = 0;
261    let mut si = 0;
262    let mut star: Option<(usize, usize)> = None;
263    while si < s.len() {
264        if pi < p.len() && (p[pi] == b'?' || p[pi] == s[si]) {
265            pi += 1;
266            si += 1;
267        } else if pi < p.len() && p[pi] == b'*' {
268            star = Some((pi, si));
269            pi += 1;
270        } else if let Some((sp, ss)) = star {
271            pi = sp + 1;
272            si = ss + 1;
273            star = Some((sp, si));
274        } else {
275            return false;
276        }
277    }
278    while pi < p.len() && p[pi] == b'*' {
279        pi += 1;
280    }
281    pi == p.len()
282}
283
284fn principal_matches(allowed: &Option<Vec<String>>, principal_id: Option<&str>) -> bool {
285    match allowed {
286        // No Principal field on the statement → match any caller (incl. anonymous).
287        None => true,
288        Some(list) if list.is_empty() => true,
289        Some(list) => match principal_id {
290            None => false,
291            Some(id) => list.iter().any(|p| p == "*" || p == id),
292        },
293    }
294}
295
296/// Wrap a Policy in an Arc so cloning the S4Service stays cheap.
297pub type SharedPolicy = Arc<Policy>;
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    fn p(s: &str) -> Policy {
304        Policy::from_json_str(s).expect("policy")
305    }
306
307    #[test]
308    fn allow_then_deny_explicit_deny_wins() {
309        let pol = p(r#"{
310            "Version": "2012-10-17",
311            "Statement": [
312              {"Sid": "AllowAll", "Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::b/*"},
313              {"Sid": "DenyDelete", "Effect": "Deny", "Action": "s3:DeleteObject", "Resource": "arn:aws:s3:::b/*"}
314            ]
315        }"#);
316        let d = pol.evaluate("s3:GetObject", "b", Some("k"), None);
317        assert!(d.allow);
318        assert_eq!(d.matched_sid.as_deref(), Some("AllowAll"));
319        let d = pol.evaluate("s3:DeleteObject", "b", Some("k"), None);
320        assert!(!d.allow);
321        assert_eq!(d.matched_effect, Some(Effect::Deny));
322        assert_eq!(d.matched_sid.as_deref(), Some("DenyDelete"));
323    }
324
325    #[test]
326    fn implicit_deny_when_no_statement_matches() {
327        let pol = p(r#"{
328            "Version": "2012-10-17",
329            "Statement": [
330              {"Effect": "Allow", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::other/*"}
331            ]
332        }"#);
333        let d = pol.evaluate("s3:GetObject", "mine", Some("k"), None);
334        assert!(!d.allow);
335        assert_eq!(d.matched_effect, None);
336    }
337
338    #[test]
339    fn resource_glob_matches_prefix() {
340        let pol = p(r#"{
341            "Version": "2012-10-17",
342            "Statement": [{
343              "Effect": "Allow",
344              "Action": "s3:GetObject",
345              "Resource": "arn:aws:s3:::b/data/*.parquet"
346            }]
347        }"#);
348        assert!(
349            pol.evaluate("s3:GetObject", "b", Some("data/foo.parquet"), None)
350                .allow
351        );
352        assert!(
353            pol.evaluate("s3:GetObject", "b", Some("data/sub/bar.parquet"), None)
354                .allow
355        );
356        assert!(
357            !pol.evaluate("s3:GetObject", "b", Some("data/foo.txt"), None)
358                .allow
359        );
360    }
361
362    #[test]
363    fn s3_action_wildcard() {
364        let pol = p(r#"{
365            "Version": "2012-10-17",
366            "Statement": [{"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::*"}]
367        }"#);
368        assert!(pol.evaluate("s3:GetObject", "any", Some("k"), None).allow);
369        assert!(pol.evaluate("s3:PutObject", "any", Some("k"), None).allow);
370        // Non-s3 action would not match (we don't generate any non-s3 actions
371        // from S4Service handlers, but verify the matcher behaves correctly)
372        assert!(!pol.evaluate("iam:ListUsers", "any", None, None).allow);
373    }
374
375    #[test]
376    fn principal_match_by_access_key_id() {
377        let pol = p(r#"{
378            "Version": "2012-10-17",
379            "Statement": [{
380              "Effect": "Allow",
381              "Action": "s3:*",
382              "Resource": "arn:aws:s3:::b/*",
383              "Principal": {"AWS": ["AKIATEST123"]}
384            }]
385        }"#);
386        assert!(
387            pol.evaluate("s3:GetObject", "b", Some("k"), Some("AKIATEST123"))
388                .allow
389        );
390        assert!(
391            !pol.evaluate("s3:GetObject", "b", Some("k"), Some("AKIAOTHER"))
392                .allow
393        );
394        assert!(!pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
395    }
396
397    #[test]
398    fn principal_wildcard_matches_anyone() {
399        let pol = p(r#"{
400            "Version": "2012-10-17",
401            "Statement": [{
402              "Effect": "Allow",
403              "Action": "s3:*",
404              "Resource": "arn:aws:s3:::b/*",
405              "Principal": "*"
406            }]
407        }"#);
408        assert!(
409            pol.evaluate("s3:GetObject", "b", Some("k"), Some("AKIAANY"))
410                .allow
411        );
412        assert!(pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
413    }
414
415    #[test]
416    fn resource_can_be_string_or_array() {
417        let single = p(r#"{
418            "Statement": [{"Effect": "Allow", "Action": "s3:GetObject",
419                          "Resource": "arn:aws:s3:::a/*"}]
420        }"#);
421        let multi = p(r#"{
422            "Statement": [{"Effect": "Allow", "Action": "s3:GetObject",
423                          "Resource": ["arn:aws:s3:::a/*", "arn:aws:s3:::b/*"]}]
424        }"#);
425        assert!(single.evaluate("s3:GetObject", "a", Some("k"), None).allow);
426        assert!(!single.evaluate("s3:GetObject", "b", Some("k"), None).allow);
427        assert!(multi.evaluate("s3:GetObject", "b", Some("k"), None).allow);
428    }
429
430    #[test]
431    fn bucket_level_resource_for_listbucket() {
432        let pol = p(r#"{
433            "Statement": [{"Effect": "Allow", "Action": "s3:ListBucket",
434                          "Resource": "arn:aws:s3:::b"}]
435        }"#);
436        // ListBucket uses a key=None resource, formatted as bucket-only ARN
437        assert!(pol.evaluate("s3:ListBucket", "b", None, None).allow);
438        assert!(!pol.evaluate("s3:ListBucket", "other", None, None).allow);
439    }
440
441    #[test]
442    fn glob_match_basics() {
443        assert!(glob_match("foo", "foo"));
444        assert!(!glob_match("foo", "bar"));
445        assert!(glob_match("*", "anything"));
446        assert!(glob_match("foo*", "foobar"));
447        assert!(glob_match("*bar", "foobar"));
448        assert!(glob_match("foo*bar", "fooXYZbar"));
449        assert!(glob_match("a?c", "abc"));
450        assert!(!glob_match("a?c", "abbc"));
451        assert!(glob_match("a*b*c", "axxxbyyyc"));
452    }
453}