- Remove gitlinks (160000 mode) for chainfire, flaredb, iam - Add workspace contents as regular tracked files - Update flake.nix to use simple paths instead of builtins.fetchGit This resolves the nix build failure where submodule directories appeared empty in the nix store. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
349 lines
12 KiB
Rust
349 lines
12 KiB
Rust
//! Condition evaluation for ABAC
|
|
//!
|
|
//! Evaluates condition expressions against the current authorization context.
|
|
|
|
use std::net::IpAddr;
|
|
use std::str::FromStr;
|
|
|
|
use ipnetwork::IpNetwork;
|
|
|
|
use iam_types::{Condition, ConditionExpr, Error, IamError, Result};
|
|
|
|
use crate::context::VariableContext;
|
|
|
|
/// Evaluate a condition against the given context
|
|
pub fn evaluate_condition(condition: &Condition, ctx: &VariableContext<'_>) -> Result<bool> {
|
|
evaluate_expr(&condition.expression, ctx)
|
|
}
|
|
|
|
/// Evaluate a condition expression
|
|
fn evaluate_expr(expr: &ConditionExpr, ctx: &VariableContext<'_>) -> Result<bool> {
|
|
match expr {
|
|
ConditionExpr::StringEquals { key, value } => {
|
|
let actual = resolve_key(key, ctx);
|
|
let expected = ctx.substitute(value);
|
|
Ok(actual.as_deref() == Some(&expected))
|
|
}
|
|
|
|
ConditionExpr::StringNotEquals { key, value } => {
|
|
let actual = resolve_key(key, ctx);
|
|
let expected = ctx.substitute(value);
|
|
Ok(actual.as_deref() != Some(&expected))
|
|
}
|
|
|
|
ConditionExpr::StringLike { key, pattern } => {
|
|
let actual = require_key(key, ctx)?;
|
|
let pattern = ctx.substitute(pattern);
|
|
Ok(glob_match::glob_match(&pattern, &actual))
|
|
}
|
|
|
|
ConditionExpr::StringNotLike { key, pattern } => {
|
|
let actual = require_key(key, ctx)?;
|
|
let pattern = ctx.substitute(pattern);
|
|
Ok(!glob_match::glob_match(&pattern, &actual))
|
|
}
|
|
|
|
ConditionExpr::NumericEquals { key, value } => {
|
|
let actual = require_key(key, ctx)?;
|
|
let actual_num: i64 = actual.parse().map_err(|_| {
|
|
Error::Iam(IamError::InvalidCondition(format!(
|
|
"Cannot parse '{}' as number for key '{}'",
|
|
actual, key
|
|
)))
|
|
})?;
|
|
Ok(actual_num == *value)
|
|
}
|
|
|
|
ConditionExpr::NumericLessThan { key, value } => {
|
|
let actual = require_key(key, ctx)?;
|
|
let actual_num: i64 = actual.parse().map_err(|_| {
|
|
Error::Iam(IamError::InvalidCondition(format!(
|
|
"Cannot parse '{}' as number",
|
|
actual
|
|
)))
|
|
})?;
|
|
Ok(actual_num < *value)
|
|
}
|
|
|
|
ConditionExpr::NumericLessThanEquals { key, value } => {
|
|
let actual = require_key(key, ctx)?;
|
|
let actual_num: i64 = actual.parse().map_err(|_| {
|
|
Error::Iam(IamError::InvalidCondition(format!(
|
|
"Cannot parse '{}' as number",
|
|
actual
|
|
)))
|
|
})?;
|
|
Ok(actual_num <= *value)
|
|
}
|
|
|
|
ConditionExpr::NumericGreaterThan { key, value } => {
|
|
let actual = require_key(key, ctx)?;
|
|
let actual_num: i64 = actual.parse().map_err(|_| {
|
|
Error::Iam(IamError::InvalidCondition(format!(
|
|
"Cannot parse '{}' as number",
|
|
actual
|
|
)))
|
|
})?;
|
|
Ok(actual_num > *value)
|
|
}
|
|
|
|
ConditionExpr::NumericGreaterThanEquals { key, value } => {
|
|
let actual = require_key(key, ctx)?;
|
|
let actual_num: i64 = actual.parse().map_err(|_| {
|
|
Error::Iam(IamError::InvalidCondition(format!(
|
|
"Cannot parse '{}' as number",
|
|
actual
|
|
)))
|
|
})?;
|
|
Ok(actual_num >= *value)
|
|
}
|
|
|
|
ConditionExpr::IpAddress { key, cidr } => {
|
|
let actual = require_key(key, ctx)?;
|
|
let ip: IpAddr = actual.parse().map_err(|_| {
|
|
Error::Iam(IamError::InvalidCondition(format!(
|
|
"Cannot parse '{}' as IP address",
|
|
actual
|
|
)))
|
|
})?;
|
|
let network: IpNetwork = cidr.parse().map_err(|_| {
|
|
Error::Iam(IamError::InvalidCondition(format!(
|
|
"Cannot parse '{}' as CIDR",
|
|
cidr
|
|
)))
|
|
})?;
|
|
Ok(network.contains(ip))
|
|
}
|
|
|
|
ConditionExpr::NotIpAddress { key, cidr } => {
|
|
let actual = require_key(key, ctx)?;
|
|
let ip: IpAddr = actual.parse().map_err(|_| {
|
|
Error::Iam(IamError::InvalidCondition(format!(
|
|
"Cannot parse '{}' as IP address",
|
|
actual
|
|
)))
|
|
})?;
|
|
let network: IpNetwork = cidr.parse().map_err(|_| {
|
|
Error::Iam(IamError::InvalidCondition(format!(
|
|
"Cannot parse '{}' as CIDR",
|
|
cidr
|
|
)))
|
|
})?;
|
|
Ok(!network.contains(ip))
|
|
}
|
|
|
|
ConditionExpr::TimeBetween { start, end } => {
|
|
let now = ctx.context.timestamp;
|
|
|
|
// Parse start/end as either Unix timestamp or HH:MM format
|
|
let start_ts = parse_time(start, now)?;
|
|
let end_ts = parse_time(end, now)?;
|
|
|
|
Ok(now >= start_ts && now <= end_ts)
|
|
}
|
|
|
|
ConditionExpr::Exists { key } => Ok(resolve_key(key, ctx).is_some()),
|
|
|
|
ConditionExpr::StringEqualsAny { key, values } => {
|
|
let actual = resolve_key(key, ctx);
|
|
match actual {
|
|
Some(actual_val) => {
|
|
let substituted: Vec<String> =
|
|
values.iter().map(|v| ctx.substitute(v)).collect();
|
|
Ok(substituted.contains(&actual_val))
|
|
}
|
|
None => Ok(false),
|
|
}
|
|
}
|
|
|
|
ConditionExpr::Bool { key, value } => {
|
|
let actual = require_key(key, ctx)?;
|
|
let actual_bool = matches!(actual.to_lowercase().as_str(), "true" | "1" | "yes");
|
|
Ok(actual_bool == *value)
|
|
}
|
|
|
|
ConditionExpr::And(exprs) => {
|
|
for e in exprs {
|
|
if !evaluate_expr(e, ctx)? {
|
|
return Ok(false);
|
|
}
|
|
}
|
|
Ok(true)
|
|
}
|
|
|
|
ConditionExpr::Or(exprs) => {
|
|
for e in exprs {
|
|
if evaluate_expr(e, ctx)? {
|
|
return Ok(true);
|
|
}
|
|
}
|
|
Ok(false)
|
|
}
|
|
|
|
ConditionExpr::Not(inner) => Ok(!evaluate_expr(inner, ctx)?),
|
|
}
|
|
}
|
|
|
|
/// Resolve a key to its value, returning Option
|
|
fn resolve_key(key: &str, ctx: &VariableContext<'_>) -> Option<String> {
|
|
ctx.resolve(key)
|
|
}
|
|
|
|
/// Resolve a key, returning an error if not found
|
|
fn require_key(key: &str, ctx: &VariableContext<'_>) -> Result<String> {
|
|
ctx.resolve(key).ok_or_else(|| {
|
|
Error::Iam(IamError::InvalidCondition(format!(
|
|
"Key not found: {}",
|
|
key
|
|
)))
|
|
})
|
|
}
|
|
|
|
/// Parse time string to Unix timestamp
|
|
fn parse_time(time_str: &str, reference_time: u64) -> Result<u64> {
|
|
// Try parsing as Unix timestamp
|
|
if let Ok(ts) = time_str.parse::<u64>() {
|
|
return Ok(ts);
|
|
}
|
|
|
|
// Try parsing as HH:MM (relative to current day)
|
|
if let Some((hours, minutes)) = time_str.split_once(':') {
|
|
let hours: u64 = hours.parse().map_err(|_| {
|
|
Error::Iam(IamError::InvalidCondition(format!(
|
|
"Invalid time format: {}",
|
|
time_str
|
|
)))
|
|
})?;
|
|
let minutes: u64 = minutes.parse().map_err(|_| {
|
|
Error::Iam(IamError::InvalidCondition(format!(
|
|
"Invalid time format: {}",
|
|
time_str
|
|
)))
|
|
})?;
|
|
|
|
// Calculate seconds since midnight
|
|
let time_of_day = hours * 3600 + minutes * 60;
|
|
|
|
// Get current day start
|
|
let day_start = (reference_time / 86400) * 86400;
|
|
|
|
return Ok(day_start + time_of_day);
|
|
}
|
|
|
|
// Try parsing as ISO 8601
|
|
// For simplicity, just return an error for unsupported formats
|
|
Err(Error::Iam(IamError::InvalidCondition(format!(
|
|
"Unsupported time format: {}",
|
|
time_str
|
|
))))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::context::AuthzContext;
|
|
use iam_types::{Principal, Resource};
|
|
use std::net::{IpAddr, Ipv4Addr};
|
|
|
|
fn test_context<'a>(
|
|
principal: &'a Principal,
|
|
resource: &'a Resource,
|
|
context: &'a AuthzContext,
|
|
) -> VariableContext<'a> {
|
|
VariableContext::new(principal, resource, context)
|
|
}
|
|
|
|
#[test]
|
|
fn test_string_equals() {
|
|
let principal = Principal::new_user("alice", "Alice");
|
|
let resource = Resource::new("instance", "vm-1", "org-1", "proj-1").with_owner("alice");
|
|
let ctx = AuthzContext::new();
|
|
let var_ctx = test_context(&principal, &resource, &ctx);
|
|
|
|
// Direct comparison
|
|
let cond = Condition::string_equals("resource.owner", "alice");
|
|
assert!(evaluate_condition(&cond, &var_ctx).unwrap());
|
|
|
|
// Variable substitution
|
|
let cond = Condition::string_equals("resource.owner", "${principal.id}");
|
|
assert!(evaluate_condition(&cond, &var_ctx).unwrap());
|
|
|
|
// Non-matching
|
|
let cond = Condition::string_equals("resource.owner", "bob");
|
|
assert!(!evaluate_condition(&cond, &var_ctx).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn test_string_like() {
|
|
let principal = Principal::new_user("alice", "Alice");
|
|
let resource = Resource::new("instance", "vm-prod-001", "org-1", "proj-1");
|
|
let ctx = AuthzContext::new();
|
|
let var_ctx = test_context(&principal, &resource, &ctx);
|
|
|
|
let cond = Condition::string_like("resource.id", "vm-prod-*");
|
|
assert!(evaluate_condition(&cond, &var_ctx).unwrap());
|
|
|
|
let cond = Condition::string_like("resource.id", "vm-dev-*");
|
|
assert!(!evaluate_condition(&cond, &var_ctx).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn test_ip_address() {
|
|
let principal = Principal::new_user("alice", "Alice");
|
|
let resource = Resource::new("instance", "vm-1", "org-1", "proj-1");
|
|
let ctx = AuthzContext::new().with_source_ip(IpAddr::V4(Ipv4Addr::new(10, 0, 1, 50)));
|
|
let var_ctx = test_context(&principal, &resource, &ctx);
|
|
|
|
let cond = Condition::ip_address("request.source_ip", "10.0.0.0/8");
|
|
assert!(evaluate_condition(&cond, &var_ctx).unwrap());
|
|
|
|
let cond = Condition::ip_address("request.source_ip", "192.168.0.0/16");
|
|
assert!(!evaluate_condition(&cond, &var_ctx).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn test_and_condition() {
|
|
let principal = Principal::new_user("alice", "Alice");
|
|
let resource = Resource::new("instance", "vm-1", "org-1", "proj-1").with_owner("alice");
|
|
let ctx = AuthzContext::new().with_source_ip(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)));
|
|
let var_ctx = test_context(&principal, &resource, &ctx);
|
|
|
|
let cond = Condition::and(vec![
|
|
Condition::string_equals("resource.owner", "${principal.id}"),
|
|
Condition::ip_address("request.source_ip", "10.0.0.0/8"),
|
|
]);
|
|
assert!(evaluate_condition(&cond, &var_ctx).unwrap());
|
|
|
|
// One condition fails
|
|
let cond = Condition::and(vec![
|
|
Condition::string_equals("resource.owner", "bob"),
|
|
Condition::ip_address("request.source_ip", "10.0.0.0/8"),
|
|
]);
|
|
assert!(!evaluate_condition(&cond, &var_ctx).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn test_or_condition() {
|
|
let principal = Principal::new_user("alice", "Alice");
|
|
let resource = Resource::new("instance", "vm-1", "org-1", "proj-1").with_owner("bob");
|
|
let ctx = AuthzContext::new();
|
|
let var_ctx = test_context(&principal, &resource, &ctx);
|
|
|
|
let cond = Condition::or(vec![
|
|
Condition::string_equals("resource.owner", "${principal.id}"), // false
|
|
Condition::string_equals("principal.id", "alice"), // true
|
|
]);
|
|
assert!(evaluate_condition(&cond, &var_ctx).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn test_not_condition() {
|
|
let principal = Principal::new_user("alice", "Alice");
|
|
let resource = Resource::new("instance", "vm-1", "org-1", "proj-1");
|
|
let ctx = AuthzContext::new();
|
|
let var_ctx = test_context(&principal, &resource, &ctx);
|
|
|
|
let cond = Condition::not(Condition::string_equals("principal.id", "bob"));
|
|
assert!(evaluate_condition(&cond, &var_ctx).unwrap());
|
|
}
|
|
}
|