use std::net::IpAddr;
use chrono::{DateTime, Utc};
use serde_json::Value;
pub use fakecloud_core::auth::ConditionContext;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConditionOperator {
StringEquals,
StringNotEquals,
StringEqualsIgnoreCase,
StringNotEqualsIgnoreCase,
StringLike,
StringNotLike,
NumericEquals,
NumericNotEquals,
NumericLessThan,
NumericLessThanEquals,
NumericGreaterThan,
NumericGreaterThanEquals,
DateEquals,
DateNotEquals,
DateLessThan,
DateLessThanEquals,
DateGreaterThan,
DateGreaterThanEquals,
Bool,
BinaryEquals,
IpAddress,
NotIpAddress,
ArnEquals,
ArnNotEquals,
ArnLike,
ArnNotLike,
Null,
}
impl ConditionOperator {
fn from_str(name: &str) -> Option<Self> {
Some(match name {
"StringEquals" => Self::StringEquals,
"StringNotEquals" => Self::StringNotEquals,
"StringEqualsIgnoreCase" => Self::StringEqualsIgnoreCase,
"StringNotEqualsIgnoreCase" => Self::StringNotEqualsIgnoreCase,
"StringLike" => Self::StringLike,
"StringNotLike" => Self::StringNotLike,
"NumericEquals" => Self::NumericEquals,
"NumericNotEquals" => Self::NumericNotEquals,
"NumericLessThan" => Self::NumericLessThan,
"NumericLessThanEquals" => Self::NumericLessThanEquals,
"NumericGreaterThan" => Self::NumericGreaterThan,
"NumericGreaterThanEquals" => Self::NumericGreaterThanEquals,
"DateEquals" => Self::DateEquals,
"DateNotEquals" => Self::DateNotEquals,
"DateLessThan" => Self::DateLessThan,
"DateLessThanEquals" => Self::DateLessThanEquals,
"DateGreaterThan" => Self::DateGreaterThan,
"DateGreaterThanEquals" => Self::DateGreaterThanEquals,
"Bool" => Self::Bool,
"BinaryEquals" => Self::BinaryEquals,
"IpAddress" => Self::IpAddress,
"NotIpAddress" => Self::NotIpAddress,
"ArnEquals" => Self::ArnEquals,
"ArnNotEquals" => Self::ArnNotEquals,
"ArnLike" => Self::ArnLike,
"ArnNotLike" => Self::ArnNotLike,
"Null" => Self::Null,
_ => return None,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Qualifier {
Single,
ForAnyValue,
ForAllValues,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ParsedOperatorName {
pub op: ConditionOperator,
pub if_exists: bool,
pub qualifier: Qualifier,
}
impl ParsedOperatorName {
pub fn parse(raw: &str) -> Option<Self> {
let (qualifier, rest) = if let Some(s) = raw.strip_prefix("ForAllValues:") {
(Qualifier::ForAllValues, s)
} else if let Some(s) = raw.strip_prefix("ForAnyValue:") {
(Qualifier::ForAnyValue, s)
} else {
(Qualifier::Single, raw)
};
let (base, if_exists) = if let Some(s) = rest.strip_suffix("IfExists") {
(s, true)
} else {
(rest, false)
};
ConditionOperator::from_str(base).map(|op| Self {
op,
if_exists,
qualifier,
})
}
}
#[derive(Debug, Clone)]
pub struct ParsedCondition {
pub operator: ParsedOperatorName,
pub key: String,
pub values: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct CompiledCondition {
pub entries: Vec<ParsedCondition>,
}
impl CompiledCondition {
pub fn parse(value: &Value) -> Self {
let mut out = Self::default();
let Some(obj) = value.as_object() else {
return out;
};
for (op_name, key_map) in obj {
let Some(operator) = ParsedOperatorName::parse(op_name) else {
out.entries.push(ParsedCondition {
operator: ParsedOperatorName {
op: ConditionOperator::Null,
if_exists: false,
qualifier: Qualifier::Single,
},
key: format!("__unknown_operator__:{op_name}"),
values: Vec::new(),
});
continue;
};
let Some(inner) = key_map.as_object() else {
continue;
};
for (key, values) in inner {
let values = coerce_value_list(values);
out.entries.push(ParsedCondition {
operator,
key: key.clone(),
values,
});
}
}
out
}
pub fn matches(&self, ctx: &ConditionContext) -> bool {
for entry in &self.entries {
if entry.key.starts_with("__unknown_operator__:") {
let op_name = entry.key.trim_start_matches("__unknown_operator__:");
tracing::debug!(
target: "fakecloud::iam::audit",
operator = %op_name,
"unknown condition operator; treating statement as non-applicable"
);
return false;
}
if !evaluate_entry(entry, ctx) {
return false;
}
}
true
}
}
fn coerce_value_list(value: &Value) -> Vec<String> {
match value {
Value::String(s) => vec![s.clone()],
Value::Bool(b) => vec![b.to_string()],
Value::Number(n) => vec![n.to_string()],
Value::Array(arr) => arr.iter().filter_map(value_to_string).collect(),
_ => Vec::new(),
}
}
fn value_to_string(v: &Value) -> Option<String> {
match v {
Value::String(s) => Some(s.clone()),
Value::Bool(b) => Some(b.to_string()),
Value::Number(n) => Some(n.to_string()),
_ => None,
}
}
pub fn evaluate_entry(entry: &ParsedCondition, ctx: &ConditionContext) -> bool {
if entry.operator.op == ConditionOperator::Null {
return evaluate_null(entry, ctx);
}
let context_values = ctx.lookup(&entry.key);
let context_values = match context_values {
Some(vs) if !vs.is_empty() => vs,
_ => {
if entry.operator.if_exists {
return true;
}
if ctx.lookup(&entry.key).is_none() {
tracing::debug!(
target: "fakecloud::iam::audit",
key = %entry.key,
operator = ?entry.operator.op,
"condition key not populated; treating statement as non-applicable"
);
}
return false;
}
};
match entry.operator.qualifier {
Qualifier::Single | Qualifier::ForAnyValue => {
context_values
.iter()
.any(|cv| match_values(entry.operator.op, &entry.values, cv))
}
Qualifier::ForAllValues => {
context_values
.iter()
.all(|cv| match_values(entry.operator.op, &entry.values, cv))
}
}
}
fn evaluate_null(entry: &ParsedCondition, ctx: &ConditionContext) -> bool {
let key_present = ctx
.lookup(&entry.key)
.map(|v| !v.is_empty())
.unwrap_or(false);
entry.values.iter().any(|v| match v.as_str() {
"true" => !key_present,
"false" => key_present,
_ => false,
})
}
fn match_values(op: ConditionOperator, policy_values: &[String], context_value: &str) -> bool {
use ConditionOperator::*;
match op {
StringEquals => policy_values.iter().any(|pv| pv == context_value),
StringNotEquals => policy_values.iter().all(|pv| pv != context_value),
StringEqualsIgnoreCase => policy_values
.iter()
.any(|pv| pv.eq_ignore_ascii_case(context_value)),
StringNotEqualsIgnoreCase => policy_values
.iter()
.all(|pv| !pv.eq_ignore_ascii_case(context_value)),
StringLike => policy_values.iter().any(|pv| glob(pv, context_value)),
StringNotLike => policy_values.iter().all(|pv| !glob(pv, context_value)),
NumericEquals => numeric_cmp(policy_values, context_value, |p, c| p == c),
NumericNotEquals => numeric_cmp_all(policy_values, context_value, |p, c| p != c),
NumericLessThan => numeric_cmp(policy_values, context_value, |p, c| c < p),
NumericLessThanEquals => numeric_cmp(policy_values, context_value, |p, c| c <= p),
NumericGreaterThan => numeric_cmp(policy_values, context_value, |p, c| c > p),
NumericGreaterThanEquals => numeric_cmp(policy_values, context_value, |p, c| c >= p),
DateEquals => date_cmp(policy_values, context_value, |p, c| p == c),
DateNotEquals => date_cmp_all(policy_values, context_value, |p, c| p != c),
DateLessThan => date_cmp(policy_values, context_value, |p, c| c < p),
DateLessThanEquals => date_cmp(policy_values, context_value, |p, c| c <= p),
DateGreaterThan => date_cmp(policy_values, context_value, |p, c| c > p),
DateGreaterThanEquals => date_cmp(policy_values, context_value, |p, c| c >= p),
Bool => bool_match(policy_values, context_value),
BinaryEquals => policy_values.iter().any(|pv| pv == context_value),
IpAddress => policy_values.iter().any(|pv| cidr_match(pv, context_value)),
NotIpAddress => policy_values
.iter()
.all(|pv| !cidr_match(pv, context_value)),
ArnEquals | ArnLike => policy_values.iter().any(|pv| glob(pv, context_value)),
ArnNotEquals | ArnNotLike => policy_values.iter().all(|pv| !glob(pv, context_value)),
Null => false, }
}
fn numeric_cmp(
policy_values: &[String],
context_value: &str,
pred: impl Fn(f64, f64) -> bool,
) -> bool {
let Ok(c) = context_value.parse::<f64>() else {
tracing::debug!(
target: "fakecloud::iam::audit",
context_value = %context_value,
"non-numeric context value for Numeric* operator; failing closed"
);
return false;
};
policy_values.iter().any(|pv| {
pv.parse::<f64>()
.map(|p| pred(p, c))
.ok()
.unwrap_or_else(|| {
tracing::debug!(
target: "fakecloud::iam::audit",
policy_value = %pv,
"non-numeric policy value for Numeric* operator; failing closed"
);
false
})
})
}
fn numeric_cmp_all(
policy_values: &[String],
context_value: &str,
pred: impl Fn(f64, f64) -> bool,
) -> bool {
let Ok(c) = context_value.parse::<f64>() else {
return false;
};
policy_values
.iter()
.all(|pv| pv.parse::<f64>().map(|p| pred(p, c)).unwrap_or(false))
}
fn date_cmp(
policy_values: &[String],
context_value: &str,
pred: impl Fn(DateTime<Utc>, DateTime<Utc>) -> bool,
) -> bool {
let Some(c) = parse_date(context_value) else {
tracing::debug!(
target: "fakecloud::iam::audit",
context_value = %context_value,
"unparseable context date for Date* operator; failing closed"
);
return false;
};
policy_values
.iter()
.any(|pv| parse_date(pv).map(|p| pred(p, c)).unwrap_or(false))
}
fn date_cmp_all(
policy_values: &[String],
context_value: &str,
pred: impl Fn(DateTime<Utc>, DateTime<Utc>) -> bool,
) -> bool {
let Some(c) = parse_date(context_value) else {
return false;
};
policy_values
.iter()
.all(|pv| parse_date(pv).map(|p| pred(p, c)).unwrap_or(false))
}
fn parse_date(s: &str) -> Option<DateTime<Utc>> {
if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
return Some(dt.with_timezone(&Utc));
}
if let Ok(secs) = s.parse::<i64>() {
return DateTime::from_timestamp(secs, 0);
}
None
}
fn bool_match(policy_values: &[String], context_value: &str) -> bool {
let cv = context_value.eq_ignore_ascii_case("true");
policy_values.iter().any(|pv| {
let pvb = pv.eq_ignore_ascii_case("true");
let pv_is_bool = pv.eq_ignore_ascii_case("true") || pv.eq_ignore_ascii_case("false");
pv_is_bool && pvb == cv
})
}
pub(crate) fn cidr_match(pattern: &str, value: &str) -> bool {
let Ok(addr) = value.parse::<IpAddr>() else {
return false;
};
let (net_str, prefix_len) = match pattern.split_once('/') {
Some((n, p)) => {
let Ok(pl) = p.parse::<u8>() else {
return false;
};
(n, Some(pl))
}
None => (pattern, None),
};
let Ok(net) = net_str.parse::<IpAddr>() else {
return false;
};
match (net, addr) {
(IpAddr::V4(n), IpAddr::V4(a)) => {
let pl = prefix_len.unwrap_or(32);
if pl > 32 {
return false;
}
let mask: u32 = if pl == 0 { 0 } else { u32::MAX << (32 - pl) };
(u32::from(n) & mask) == (u32::from(a) & mask)
}
(IpAddr::V6(n), IpAddr::V6(a)) => {
let pl = prefix_len.unwrap_or(128);
if pl > 128 {
return false;
}
let mask: u128 = if pl == 0 { 0 } else { u128::MAX << (128 - pl) };
(u128::from(n) & mask) == (u128::from(a) & mask)
}
_ => false,
}
}
fn glob(pattern: &str, value: &str) -> bool {
let p: Vec<char> = pattern.chars().collect();
let v: Vec<char> = value.chars().collect();
let mut pi = 0usize;
let mut vi = 0usize;
let mut star: Option<usize> = None;
let mut star_v = 0usize;
while vi < v.len() {
if pi < p.len() && (p[pi] == '?' || p[pi] == v[vi]) {
pi += 1;
vi += 1;
} else if pi < p.len() && p[pi] == '*' {
star = Some(pi);
star_v = vi;
pi += 1;
} else if let Some(s) = star {
pi = s + 1;
star_v += 1;
vi = star_v;
} else {
return false;
}
}
while pi < p.len() && p[pi] == '*' {
pi += 1;
}
pi == p.len()
}
pub fn evaluate_condition_block(block: &CompiledCondition, ctx: &ConditionContext) -> bool {
block.matches(ctx)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn ctx_user(name: &str) -> ConditionContext {
ConditionContext {
aws_username: Some(name.to_string()),
aws_principal_arn: Some(format!("arn:aws:iam::123456789012:user/{name}")),
aws_principal_account: Some("123456789012".to_string()),
aws_principal_type: Some("User".to_string()),
aws_userid: Some("AIDAEXAMPLE".to_string()),
..Default::default()
}
}
fn compile(v: serde_json::Value) -> CompiledCondition {
CompiledCondition::parse(&v)
}
#[test]
fn parse_plain_operator() {
let p = ParsedOperatorName::parse("StringEquals").unwrap();
assert_eq!(p.op, ConditionOperator::StringEquals);
assert!(!p.if_exists);
assert_eq!(p.qualifier, Qualifier::Single);
}
#[test]
fn parse_if_exists_suffix() {
let p = ParsedOperatorName::parse("StringEqualsIfExists").unwrap();
assert_eq!(p.op, ConditionOperator::StringEquals);
assert!(p.if_exists);
}
#[test]
fn parse_for_all_values_qualifier() {
let p = ParsedOperatorName::parse("ForAllValues:StringLike").unwrap();
assert_eq!(p.op, ConditionOperator::StringLike);
assert_eq!(p.qualifier, Qualifier::ForAllValues);
}
#[test]
fn parse_for_any_value_with_if_exists() {
let p = ParsedOperatorName::parse("ForAnyValue:DateLessThanIfExists").unwrap();
assert_eq!(p.op, ConditionOperator::DateLessThan);
assert!(p.if_exists);
assert_eq!(p.qualifier, Qualifier::ForAnyValue);
}
#[test]
fn parse_unknown_operator_returns_none() {
assert!(ParsedOperatorName::parse("NotARealOp").is_none());
}
#[test]
fn string_equals_matches_exact() {
let b = compile(json!({ "StringEquals": { "aws:username": "alice" } }));
assert!(b.matches(&ctx_user("alice")));
assert!(!b.matches(&ctx_user("bob")));
}
#[test]
fn string_not_equals_denies_match() {
let b = compile(json!({ "StringNotEquals": { "aws:username": "alice" } }));
assert!(!b.matches(&ctx_user("alice")));
assert!(b.matches(&ctx_user("bob")));
}
#[test]
fn string_equals_ignore_case() {
let b = compile(json!({ "StringEqualsIgnoreCase": { "aws:username": "ALICE" } }));
assert!(b.matches(&ctx_user("alice")));
}
#[test]
fn string_like_wildcard() {
let b = compile(json!({ "StringLike": { "aws:username": "al*" } }));
assert!(b.matches(&ctx_user("alice")));
assert!(!b.matches(&ctx_user("bob")));
}
#[test]
fn string_not_like_wildcard() {
let b = compile(json!({ "StringNotLike": { "aws:username": "al*" } }));
assert!(!b.matches(&ctx_user("alice")));
assert!(b.matches(&ctx_user("bob")));
}
#[test]
fn string_equals_list_is_or() {
let b = compile(json!({
"StringEquals": { "aws:username": ["alice", "carol"] }
}));
assert!(b.matches(&ctx_user("alice")));
assert!(b.matches(&ctx_user("carol")));
assert!(!b.matches(&ctx_user("bob")));
}
#[test]
fn numeric_equals() {
let mut ctx = ctx_user("alice");
ctx.service_keys
.insert("s3:maxkeys".to_string(), vec!["42".to_string()]);
let b = compile(json!({ "NumericEquals": { "s3:maxkeys": "42" } }));
assert!(b.matches(&ctx));
}
#[test]
fn numeric_less_than_epoch() {
let mut ctx = ctx_user("alice");
ctx.aws_epoch_time = Some(1_000);
let b = compile(json!({ "NumericLessThan": { "aws:epochtime": "2000" } }));
assert!(b.matches(&ctx));
ctx.aws_epoch_time = Some(3_000);
assert!(!b.matches(&ctx));
}
#[test]
fn date_less_than_current_time() {
let mut ctx = ctx_user("alice");
ctx.aws_current_time = DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
.ok()
.map(|d| d.with_timezone(&Utc));
let b = compile(json!({
"DateLessThan": { "aws:CurrentTime": "2025-01-01T00:00:00Z" }
}));
assert!(b.matches(&ctx));
}
#[test]
fn date_greater_than_blocks_past() {
let mut ctx = ctx_user("alice");
ctx.aws_current_time = DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
.ok()
.map(|d| d.with_timezone(&Utc));
let b = compile(json!({
"DateGreaterThan": { "aws:CurrentTime": "2025-01-01T00:00:00Z" }
}));
assert!(!b.matches(&ctx));
}
#[test]
fn bool_secure_transport() {
let mut ctx = ctx_user("alice");
ctx.aws_secure_transport = Some(false);
let b = compile(json!({
"Bool": { "aws:SecureTransport": "false" }
}));
assert!(b.matches(&ctx));
ctx.aws_secure_transport = Some(true);
assert!(!b.matches(&ctx));
}
#[test]
fn ip_address_cidr_match() {
let mut ctx = ctx_user("alice");
ctx.aws_source_ip = Some("10.0.0.5".parse().unwrap());
let b = compile(json!({ "IpAddress": { "aws:SourceIp": "10.0.0.0/24" } }));
assert!(b.matches(&ctx));
}
#[test]
fn ip_address_cidr_outside() {
let mut ctx = ctx_user("alice");
ctx.aws_source_ip = Some("192.168.1.5".parse().unwrap());
let b = compile(json!({ "IpAddress": { "aws:SourceIp": "10.0.0.0/24" } }));
assert!(!b.matches(&ctx));
}
#[test]
fn not_ip_address_blocks_cidr() {
let mut ctx = ctx_user("alice");
ctx.aws_source_ip = Some("10.0.0.5".parse().unwrap());
let b = compile(json!({ "NotIpAddress": { "aws:SourceIp": "10.0.0.0/24" } }));
assert!(!b.matches(&ctx));
}
#[test]
fn ip_address_bare_v4() {
let mut ctx = ctx_user("alice");
ctx.aws_source_ip = Some("127.0.0.1".parse().unwrap());
let b = compile(json!({ "IpAddress": { "aws:SourceIp": "127.0.0.1" } }));
assert!(b.matches(&ctx));
}
#[test]
fn ip_address_v6_cidr() {
let mut ctx = ctx_user("alice");
ctx.aws_source_ip = Some("2001:db8::1".parse().unwrap());
let b = compile(json!({ "IpAddress": { "aws:SourceIp": "2001:db8::/32" } }));
assert!(b.matches(&ctx));
}
#[test]
fn arn_like_wildcard() {
let b = compile(json!({
"ArnLike": { "aws:PrincipalArn": "arn:aws:iam::*:user/*" }
}));
assert!(b.matches(&ctx_user("alice")));
}
#[test]
fn arn_not_equals_rejects_exact() {
let b = compile(json!({
"ArnNotEquals": {
"aws:PrincipalArn": "arn:aws:iam::123456789012:user/alice"
}
}));
assert!(!b.matches(&ctx_user("alice")));
assert!(b.matches(&ctx_user("bob")));
}
#[test]
fn null_true_requires_missing_key() {
let b = compile(json!({ "Null": { "aws:username": "true" } }));
assert!(!b.matches(&ctx_user("alice"))); let ctx = ConditionContext::default();
assert!(b.matches(&ctx)); }
#[test]
fn null_false_requires_present_key() {
let b = compile(json!({ "Null": { "aws:username": "false" } }));
assert!(b.matches(&ctx_user("alice")));
let ctx = ConditionContext::default();
assert!(!b.matches(&ctx));
}
#[test]
fn if_exists_passes_on_missing_key() {
let b = compile(json!({
"StringEqualsIfExists": { "aws:username": "alice" }
}));
let ctx = ConditionContext::default();
assert!(b.matches(&ctx));
}
#[test]
fn if_exists_still_checks_present_key() {
let b = compile(json!({
"StringEqualsIfExists": { "aws:username": "alice" }
}));
assert!(b.matches(&ctx_user("alice")));
assert!(!b.matches(&ctx_user("bob")));
}
#[test]
fn for_all_values_every_context_must_match() {
let mut ctx = ctx_user("alice");
ctx.request_tags = Some(
[("env", "dev"), ("team", "platform")]
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
);
let b = compile(json!({
"ForAllValues:StringEquals": {
"aws:TagKeys": ["env", "team", "owner"]
}
}));
assert!(b.matches(&ctx));
ctx.request_tags = Some(
[("env", "dev"), ("rogue", "x")]
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
);
assert!(!b.matches(&ctx));
}
#[test]
fn for_any_value_some_context_matches() {
let mut ctx = ctx_user("alice");
ctx.request_tags = Some(
[("env", "dev"), ("rogue", "x")]
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
);
let b = compile(json!({
"ForAnyValue:StringEquals": { "aws:TagKeys": "env" }
}));
assert!(b.matches(&ctx));
}
#[test]
fn multiple_operators_must_all_match() {
let mut ctx = ctx_user("alice");
ctx.aws_source_ip = Some("10.0.0.1".parse().unwrap());
let b = compile(json!({
"StringEquals": { "aws:username": "alice" },
"IpAddress": { "aws:SourceIp": "10.0.0.0/24" }
}));
assert!(b.matches(&ctx));
let mut wrong_ip = ctx.clone();
wrong_ip.aws_source_ip = Some("192.168.1.1".parse().unwrap());
assert!(!b.matches(&wrong_ip));
let wrong_user = ctx_user("bob");
let mut wu = wrong_user;
wu.aws_source_ip = Some("10.0.0.1".parse().unwrap());
assert!(!b.matches(&wu));
}
#[test]
fn unknown_operator_fails_closed() {
let b = compile(json!({ "NotARealOp": { "aws:username": "alice" } }));
assert!(!b.matches(&ctx_user("alice")));
}
#[test]
fn unknown_key_fails_closed() {
let b = compile(json!({
"StringEquals": { "aws:madeupkey": "whatever" }
}));
assert!(!b.matches(&ctx_user("alice")));
}
#[test]
fn context_lookup_case_insensitive() {
let ctx = ctx_user("alice");
assert_eq!(ctx.lookup("AWS:UserName"), Some(vec!["alice".to_string()]));
assert_eq!(ctx.lookup("aws:username"), Some(vec!["alice".to_string()]));
}
#[test]
fn cidr_match_helper() {
assert!(cidr_match("10.0.0.0/8", "10.1.2.3"));
assert!(!cidr_match("10.0.0.0/8", "11.0.0.1"));
assert!(cidr_match("0.0.0.0/0", "1.2.3.4"));
assert!(!cidr_match("invalid", "1.2.3.4"));
}
}