Skip to content

Commit

Permalink
feat(s3s-policy): add SimpleEvaluator
Browse files Browse the repository at this point in the history
  • Loading branch information
Nugine committed Oct 3, 2024
1 parent 65e1b3b commit 3fabe50
Show file tree
Hide file tree
Showing 6 changed files with 447 additions and 63 deletions.
2 changes: 2 additions & 0 deletions crates/s3s-policy/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,7 @@ license.workspace = true

[dependencies]
indexmap = { version = "2.5.0", features = ["serde"] }
s3s = { version = "0.11.0-dev", path = "../s3s" }
serde = { version = "1.0.210", features = ["derive"] }
serde_json = "1.0.128"
thiserror = "1.0.64"
206 changes: 206 additions & 0 deletions crates/s3s-policy/src/eval.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
use crate::model::WildcardOneOrMore;
use crate::model::{ActionRule, ResourceRule};
use crate::model::{Effect, Policy, Statement};
use crate::pattern::{PatternError, PatternSet};

use s3s::auth::S3AuthContext;
use s3s::path::S3Path;

pub struct SimpleEvaluator {
rules: Vec<ParsedRule>,
}

#[derive(Debug, thiserror::Error)]
pub enum EvalError {
#[error("Not supported")]
NotSupported,

#[error("Invalid pattern")]
InvalidPattern,
}

struct ParsedRule {
effect: Effect,
action: Option<PatternSet>,
not_action: Option<PatternSet>,
resource: Option<PatternSet>,
not_resource: Option<PatternSet>,
}

impl SimpleEvaluator {
/// Create a new evaluator from a policy.
///
/// # Errors
/// Returns an error if the policy contains unsupported features.
pub fn new(policy: &Policy) -> Result<Self, EvalError> {
// TODO
for statement in policy.statement.as_slice() {
if statement.principal.is_some() {
return Err(EvalError::NotSupported);
}
if statement.condition.is_some() {
return Err(EvalError::NotSupported);
}
}

let rules = policy
.statement
.as_slice()
.iter()
.map(Self::parse_rule)
.collect::<Result<_, _>>()?;
Ok(SimpleEvaluator { rules })
}

fn parse_rule(stmt: &Statement) -> Result<ParsedRule, EvalError> {
let mut action = None;
let mut not_action = None;
let mut resource = None;
let mut not_resource = None;

let handle_error = |e: PatternError| match e {
PatternError::InvalidPattern => EvalError::InvalidPattern,
};

let build = |w: &WildcardOneOrMore<String>| match w.as_slice() {
Some(w) => PatternSet::new(w.iter().map(String::as_str)).map_err(handle_error),
None => PatternSet::new(["*"]).map_err(handle_error),
};

match &stmt.action {
ActionRule::Action(w) => action = Some(build(w)?),
ActionRule::NotAction(w) => not_action = Some(build(w)?),
}

match &stmt.resource {
ResourceRule::Resource(w) => resource = Some(build(w)?),
ResourceRule::NotResource(w) => not_resource = Some(build(w)?),
}

Ok(ParsedRule {
effect: stmt.effect.clone(),
action,
not_action,
resource,
not_resource,
})
}

fn s3_op_to_action(op_name: &str) -> String {
// FIXME: some op names are not exactly the same as the action names
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-with-s3-policy-actions.html

// TODO: replace this with a static table
if op_name == "ListBuckets" {
return "s3:ListAllMyBuckets".to_owned();
}

format!("s3:{op_name}")
}

fn s3_path_to_resource(path: &S3Path) -> String {
match path {
S3Path::Root => "arn:aws:s3:::".to_owned(),
S3Path::Bucket { bucket } => format!("arn:aws:s3:::{bucket}"),
S3Path::Object { bucket, key } => format!("arn:aws:s3:::{bucket}/{key}"),
}
}

/// Check if the request is allowed by the policy.
///
/// # Errors
///
pub fn check_access(&self, cx: &mut S3AuthContext<'_>) -> Result<Effect, EvalError> {
Ok(self.check(cx.s3_op().name(), cx.s3_path()))
}

fn check(&self, op_name: &str, path: &S3Path) -> Effect {
let action = Self::s3_op_to_action(op_name);
let resource = Self::s3_path_to_resource(path);

let mut allow_count = 0;
for rule in &self.rules {
let mut action_matched = false;
if let Some(ps) = &rule.action {
if ps.is_match(&action) {
action_matched = true;
}
} else if let Some(ps) = &rule.not_action {
if !ps.is_match(&action) {
action_matched = true;
}
}

let mut resource_matched = false;
if let Some(ps) = &rule.resource {
if ps.is_match(&resource) {
resource_matched = true;
}
} else if let Some(ps) = &rule.not_resource {
if !ps.is_match(&resource) {
resource_matched = true;
}
}

let matched = action_matched && resource_matched;

if matched {
match rule.effect {
Effect::Allow => {
allow_count += 1;
}
Effect::Deny => {
return Effect::Deny;
}
}
}
}

if allow_count > 0 {
return Effect::Allow;
}

Effect::Deny
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn example1() {
let json = crate::tests::example1_json();
let mut policy: Policy = serde_json::from_str(json).unwrap();

// TODO
for stmt in policy.statement.as_mut_slice() {
stmt.principal = None;
stmt.condition = None;
}

let evaluator = SimpleEvaluator::new(&policy).unwrap();

{
let ans = evaluator.check("ListBuckets", &S3Path::root());
assert_eq!(ans, Effect::Allow);
}
{
let ans = evaluator.check("ListObjectsV2", &S3Path::bucket("confidential-data"));
assert_eq!(ans, Effect::Allow);

let ans = evaluator.check("GetObject", &S3Path::object("confidential-data", "file.txt"));
assert_eq!(ans, Effect::Allow);

let ans = evaluator.check("PutObject", &S3Path::bucket("confidential-data"));
assert_eq!(ans, Effect::Deny);
}
{
let ans = evaluator.check("ListObjectsV2", &S3Path::bucket("another-bucket"));
assert_eq!(ans, Effect::Deny);

let ans = evaluator.check("GetObject", &S3Path::object("another-bucket", "file.txt"));
assert_eq!(ans, Effect::Deny);
}
}
}
9 changes: 9 additions & 0 deletions crates/s3s-policy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,14 @@
#![warn(
clippy::dbg_macro, //
)]
#![allow(
clippy::module_name_repetitions, //
)]

mod pattern;

pub mod eval;
pub mod model;

#[cfg(test)]
mod tests;
101 changes: 38 additions & 63 deletions crates/s3s-policy/src/model.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! <https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_grammar.html>

use std::marker::PhantomData;
use std::slice;

use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -280,6 +281,40 @@ where
}
}

impl<T> OneOrMore<T> {
pub fn as_slice(&self) -> &[T] {
match self {
OneOrMore::One(t) => slice::from_ref(t),
OneOrMore::More(ts) => ts,
}
}

pub fn as_mut_slice(&mut self) -> &mut [T] {
match self {
OneOrMore::One(t) => slice::from_mut(t),
OneOrMore::More(ts) => ts,
}
}
}

impl<T> WildcardOneOrMore<T> {
pub fn as_slice(&self) -> Option<&[T]> {
match self {
WildcardOneOrMore::Wildcard => None,
WildcardOneOrMore::One(t) => Some(slice::from_ref(t)),
WildcardOneOrMore::More(ts) => Some(ts),
}
}

pub fn as_mut_slice(&mut self) -> Option<&mut [T]> {
match self {
WildcardOneOrMore::Wildcard => None,
WildcardOneOrMore::One(t) => Some(slice::from_mut(t)),
WildcardOneOrMore::More(ts) => Some(ts),
}
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -457,42 +492,9 @@ mod tests {
}
}

/// <https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies.html#access_policies-json>
#[test]
fn example1() {
let json = r#"
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "FirstStatement",
"Effect": "Allow",
"Action": ["iam:ChangePassword"],
"Resource": "*"
},
{
"Sid": "SecondStatement",
"Effect": "Allow",
"Action": "s3:ListAllMyBuckets",
"Resource": "*"
},
{
"Sid": "ThirdStatement",
"Effect": "Allow",
"Action": [
"s3:List*",
"s3:Get*"
],
"Resource": [
"arn:aws:s3:::confidential-data",
"arn:aws:s3:::confidential-data/*"
],
"Condition": {"Bool": {"aws:MultiFactorAuthPresent": "true"}}
}
]
}
"#;

let json = crate::tests::example1_json();
let policy: Policy = serde_json::from_str(json).unwrap();

let expected = Policy {
Expand Down Expand Up @@ -541,20 +543,9 @@ mod tests {
}
}

/// <https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies.html#access_policies-json>
#[test]
fn example2() {
let json = r#"
{
"Version": "2012-10-17",
"Statement": {
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::example_bucket"
}
}
"#;

let json = crate::tests::example2_json();
let policy: Policy = serde_json::from_str(json).unwrap();

let expected = Policy {
Expand All @@ -579,25 +570,9 @@ mod tests {
}
}

/// <https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies.html#access_policies-json>
#[test]
fn example3() {
let json = r#"
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "1",
"Effect": "Allow",
"Principal": {"AWS": ["arn:aws:iam::account-id:root"]},
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::mybucket",
"arn:aws:s3:::mybucket/*"
]
}]
}
"#;

let json = crate::tests::example3_json();
let policy: Policy = serde_json::from_str(json).unwrap();

let expected = Policy {
Expand Down
Loading

0 comments on commit 3fabe50

Please sign in to comment.