1use 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 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#[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>, resources: Vec<String>, 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 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 }
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 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
233fn 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
245fn resource_matches(pattern: &str, resource: &str) -> bool {
248 glob_match(pattern, resource)
249}
250
251fn 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 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
296pub 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 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 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}