use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct PolicyDocument {
#[serde(default)]
pub version: Option<String>,
pub statement: Vec<PolicyStatement>,
}
impl PolicyDocument {
pub fn from_json(json_str: &str) -> Option<Self> {
serde_json::from_str(json_str).ok()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct PolicyStatement {
#[serde(default)]
pub sid: Option<String>,
pub effect: Effect,
#[serde(default)]
pub principal: PrincipalValue,
#[serde(default)]
pub action: StringOrArray,
#[serde(default)]
pub resource: StringOrArray,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub condition: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Effect {
Allow,
Deny,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PrincipalValue {
Typed(HashMap<String, StringOrArray>),
Wildcard(String),
}
impl PrincipalValue {
pub fn flatten(&self) -> Vec<String> {
match self {
PrincipalValue::Wildcard(s) => vec![s.clone()],
PrincipalValue::Typed(map) => map
.values()
.flat_map(|v| v.as_slice().into_iter().map(|s| s.to_string()))
.collect(),
}
}
}
impl Default for PrincipalValue {
fn default() -> Self {
PrincipalValue::Wildcard(String::new())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum StringOrArray {
Single(String),
Multiple(Vec<String>),
}
impl StringOrArray {
pub fn as_slice(&self) -> Vec<&str> {
match self {
StringOrArray::Single(s) => vec![s.as_str()],
StringOrArray::Multiple(v) => v.iter().map(|s| s.as_str()).collect(),
}
}
}
impl Default for StringOrArray {
fn default() -> Self {
StringOrArray::Multiple(vec![])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_json_parses_minimal_policy() {
let json = r#"{"Version":"2012-10-17","Statement":[]}"#;
let doc = PolicyDocument::from_json(json).unwrap();
assert_eq!(doc.version.as_deref(), Some("2012-10-17"));
assert!(doc.statement.is_empty());
}
#[test]
fn from_json_returns_none_for_invalid_json() {
assert!(PolicyDocument::from_json("not json").is_none());
assert!(PolicyDocument::from_json(r#"{"no_statement_key": true}"#).is_none());
}
#[test]
fn from_json_parses_wildcard_principal_and_string_action() {
let json = r#"{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-bucket/*"
}]
}"#;
let doc = PolicyDocument::from_json(json).unwrap();
assert_eq!(doc.statement.len(), 1);
let stmt = &doc.statement[0];
assert_eq!(stmt.effect, Effect::Allow);
assert!(matches!(&stmt.principal, PrincipalValue::Wildcard(s) if s == "*"));
assert_eq!(stmt.action.as_slice(), vec!["s3:GetObject"]);
assert_eq!(stmt.resource.as_slice(), vec!["arn:aws:s3:::my-bucket/*"]);
}
#[test]
fn from_json_parses_typed_principal_and_array_action() {
let json = r#"{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Deny",
"Principal": {"AWS": ["arn:aws:iam::123456789012:root", "arn:aws:iam::111111111111:role/Foo"]},
"Action": ["s3:DeleteObject", "s3:DeleteBucket"],
"Resource": "*"
}]
}"#;
let doc = PolicyDocument::from_json(json).unwrap();
let stmt = &doc.statement[0];
assert_eq!(stmt.effect, Effect::Deny);
let principals = stmt.principal.flatten();
assert_eq!(principals.len(), 2);
assert!(principals.contains(&"arn:aws:iam::123456789012:root".to_string()));
assert_eq!(stmt.action.as_slice().len(), 2);
assert!(stmt.action.as_slice().contains(&"s3:DeleteObject"));
}
#[test]
fn from_json_parses_service_principal() {
let json = r#"{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "cloudtrail.amazonaws.com"},
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::my-bucket/AWSLogs/*"
}]
}"#;
let doc = PolicyDocument::from_json(json).unwrap();
let stmt = &doc.statement[0];
let principals = stmt.principal.flatten();
assert_eq!(principals, vec!["cloudtrail.amazonaws.com"]);
}
#[test]
fn from_json_parses_condition_block() {
let json = r#"{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "*",
"Condition": {"StringEquals": {"aws:PrincipalOrgID": ["o-abc123"]}}
}]
}"#;
let doc = PolicyDocument::from_json(json).unwrap();
assert!(doc.statement[0].condition.is_some());
}
#[test]
fn from_json_parses_sid() {
let json = r#"{
"Version": "2012-10-17",
"Statement": [{"Sid": "AllowPublicRead", "Effect": "Allow", "Principal": "*", "Action": "s3:GetObject", "Resource": "*"}]
}"#;
let doc = PolicyDocument::from_json(json).unwrap();
assert_eq!(doc.statement[0].sid.as_deref(), Some("AllowPublicRead"));
}
#[test]
fn principal_wildcard_flatten() {
let p = PrincipalValue::Wildcard("*".into());
assert_eq!(p.flatten(), vec!["*"]);
}
#[test]
fn principal_typed_flatten_multiple_categories() {
let mut map = HashMap::new();
map.insert(
"AWS".into(),
StringOrArray::Multiple(vec!["arn:aws:iam::1:root".into()]),
);
map.insert(
"Service".into(),
StringOrArray::Single("lambda.amazonaws.com".into()),
);
let p = PrincipalValue::Typed(map);
let mut flat = p.flatten();
flat.sort();
assert_eq!(flat.len(), 2);
assert!(flat.contains(&"arn:aws:iam::1:root".to_string()));
assert!(flat.contains(&"lambda.amazonaws.com".to_string()));
}
#[test]
fn string_or_array_single_as_slice() {
let s = StringOrArray::Single("s3:GetObject".into());
assert_eq!(s.as_slice(), vec!["s3:GetObject"]);
}
#[test]
fn string_or_array_multiple_as_slice() {
let s = StringOrArray::Multiple(vec!["s3:GetObject".into(), "s3:PutObject".into()]);
assert_eq!(s.as_slice(), vec!["s3:GetObject", "s3:PutObject"]);
}
#[test]
fn string_or_array_default_is_empty_multiple() {
let s = StringOrArray::default();
assert!(s.as_slice().is_empty());
}
}