diff --git a/Cargo.lock b/Cargo.lock index ee67c7a..711cc64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -510,6 +510,7 @@ dependencies = [ "clap", "config", "josekit", + "kdl", "miette", "migration", "oauth2", @@ -2211,6 +2212,17 @@ dependencies = [ "serde", ] +[[package]] +name = "kdl" +version = "6.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81a29e7b50079ff44549f68c0becb1c73d7f6de2a4ea952da77966daf3d4761e" +dependencies = [ + "miette", + "num", + "winnow 0.6.24", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2501,6 +2513,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -2527,6 +2553,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -2564,6 +2599,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -4801,7 +4847,7 @@ dependencies = [ "serde_spanned", "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.13", ] [[package]] @@ -4813,7 +4859,7 @@ dependencies = [ "indexmap 2.12.1", "toml_datetime 0.7.3", "toml_parser", - "winnow", + "winnow 0.7.13", ] [[package]] @@ -4822,7 +4868,7 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" dependencies = [ - "winnow", + "winnow 0.7.13", ] [[package]] @@ -5708,6 +5754,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.6.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d71a593cc5c42ad7876e2c1fda56f314f3754c084128833e64f1345ff8a03a" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "0.7.13" diff --git a/Cargo.toml b/Cargo.toml index fd71338..e89c260 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,6 +72,9 @@ async-graphql-axum = "7" tokio-cron-scheduler = "0.13" bincode = "2.0.1" +# Policy / authorization engine +kdl = "6" + [dev-dependencies] # Existing OIDC/OAuth testing openidconnect = { version = "4", features = ["reqwest-blocking"] } diff --git a/src/authz/condition.rs b/src/authz/condition.rs new file mode 100644 index 0000000..f49f57d --- /dev/null +++ b/src/authz/condition.rs @@ -0,0 +1,722 @@ +//! Simple expression parser and evaluator for ABAC policy conditions. +//! +//! Supported syntax: +//! - Comparisons: `==`, `!=`, `>`, `<`, `>=`, `<=` +//! - Boolean operators: `&&`, `||`, `!` +//! - Membership: `x in list` +//! - Dot-path access: `request.ip`, `context.corporate_ips` +//! - Literals: integers, floats, `"strings"`, `true`, `false` +//! - Parentheses for grouping + +use crate::authz::errors::AuthzError; +use serde_json::Value; + +// ─── AST ──────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq)] +pub enum Expr { + Literal(LitValue), + Path(Vec), + BinOp { + op: BinOp, + left: Box, + right: Box, + }, + UnaryNot(Box), + In { + element: Box, + collection: Box, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum BinOp { + Eq, + Ne, + Gt, + Lt, + Ge, + Le, + And, + Or, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum LitValue { + Int(i64), + Float(f64), + Str(String), + Bool(bool), +} + +// ─── Parser ───────────────────────────────────────────────────────────── + +struct Parser { + tokens: Vec, + pos: usize, +} + +#[derive(Debug, Clone, PartialEq)] +enum Token { + Ident(String), + Int(i64), + Float(f64), + Str(String), + True, + False, + Dot, + LParen, + RParen, + Eq, // == + Ne, // != + Gt, // > + Lt, // < + Ge, // >= + Le, // <= + And, // && + Or, // || + Not, // ! + In, // in +} + +fn tokenize(input: &str) -> Result, AuthzError> { + let mut tokens = Vec::new(); + let chars: Vec = input.chars().collect(); + let mut i = 0; + + while i < chars.len() { + match chars[i] { + ' ' | '\t' | '\n' | '\r' => { + i += 1; + } + '.' => { + tokens.push(Token::Dot); + i += 1; + } + '(' => { + tokens.push(Token::LParen); + i += 1; + } + ')' => { + tokens.push(Token::RParen); + i += 1; + } + '=' if i + 1 < chars.len() && chars[i + 1] == '=' => { + tokens.push(Token::Eq); + i += 2; + } + '!' if i + 1 < chars.len() && chars[i + 1] == '=' => { + tokens.push(Token::Ne); + i += 2; + } + '!' => { + tokens.push(Token::Not); + i += 1; + } + '>' if i + 1 < chars.len() && chars[i + 1] == '=' => { + tokens.push(Token::Ge); + i += 2; + } + '>' => { + tokens.push(Token::Gt); + i += 1; + } + '<' if i + 1 < chars.len() && chars[i + 1] == '=' => { + tokens.push(Token::Le); + i += 2; + } + '<' => { + tokens.push(Token::Lt); + i += 1; + } + '&' if i + 1 < chars.len() && chars[i + 1] == '&' => { + tokens.push(Token::And); + i += 2; + } + '|' if i + 1 < chars.len() && chars[i + 1] == '|' => { + tokens.push(Token::Or); + i += 2; + } + '"' => { + i += 1; + let start = i; + while i < chars.len() && chars[i] != '"' { + if chars[i] == '\\' { + i += 1; // skip escaped char + } + i += 1; + } + if i >= chars.len() { + return Err(AuthzError::InvalidCondition( + "unterminated string literal".into(), + )); + } + let s: String = chars[start..i].iter().collect(); + tokens.push(Token::Str(s)); + i += 1; // skip closing quote + } + c if c.is_ascii_digit() => { + let start = i; + while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') { + i += 1; + } + let num_str: String = chars[start..i].iter().collect(); + if num_str.contains('.') { + let f: f64 = num_str.parse().map_err(|_| { + AuthzError::InvalidCondition(format!("invalid float `{num_str}`")) + })?; + tokens.push(Token::Float(f)); + } else { + let n: i64 = num_str.parse().map_err(|_| { + AuthzError::InvalidCondition(format!("invalid integer `{num_str}`")) + })?; + tokens.push(Token::Int(n)); + } + } + c if c.is_ascii_alphabetic() || c == '_' => { + let start = i; + while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') { + i += 1; + } + let word: String = chars[start..i].iter().collect(); + match word.as_str() { + "true" => tokens.push(Token::True), + "false" => tokens.push(Token::False), + "in" => tokens.push(Token::In), + _ => tokens.push(Token::Ident(word)), + } + } + c => { + return Err(AuthzError::InvalidCondition(format!( + "unexpected character `{c}`" + ))); + } + } + } + Ok(tokens) +} + +impl Parser { + fn new(tokens: Vec) -> Self { + Self { tokens, pos: 0 } + } + + fn peek(&self) -> Option<&Token> { + self.tokens.get(self.pos) + } + + fn advance(&mut self) -> Option { + let tok = self.tokens.get(self.pos).cloned(); + self.pos += 1; + tok + } + + fn expect_rparen(&mut self) -> Result<(), AuthzError> { + if self.advance() != Some(Token::RParen) { + return Err(AuthzError::InvalidCondition( + "expected closing parenthesis `)`".into(), + )); + } + Ok(()) + } + + /// Entry: parse_or + fn parse_expr(&mut self) -> Result { + self.parse_or() + } + + /// or_expr = and_expr ("||" and_expr)* + fn parse_or(&mut self) -> Result { + let mut left = self.parse_and()?; + while self.peek() == Some(&Token::Or) { + self.advance(); + let right = self.parse_and()?; + left = Expr::BinOp { + op: BinOp::Or, + left: Box::new(left), + right: Box::new(right), + }; + } + Ok(left) + } + + /// and_expr = comparison ("&&" comparison)* + fn parse_and(&mut self) -> Result { + let mut left = self.parse_comparison()?; + while self.peek() == Some(&Token::And) { + self.advance(); + let right = self.parse_comparison()?; + left = Expr::BinOp { + op: BinOp::And, + left: Box::new(left), + right: Box::new(right), + }; + } + Ok(left) + } + + /// comparison = unary (("==" | "!=" | ">" | "<" | ">=" | "<=" | "in") unary)? + fn parse_comparison(&mut self) -> Result { + let left = self.parse_unary()?; + match self.peek() { + Some(Token::Eq) => { + self.advance(); + let right = self.parse_unary()?; + Ok(Expr::BinOp { + op: BinOp::Eq, + left: Box::new(left), + right: Box::new(right), + }) + } + Some(Token::Ne) => { + self.advance(); + let right = self.parse_unary()?; + Ok(Expr::BinOp { + op: BinOp::Ne, + left: Box::new(left), + right: Box::new(right), + }) + } + Some(Token::Gt) => { + self.advance(); + let right = self.parse_unary()?; + Ok(Expr::BinOp { + op: BinOp::Gt, + left: Box::new(left), + right: Box::new(right), + }) + } + Some(Token::Lt) => { + self.advance(); + let right = self.parse_unary()?; + Ok(Expr::BinOp { + op: BinOp::Lt, + left: Box::new(left), + right: Box::new(right), + }) + } + Some(Token::Ge) => { + self.advance(); + let right = self.parse_unary()?; + Ok(Expr::BinOp { + op: BinOp::Ge, + left: Box::new(left), + right: Box::new(right), + }) + } + Some(Token::Le) => { + self.advance(); + let right = self.parse_unary()?; + Ok(Expr::BinOp { + op: BinOp::Le, + left: Box::new(left), + right: Box::new(right), + }) + } + Some(Token::In) => { + self.advance(); + let right = self.parse_unary()?; + Ok(Expr::In { + element: Box::new(left), + collection: Box::new(right), + }) + } + _ => Ok(left), + } + } + + /// unary = "!" unary | primary + fn parse_unary(&mut self) -> Result { + if self.peek() == Some(&Token::Not) { + self.advance(); + let expr = self.parse_unary()?; + return Ok(Expr::UnaryNot(Box::new(expr))); + } + self.parse_primary() + } + + /// primary = literal | path | "(" expr ")" + fn parse_primary(&mut self) -> Result { + match self.peek().cloned() { + Some(Token::Int(n)) => { + self.advance(); + Ok(Expr::Literal(LitValue::Int(n))) + } + Some(Token::Float(f)) => { + self.advance(); + Ok(Expr::Literal(LitValue::Float(f))) + } + Some(Token::Str(s)) => { + self.advance(); + Ok(Expr::Literal(LitValue::Str(s))) + } + Some(Token::True) => { + self.advance(); + Ok(Expr::Literal(LitValue::Bool(true))) + } + Some(Token::False) => { + self.advance(); + Ok(Expr::Literal(LitValue::Bool(false))) + } + Some(Token::Ident(name)) => { + self.advance(); + let mut path = vec![name]; + while self.peek() == Some(&Token::Dot) { + self.advance(); + match self.advance() { + Some(Token::Ident(seg)) => path.push(seg), + _ => { + return Err(AuthzError::InvalidCondition( + "expected identifier after `.`".into(), + )); + } + } + } + Ok(Expr::Path(path)) + } + Some(Token::LParen) => { + self.advance(); + let expr = self.parse_expr()?; + self.expect_rparen()?; + Ok(expr) + } + other => Err(AuthzError::InvalidCondition(format!( + "unexpected token: {other:?}" + ))), + } + } +} + +/// Parse a condition expression string into an AST. +pub fn parse_condition(input: &str) -> Result { + let tokens = tokenize(input)?; + if tokens.is_empty() { + return Err(AuthzError::InvalidCondition("empty expression".into())); + } + let mut parser = Parser::new(tokens); + let expr = parser.parse_expr()?; + if parser.pos < parser.tokens.len() { + return Err(AuthzError::InvalidCondition(format!( + "unexpected trailing token: {:?}", + parser.tokens[parser.pos] + ))); + } + Ok(expr) +} + +// ─── Evaluator ────────────────────────────────────────────────────────── + +/// Evaluate a parsed expression against a JSON context. +/// Returns `true` if the condition is satisfied. +pub fn evaluate(expr: &Expr, context: &Value) -> Result { + match eval_value(expr, context)? { + EvalResult::Bool(b) => Ok(b), + other => Err(AuthzError::InvalidCondition(format!( + "condition must evaluate to boolean, got: {other:?}" + ))), + } +} + +#[derive(Debug, Clone)] +enum EvalResult { + Int(i64), + Float(f64), + Str(String), + Bool(bool), + Array(Vec), + Null, +} + +impl EvalResult { + fn as_f64(&self) -> Option { + match self { + EvalResult::Int(n) => Some(*n as f64), + EvalResult::Float(f) => Some(*f), + _ => None, + } + } +} + +impl PartialEq for EvalResult { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (EvalResult::Int(a), EvalResult::Int(b)) => a == b, + (EvalResult::Float(a), EvalResult::Float(b)) => a == b, + (EvalResult::Int(a), EvalResult::Float(b)) => (*a as f64) == *b, + (EvalResult::Float(a), EvalResult::Int(b)) => *a == (*b as f64), + (EvalResult::Str(a), EvalResult::Str(b)) => a == b, + (EvalResult::Bool(a), EvalResult::Bool(b)) => a == b, + (EvalResult::Null, EvalResult::Null) => true, + _ => false, + } + } +} + +fn eval_value(expr: &Expr, context: &Value) -> Result { + match expr { + Expr::Literal(lit) => Ok(match lit { + LitValue::Int(n) => EvalResult::Int(*n), + LitValue::Float(f) => EvalResult::Float(*f), + LitValue::Str(s) => EvalResult::Str(s.clone()), + LitValue::Bool(b) => EvalResult::Bool(*b), + }), + Expr::Path(segments) => { + let mut current = context; + for seg in segments { + current = current.get(seg).unwrap_or(&Value::Null); + } + Ok(json_to_eval(current)) + } + Expr::UnaryNot(inner) => { + let val = eval_value(inner, context)?; + match val { + EvalResult::Bool(b) => Ok(EvalResult::Bool(!b)), + _ => Err(AuthzError::InvalidCondition( + "`!` operator requires a boolean operand".into(), + )), + } + } + Expr::In { element, collection } => { + let elem = eval_value(element, context)?; + let coll = eval_value(collection, context)?; + match coll { + EvalResult::Array(items) => Ok(EvalResult::Bool(items.contains(&elem))), + _ => Err(AuthzError::InvalidCondition( + "`in` operator requires an array on the right side".into(), + )), + } + } + Expr::BinOp { op, left, right } => { + let l = eval_value(left, context)?; + let r = eval_value(right, context)?; + match op { + BinOp::And => match (&l, &r) { + (EvalResult::Bool(a), EvalResult::Bool(b)) => { + Ok(EvalResult::Bool(*a && *b)) + } + _ => Err(AuthzError::InvalidCondition( + "`&&` requires boolean operands".into(), + )), + }, + BinOp::Or => match (&l, &r) { + (EvalResult::Bool(a), EvalResult::Bool(b)) => { + Ok(EvalResult::Bool(*a || *b)) + } + _ => Err(AuthzError::InvalidCondition( + "`||` requires boolean operands".into(), + )), + }, + BinOp::Eq => Ok(EvalResult::Bool(l == r)), + BinOp::Ne => Ok(EvalResult::Bool(l != r)), + BinOp::Gt | BinOp::Lt | BinOp::Ge | BinOp::Le => { + let lf = l.as_f64().ok_or_else(|| { + AuthzError::InvalidCondition( + "comparison operator requires numeric operands".into(), + ) + })?; + let rf = r.as_f64().ok_or_else(|| { + AuthzError::InvalidCondition( + "comparison operator requires numeric operands".into(), + ) + })?; + let result = match op { + BinOp::Gt => lf > rf, + BinOp::Lt => lf < rf, + BinOp::Ge => lf >= rf, + BinOp::Le => lf <= rf, + _ => unreachable!(), + }; + Ok(EvalResult::Bool(result)) + } + } + } + } +} + +fn json_to_eval(value: &Value) -> EvalResult { + match value { + Value::Null => EvalResult::Null, + Value::Bool(b) => EvalResult::Bool(*b), + Value::Number(n) => { + if let Some(i) = n.as_i64() { + EvalResult::Int(i) + } else if let Some(f) = n.as_f64() { + EvalResult::Float(f) + } else { + EvalResult::Null + } + } + Value::String(s) => EvalResult::Str(s.clone()), + Value::Array(arr) => EvalResult::Array(arr.iter().map(json_to_eval).collect()), + Value::Object(_) => EvalResult::Null, // objects not directly comparable + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_parse_simple_comparison() { + let expr = parse_condition("x == 5").unwrap(); + assert_eq!( + expr, + Expr::BinOp { + op: BinOp::Eq, + left: Box::new(Expr::Path(vec!["x".into()])), + right: Box::new(Expr::Literal(LitValue::Int(5))), + } + ); + } + + #[test] + fn test_parse_dot_path() { + let expr = parse_condition("request.time.hour >= 9").unwrap(); + assert_eq!( + expr, + Expr::BinOp { + op: BinOp::Ge, + left: Box::new(Expr::Path(vec![ + "request".into(), + "time".into(), + "hour".into() + ])), + right: Box::new(Expr::Literal(LitValue::Int(9))), + } + ); + } + + #[test] + fn test_parse_boolean_and() { + let expr = parse_condition("a > 1 && b < 2").unwrap(); + match expr { + Expr::BinOp { + op: BinOp::And, .. + } => {} + _ => panic!("expected And"), + } + } + + #[test] + fn test_parse_in_operator() { + let expr = parse_condition("request.ip in context.allowed_ips").unwrap(); + match expr { + Expr::In { .. } => {} + _ => panic!("expected In"), + } + } + + #[test] + fn test_parse_not_operator() { + let expr = parse_condition("!disabled").unwrap(); + match expr { + Expr::UnaryNot(_) => {} + _ => panic!("expected UnaryNot"), + } + } + + #[test] + fn test_parse_parentheses() { + let expr = parse_condition("(a || b) && c").unwrap(); + match expr { + Expr::BinOp { + op: BinOp::And, + left, + .. + } => match *left { + Expr::BinOp { op: BinOp::Or, .. } => {} + _ => panic!("expected Or inside parens"), + }, + _ => panic!("expected And"), + } + } + + #[test] + fn test_parse_string_literal() { + let expr = parse_condition(r#"name == "alice""#).unwrap(); + assert_eq!( + expr, + Expr::BinOp { + op: BinOp::Eq, + left: Box::new(Expr::Path(vec!["name".into()])), + right: Box::new(Expr::Literal(LitValue::Str("alice".into()))), + } + ); + } + + #[test] + fn test_evaluate_comparison() { + let expr = parse_condition("request.time.hour >= 9").unwrap(); + let ctx = json!({ "request": { "time": { "hour": 14 } } }); + assert!(evaluate(&expr, &ctx).unwrap()); + + let ctx2 = json!({ "request": { "time": { "hour": 7 } } }); + assert!(!evaluate(&expr, &ctx2).unwrap()); + } + + #[test] + fn test_evaluate_boolean_and() { + let expr = + parse_condition("request.time.hour >= 9 && request.time.hour < 17").unwrap(); + let ctx = json!({ "request": { "time": { "hour": 14 } } }); + assert!(evaluate(&expr, &ctx).unwrap()); + + let ctx2 = json!({ "request": { "time": { "hour": 20 } } }); + assert!(!evaluate(&expr, &ctx2).unwrap()); + } + + #[test] + fn test_evaluate_in_array() { + let expr = parse_condition("request.ip in context.corporate_ips").unwrap(); + let ctx = json!({ + "request": { "ip": "10.0.0.1" }, + "context": { "corporate_ips": ["10.0.0.1", "10.0.0.2"] } + }); + assert!(evaluate(&expr, &ctx).unwrap()); + + let ctx2 = json!({ + "request": { "ip": "192.168.1.1" }, + "context": { "corporate_ips": ["10.0.0.1", "10.0.0.2"] } + }); + assert!(!evaluate(&expr, &ctx2).unwrap()); + } + + #[test] + fn test_evaluate_not() { + let expr = parse_condition("!disabled").unwrap(); + let ctx = json!({ "disabled": false }); + assert!(evaluate(&expr, &ctx).unwrap()); + + let ctx2 = json!({ "disabled": true }); + assert!(!evaluate(&expr, &ctx2).unwrap()); + } + + #[test] + fn test_evaluate_string_eq() { + let expr = parse_condition(r#"role == "admin""#).unwrap(); + let ctx = json!({ "role": "admin" }); + assert!(evaluate(&expr, &ctx).unwrap()); + + let ctx2 = json!({ "role": "user" }); + assert!(!evaluate(&expr, &ctx2).unwrap()); + } + + #[test] + fn test_evaluate_or() { + let expr = parse_condition("a == 1 || b == 2").unwrap(); + assert!(evaluate(&expr, &json!({"a": 1, "b": 0})).unwrap()); + assert!(evaluate(&expr, &json!({"a": 0, "b": 2})).unwrap()); + assert!(!evaluate(&expr, &json!({"a": 0, "b": 0})).unwrap()); + } + + #[test] + fn test_invalid_empty_expression() { + assert!(parse_condition("").is_err()); + } + + #[test] + fn test_invalid_unterminated_string() { + assert!(parse_condition(r#""hello"#).is_err()); + } +} diff --git a/src/authz/engine.rs b/src/authz/engine.rs new file mode 100644 index 0000000..d101afe --- /dev/null +++ b/src/authz/engine.rs @@ -0,0 +1,468 @@ +use std::collections::HashSet; + +use serde_json::Value; + +use crate::authz::condition; +use crate::authz::errors::AuthzError; +use crate::authz::AuthzState; + +const MAX_DEPTH: usize = 10; + +/// Check if `principal` (e.g. "user/alice") has `permission` (e.g. "vm:start") +/// on `resource` (e.g. "vm/vm-123"), given optional ABAC `context`. +pub fn check( + state: &AuthzState, + principal: &str, + permission: &str, + resource: &str, + context: &Value, +) -> Result { + // 1. Parse the resource ref + let (resource_type, resource_id) = resource.split_once('/').ok_or_else(|| { + AuthzError::InvalidPolicy(format!( + "invalid resource reference `{resource}` (expected \"type/id\")" + )) + })?; + + // 2. Check ABAC rules first + if check_abac_rules(state, principal, permission, context)? { + return Ok(true); + } + + // 3. Find which roles/relations grant this permission + let granting_roles = match state.permission_roles.get(permission) { + Some(roles) => roles.clone(), + None => return Ok(false), + }; + + // 4. For each granting role, check if principal holds that role on the resource + let mut visited = HashSet::new(); + for role in &granting_roles { + if has_relation( + state, + principal, + resource_type, + resource_id, + role, + &mut visited, + 0, + )? { + return Ok(true); + } + } + + // 5. Also check direct relations that match the permission name + // e.g. permission "start" might be granted by relation "owner" on the resource + if let Some(res_def) = state.resources.get(resource_type) { + for relation in &res_def.relations { + // A relation directly grants its name as a pseudo-permission + let qualified = format!("{resource_type}:{relation}"); + if qualified == permission || *relation == permission { + visited.clear(); + if has_relation( + state, + principal, + resource_type, + resource_id, + relation, + &mut visited, + 0, + )? { + return Ok(true); + } + } + } + } + + Ok(false) +} + +/// Check ABAC rules that match the permission and principal. +fn check_abac_rules( + state: &AuthzState, + principal: &str, + permission: &str, + context: &Value, +) -> Result { + for rule in &state.rules { + // Check if this rule applies to the requested permission + if !rule.permissions.contains(&permission.to_string()) { + continue; + } + + // Check if the principal matches any of the rule's principal patterns + let principal_match = rule.principals.is_empty() + || rule.principals.iter().any(|p| matches_principal(principal, p, state)); + + if !principal_match { + continue; + } + + // Evaluate condition if present + if let Some(cond_str) = &rule.condition { + let expr = condition::parse_condition(cond_str)?; + let result = condition::evaluate(&expr, context)?; + if result && rule.effect == "allow" { + return Ok(true); + } + } else if rule.effect == "allow" { + // No condition, rule applies unconditionally + return Ok(true); + } + } + Ok(false) +} + +/// Check if a principal matches a principal pattern. +/// Patterns: "group:finance" matches if principal is a member of group/finance. +fn matches_principal(principal: &str, pattern: &str, state: &AuthzState) -> bool { + // Direct match: pattern = "user/alice", principal = "user/alice" + if principal == pattern { + return true; + } + + // Group pattern: "group:groupname" -> check if principal has "member" on "group/groupname" + if let Some(group_name) = pattern.strip_prefix("group:") { + let subjects = state.tuples.subjects_for("group", group_name, "member"); + return subjects.iter().any(|s| s.as_direct() == principal); + } + + false +} + +/// Recursively check if principal holds the given relation on the object, +/// following userset references. +fn has_relation( + state: &AuthzState, + principal: &str, + object_type: &str, + object_id: &str, + relation: &str, + visited: &mut HashSet, + depth: usize, +) -> Result { + if depth >= MAX_DEPTH { + return Ok(false); + } + + let key = format!("{object_type}/{object_id}#{relation}@{depth}"); + if visited.contains(&key) { + return Ok(false); + } + visited.insert(key); + + let subjects = state.tuples.subjects_for(object_type, object_id, relation); + + for subject in subjects { + // Direct match + if subject.relation.is_none() && subject.as_direct() == principal { + return Ok(true); + } + + // Userset: subject is "type/id#relation" -> check if principal has that relation + if let Some(sub_relation) = &subject.relation { + if has_relation( + state, + principal, + &subject.subject_type, + &subject.subject_id, + sub_relation, + visited, + depth + 1, + )? { + return Ok(true); + } + } + } + + Ok(false) +} + +/// Expand: list all subjects that have the given permission on the resource. +pub fn expand( + state: &AuthzState, + permission: &str, + resource: &str, +) -> Result, AuthzError> { + let (resource_type, resource_id) = resource.split_once('/').ok_or_else(|| { + AuthzError::InvalidPolicy(format!( + "invalid resource reference `{resource}` (expected \"type/id\")" + )) + })?; + + let mut result = HashSet::new(); + + // Find which roles grant this permission + if let Some(granting_roles) = state.permission_roles.get(permission) { + for role in granting_roles { + collect_subjects( + state, + resource_type, + resource_id, + role, + &mut result, + &mut HashSet::new(), + 0, + )?; + } + } + + let mut subjects: Vec = result.into_iter().collect(); + subjects.sort(); + Ok(subjects) +} + +/// Recursively collect all direct subjects reachable via a relation on an object. +fn collect_subjects( + state: &AuthzState, + object_type: &str, + object_id: &str, + relation: &str, + result: &mut HashSet, + visited: &mut HashSet, + depth: usize, +) -> Result<(), AuthzError> { + if depth >= MAX_DEPTH { + return Ok(()); + } + + let key = format!("{object_type}/{object_id}#{relation}"); + if visited.contains(&key) { + return Ok(()); + } + visited.insert(key); + + let subjects = state.tuples.subjects_for(object_type, object_id, relation); + + for subject in subjects { + if subject.relation.is_none() { + result.insert(subject.as_direct()); + } else if let Some(sub_relation) = &subject.relation { + // Expand userset + collect_subjects( + state, + &subject.subject_type, + &subject.subject_id, + sub_relation, + result, + visited, + depth + 1, + )?; + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::authz::loader::compile_policies; + use crate::authz::types::*; + use serde_json::json; + + fn make_vm_state() -> AuthzState { + let parsed = ParsedPolicy { + resources: vec![ResourceDefinition { + resource_type: "vm".into(), + relations: vec!["owner".into()], + permissions: vec!["start".into(), "stop".into(), "view_console".into()], + }], + roles: vec![ + RoleDef { + name: "vm_viewer".into(), + permissions: vec!["vm:view_console".into()], + includes: vec![], + }, + RoleDef { + name: "vm_admin".into(), + permissions: vec!["vm:start".into(), "vm:stop".into()], + includes: vec!["vm_viewer".into()], + }, + ], + rules: vec![], + grants: vec![ + GrantTuple { + relation: "vm_admin".into(), + object_type: "vm".into(), + object_id: "vm-123".into(), + subject_type: "user".into(), + subject_id: "alice".into(), + subject_relation: None, + }, + GrantTuple { + relation: "vm_viewer".into(), + object_type: "vm".into(), + object_id: "vm-456".into(), + subject_type: "group".into(), + subject_id: "engineers".into(), + subject_relation: Some("member".into()), + }, + GrantTuple { + relation: "member".into(), + object_type: "group".into(), + object_id: "engineers".into(), + subject_type: "user".into(), + subject_id: "bob".into(), + subject_relation: None, + }, + ], + }; + compile_policies(vec![parsed]).unwrap() + } + + #[test] + fn test_check_direct_grant() { + let state = make_vm_state(); + // alice has vm_admin on vm/vm-123, which grants vm:start + assert!(check(&state, "user/alice", "vm:start", "vm/vm-123", &json!({})).unwrap()); + assert!(check(&state, "user/alice", "vm:stop", "vm/vm-123", &json!({})).unwrap()); + } + + #[test] + fn test_check_inherited_permission() { + let state = make_vm_state(); + // alice has vm_admin which includes vm_viewer -> vm:view_console + assert!( + check(&state, "user/alice", "vm:view_console", "vm/vm-123", &json!({})).unwrap() + ); + } + + #[test] + fn test_check_no_permission() { + let state = make_vm_state(); + // alice has no grant on vm-456 + assert!(!check(&state, "user/alice", "vm:start", "vm/vm-456", &json!({})).unwrap()); + // bob has no grant on vm-123 + assert!(!check(&state, "user/bob", "vm:start", "vm/vm-123", &json!({})).unwrap()); + } + + #[test] + fn test_check_userset_membership() { + let state = make_vm_state(); + // bob is member of group/engineers, which has vm_viewer on vm/vm-456 + assert!( + check(&state, "user/bob", "vm:view_console", "vm/vm-456", &json!({})).unwrap() + ); + // but bob can't start vm-456 (only viewer) + assert!(!check(&state, "user/bob", "vm:start", "vm/vm-456", &json!({})).unwrap()); + } + + #[test] + fn test_check_unknown_permission() { + let state = make_vm_state(); + assert!( + !check(&state, "user/alice", "vm:delete", "vm/vm-123", &json!({})).unwrap() + ); + } + + #[test] + fn test_expand_direct() { + let state = make_vm_state(); + let subjects = expand(&state, "vm:start", "vm/vm-123").unwrap(); + assert_eq!(subjects, vec!["user/alice"]); + } + + #[test] + fn test_expand_userset() { + let state = make_vm_state(); + let subjects = expand(&state, "vm:view_console", "vm/vm-456").unwrap(); + assert_eq!(subjects, vec!["user/bob"]); + } + + #[test] + fn test_check_abac_rule() { + let parsed = ParsedPolicy { + resources: vec![ResourceDefinition { + resource_type: "invoice".into(), + relations: vec![], + permissions: vec!["view".into()], + }], + roles: vec![], + rules: vec![PolicyRule { + name: "AllowFinanceDuringHours".into(), + effect: "allow".into(), + permissions: vec!["invoice:view".into()], + principals: vec!["group:finance".into()], + condition: Some( + "request.time.hour >= 9 && request.time.hour < 17".into(), + ), + }], + grants: vec![ + GrantTuple { + relation: "member".into(), + object_type: "group".into(), + object_id: "finance".into(), + subject_type: "user".into(), + subject_id: "carol".into(), + subject_relation: None, + }, + ], + }; + let state = compile_policies(vec![parsed]).unwrap(); + + // Carol is in finance, during business hours -> allowed + let ctx = json!({ "request": { "time": { "hour": 14 } } }); + assert!(check(&state, "user/carol", "invoice:view", "invoice/inv-1", &ctx).unwrap()); + + // Carol is in finance, outside business hours -> denied + let ctx_late = json!({ "request": { "time": { "hour": 22 } } }); + assert!( + !check(&state, "user/carol", "invoice:view", "invoice/inv-1", &ctx_late).unwrap() + ); + + // Dave is NOT in finance -> denied even during business hours + let ctx_hours = json!({ "request": { "time": { "hour": 14 } } }); + assert!( + !check(&state, "user/dave", "invoice:view", "invoice/inv-1", &ctx_hours).unwrap() + ); + } + + #[test] + fn test_max_depth_prevents_infinite_loop() { + // Create a deep chain of userset references + let mut grants = Vec::new(); + for i in 0..15 { + grants.push(GrantTuple { + relation: "member".into(), + object_type: "group".into(), + object_id: format!("g{i}"), + subject_type: "group".into(), + subject_id: format!("g{}", i + 1), + subject_relation: Some("member".into()), + }); + } + // The actual user at the end + grants.push(GrantTuple { + relation: "member".into(), + object_type: "group".into(), + object_id: "g15".into(), + subject_type: "user".into(), + subject_id: "deep".into(), + subject_relation: None, + }); + // Role that grants via the first group + grants.push(GrantTuple { + relation: "viewer".into(), + object_type: "res".into(), + object_id: "r1".into(), + subject_type: "group".into(), + subject_id: "g0".into(), + subject_relation: Some("member".into()), + }); + + let parsed = ParsedPolicy { + roles: vec![RoleDef { + name: "viewer".into(), + permissions: vec!["res:view".into()], + includes: vec![], + }], + grants, + ..Default::default() + }; + let state = compile_policies(vec![parsed]).unwrap(); + + // The chain is 16 levels deep, max depth is 10, so this should return false + assert!(!check(&state, "user/deep", "res:view", "res/r1", &json!({})).unwrap()); + } +} diff --git a/src/authz/errors.rs b/src/authz/errors.rs new file mode 100644 index 0000000..91b678f --- /dev/null +++ b/src/authz/errors.rs @@ -0,0 +1,86 @@ +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use miette::Diagnostic; +use serde_json::json; +use thiserror::Error; + +#[derive(Debug, Error, Diagnostic)] +pub enum AuthzError { + #[error("Failed to load policy file `{path}`")] + #[diagnostic( + code(barycenter::authz::policy_load), + help("Check that the file exists and contains valid KDL syntax") + )] + PolicyLoadError { + path: String, + #[source] + source: std::io::Error, + }, + + #[error("Invalid policy: {0}")] + #[diagnostic( + code(barycenter::authz::invalid_policy), + help("Each policy file must contain valid `resource`, `role`, `rule`, or `grant` KDL nodes") + )] + InvalidPolicy(String), + + #[error("Invalid grant: {0}")] + #[diagnostic( + code(barycenter::authz::invalid_grant), + help("Grant syntax: grant \"relation\" on=\"type/id\" to=\"subject_type/id\" (optionally to=\"type/id#relation\")") + )] + InvalidGrant(String), + + #[error("Invalid condition expression: {0}")] + #[diagnostic( + code(barycenter::authz::invalid_condition), + help("Supported operators: ==, !=, >, <, >=, <=, &&, ||, !, in. Paths use dot notation (e.g. request.ip)") + )] + InvalidCondition(String), + + #[error("Undefined resource type `{0}`")] + #[diagnostic( + code(barycenter::authz::undefined_resource), + help("Define the resource type with: resource \"\" {{ relations {{ ... }} permissions {{ ... }} }}") + )] + UndefinedResourceType(String), + + #[error("Undefined role `{0}`")] + #[diagnostic( + code(barycenter::authz::undefined_role), + help("Define the role with: role \"\" {{ permissions {{ ... }} }}") + )] + UndefinedRole(String), + + #[error("Cyclic role inheritance detected: {0}")] + #[diagnostic( + code(barycenter::authz::cyclic_roles), + help("Check the `includes` lists in your role definitions for circular references") + )] + CyclicRoleInheritance(String), + + #[error("KDL parse error: {0}")] + #[diagnostic( + code(barycenter::authz::kdl_parse), + help("Check your KDL file syntax — see https://kdl.dev for the specification") + )] + KdlParse(String), + + #[error("I/O error: {0}")] + #[diagnostic(code(barycenter::authz::io))] + Io(#[from] std::io::Error), +} + +impl IntoResponse for AuthzError { + fn into_response(self) -> Response { + let (status, message) = match &self { + AuthzError::InvalidPolicy(_) + | AuthzError::InvalidGrant(_) + | AuthzError::InvalidCondition(_) => (StatusCode::BAD_REQUEST, self.to_string()), + _ => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()), + }; + let body = json!({ "error": message }); + (status, Json(body)).into_response() + } +} diff --git a/src/authz/loader.rs b/src/authz/loader.rs new file mode 100644 index 0000000..c541b19 --- /dev/null +++ b/src/authz/loader.rs @@ -0,0 +1,378 @@ +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use crate::authz::errors::AuthzError; +use crate::authz::policy::parse_kdl_document; +use crate::authz::types::*; +use crate::authz::AuthzState; + +/// Load all `.kdl` policy files from the given directory and compile them +/// into a single immutable `AuthzState`. +pub fn load_policies(dir: &Path) -> Result { + if !dir.is_dir() { + return Err(AuthzError::InvalidPolicy(format!( + "policies directory `{}` does not exist or is not a directory", + dir.display() + ))); + } + + let mut all_parsed = Vec::new(); + let mut file_count = 0; + + let mut entries: Vec<_> = std::fs::read_dir(dir)? + .filter_map(|e| e.ok()) + .filter(|e| { + e.path() + .extension() + .map(|ext| ext == "kdl") + .unwrap_or(false) + }) + .collect(); + entries.sort_by_key(|e| e.path()); + + for entry in entries { + let path = entry.path(); + let contents = std::fs::read_to_string(&path).map_err(|source| { + AuthzError::PolicyLoadError { + path: path.display().to_string(), + source, + } + })?; + let parsed = parse_kdl_document(&contents)?; + all_parsed.push(parsed); + file_count += 1; + } + + let state = compile_policies(all_parsed)?; + + tracing::info!( + files = file_count, + resources = state.resources.len(), + roles = state.roles.len(), + rules = state.rules.len(), + tuples = state.tuples.tuple_count(), + "Loaded authorization policies" + ); + + Ok(state) +} + +/// Merge and compile all parsed policies into a single `AuthzState`. +pub fn compile_policies(parsed: Vec) -> Result { + let mut resources: HashMap = HashMap::new(); + let mut roles: HashMap = HashMap::new(); + let mut rules: Vec = Vec::new(); + let mut grants: Vec = Vec::new(); + + // Merge all parsed policies + for p in parsed { + for res in p.resources { + resources.insert(res.resource_type.clone(), res); + } + for role in p.roles { + roles.insert(role.name.clone(), role); + } + rules.extend(p.rules); + grants.extend(p.grants); + } + + // Validate role inheritance: no cycles (topological sort) + check_role_cycles(&roles)?; + + // Pre-validate condition expressions parse correctly + for rule in &rules { + if let Some(cond) = &rule.condition { + crate::authz::condition::parse_condition(cond)?; + } + } + + // Build permission_roles: permission -> list of role names that grant it + let permission_roles = build_permission_roles(&roles); + + // Build TupleIndex from grants + let mut tuples = TupleIndex::new(); + for g in &grants { + let obj = ObjectRef { + object_type: g.object_type.clone(), + object_id: g.object_id.clone(), + }; + let subj = SubjectRef { + subject_type: g.subject_type.clone(), + subject_id: g.subject_id.clone(), + relation: g.subject_relation.clone(), + }; + tuples.insert(&obj, &g.relation, &subj); + } + + Ok(AuthzState { + resources, + roles, + rules, + tuples, + permission_roles, + }) +} + +/// Check for cycles in role inheritance using DFS. +fn check_role_cycles(roles: &HashMap) -> Result<(), AuthzError> { + let mut visited = HashSet::new(); + let mut in_stack = HashSet::new(); + + for name in roles.keys() { + if !visited.contains(name) { + dfs_cycle_check(name, roles, &mut visited, &mut in_stack)?; + } + } + Ok(()) +} + +fn dfs_cycle_check( + name: &str, + roles: &HashMap, + visited: &mut HashSet, + in_stack: &mut HashSet, +) -> Result<(), AuthzError> { + visited.insert(name.to_string()); + in_stack.insert(name.to_string()); + + if let Some(role) = roles.get(name) { + for included in &role.includes { + if in_stack.contains(included.as_str()) { + return Err(AuthzError::CyclicRoleInheritance(format!( + "{name} -> {included}" + ))); + } + if !visited.contains(included.as_str()) { + dfs_cycle_check(included, roles, visited, in_stack)?; + } + } + } + + in_stack.remove(name); + Ok(()) +} + +/// Build a map: fully-qualified permission -> list of role names that grant it +/// (expanding role inheritance). +fn build_permission_roles(roles: &HashMap) -> HashMap> { + let mut map: HashMap> = HashMap::new(); + + for (role_name, _) in roles { + let perms = collect_role_permissions(role_name, roles, &mut HashSet::new()); + for perm in perms { + map.entry(perm).or_default().push(role_name.clone()); + } + } + + map +} + +/// Recursively collect all permissions from a role, following includes. +fn collect_role_permissions( + role_name: &str, + roles: &HashMap, + visited: &mut HashSet, +) -> Vec { + if visited.contains(role_name) { + return Vec::new(); + } + visited.insert(role_name.to_string()); + + let Some(role) = roles.get(role_name) else { + return Vec::new(); + }; + + let mut perms: Vec = role.permissions.clone(); + for included in &role.includes { + perms.extend(collect_role_permissions(included, roles, visited)); + } + + perms +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_parsed_vm() -> ParsedPolicy { + ParsedPolicy { + resources: vec![ResourceDefinition { + resource_type: "vm".into(), + relations: vec!["owner".into(), "viewer".into()], + permissions: vec!["start".into(), "stop".into(), "view_console".into()], + }], + roles: vec![ + RoleDef { + name: "vm_viewer".into(), + permissions: vec!["vm:view_console".into()], + includes: vec![], + }, + RoleDef { + name: "vm_admin".into(), + permissions: vec!["vm:start".into(), "vm:stop".into()], + includes: vec!["vm_viewer".into()], + }, + ], + rules: vec![], + grants: vec![ + GrantTuple { + relation: "vm_admin".into(), + object_type: "vm".into(), + object_id: "vm-123".into(), + subject_type: "user".into(), + subject_id: "alice".into(), + subject_relation: None, + }, + ], + } + } + + #[test] + fn test_compile_basic() { + let state = compile_policies(vec![make_parsed_vm()]).unwrap(); + assert_eq!(state.resources.len(), 1); + assert_eq!(state.roles.len(), 2); + assert_eq!(state.tuples.tuple_count(), 1); + } + + #[test] + fn test_permission_roles_inheritance() { + let state = compile_policies(vec![make_parsed_vm()]).unwrap(); + // vm_admin grants vm:start, vm:stop directly and vm:view_console via includes + let start_roles = state.permission_roles.get("vm:start").unwrap(); + assert!(start_roles.contains(&"vm_admin".to_string())); + + let view_roles = state.permission_roles.get("vm:view_console").unwrap(); + assert!(view_roles.contains(&"vm_viewer".to_string())); + assert!(view_roles.contains(&"vm_admin".to_string())); + } + + #[test] + fn test_cyclic_roles_detected() { + let parsed = ParsedPolicy { + roles: vec![ + RoleDef { + name: "a".into(), + permissions: vec![], + includes: vec!["b".into()], + }, + RoleDef { + name: "b".into(), + permissions: vec![], + includes: vec!["a".into()], + }, + ], + ..Default::default() + }; + let err = compile_policies(vec![parsed]).unwrap_err(); + assert!(matches!(err, AuthzError::CyclicRoleInheritance(_))); + } + + #[test] + fn test_merge_multiple_files() { + let p1 = ParsedPolicy { + resources: vec![ResourceDefinition { + resource_type: "vm".into(), + relations: vec![], + permissions: vec!["start".into()], + }], + roles: vec![RoleDef { + name: "vm_admin".into(), + permissions: vec!["vm:start".into()], + includes: vec![], + }], + grants: vec![GrantTuple { + relation: "vm_admin".into(), + object_type: "vm".into(), + object_id: "vm-1".into(), + subject_type: "user".into(), + subject_id: "alice".into(), + subject_relation: None, + }], + ..Default::default() + }; + let p2 = ParsedPolicy { + resources: vec![ResourceDefinition { + resource_type: "invoice".into(), + relations: vec![], + permissions: vec!["view".into()], + }], + grants: vec![GrantTuple { + relation: "viewer".into(), + object_type: "invoice".into(), + object_id: "inv-1".into(), + subject_type: "user".into(), + subject_id: "bob".into(), + subject_relation: None, + }], + ..Default::default() + }; + + let state = compile_policies(vec![p1, p2]).unwrap(); + assert_eq!(state.resources.len(), 2); + assert_eq!(state.tuples.tuple_count(), 2); + } + + #[test] + fn test_load_from_directory() { + let dir = tempfile::tempdir().unwrap(); + + // Write vm_policy.kdl + std::fs::write( + dir.path().join("vm_policy.kdl"), + r#" +resource "vm" { + relations { + - "owner" + } + permissions { + - "start" + - "stop" + } +} + +role "vm_admin" { + permissions { + - "vm:start" + - "vm:stop" + } +} + +grant "vm_admin" on="vm/vm-123" to="user/alice" +"#, + ) + .unwrap(); + + // Write invoice_policy.kdl + std::fs::write( + dir.path().join("invoice_policy.kdl"), + r#" +resource "invoice" { + permissions { + - "view" + - "pay" + } +} + +grant "member" on="group/finance" to="user/carol" +"#, + ) + .unwrap(); + + // Also write a non-KDL file that should be ignored + std::fs::write(dir.path().join("README.md"), "not a policy").unwrap(); + + let state = load_policies(dir.path()).unwrap(); + assert_eq!(state.resources.len(), 2); + assert!(state.resources.contains_key("vm")); + assert!(state.resources.contains_key("invoice")); + assert_eq!(state.roles.len(), 1); + assert_eq!(state.tuples.tuple_count(), 2); + } + + #[test] + fn test_load_nonexistent_directory() { + let err = load_policies(Path::new("/nonexistent/path")).unwrap_err(); + assert!(matches!(err, AuthzError::InvalidPolicy(_))); + } +} diff --git a/src/authz/mod.rs b/src/authz/mod.rs new file mode 100644 index 0000000..b4117c3 --- /dev/null +++ b/src/authz/mod.rs @@ -0,0 +1,26 @@ +pub mod condition; +pub mod engine; +pub mod errors; +pub mod loader; +pub mod policy; +pub mod types; +pub mod web; + +use std::collections::HashMap; +use types::{PolicyRule, ResourceDefinition, RoleDef, TupleIndex}; + +/// Fully compiled authorization state, loaded from KDL policy files. +/// Immutable after construction — configuration changes require a service reload. +#[derive(Debug)] +pub struct AuthzState { + /// resource_type -> ResourceDefinition + pub resources: HashMap, + /// role_name -> RoleDef (permissions + includes) + pub roles: HashMap, + /// ABAC rules + pub rules: Vec, + /// All relationship tuples, indexed for fast lookup + pub tuples: TupleIndex, + /// permission -> list of role names that grant it (pre-computed, includes inheritance) + pub permission_roles: HashMap>, +} diff --git a/src/authz/policy.rs b/src/authz/policy.rs new file mode 100644 index 0000000..d5c7ba2 --- /dev/null +++ b/src/authz/policy.rs @@ -0,0 +1,380 @@ +use crate::authz::errors::AuthzError; +use crate::authz::types::*; +use kdl::KdlDocument; + +/// Parse a KDL document string into typed policy structs. +pub fn parse_kdl_document(source: &str) -> Result { + let doc: KdlDocument = source + .parse() + .map_err(|e: kdl::KdlError| AuthzError::KdlParse(e.to_string()))?; + + let mut policy = ParsedPolicy::default(); + + for node in doc.nodes() { + match node.name().value() { + "resource" => { + let resource_type = first_string_arg(node).ok_or_else(|| { + AuthzError::InvalidPolicy( + "resource node requires a string argument (e.g. resource \"vm\")".into(), + ) + })?; + + let mut relations = Vec::new(); + let mut permissions = Vec::new(); + + if let Some(children) = node.children() { + for child in children.nodes() { + match child.name().value() { + "relations" => { + relations = dash_list(child); + } + "permissions" => { + permissions = dash_list(child); + } + other => { + return Err(AuthzError::InvalidPolicy(format!( + "unexpected child `{other}` in resource `{resource_type}` (expected `relations` or `permissions`)" + ))); + } + } + } + } + + policy.resources.push(ResourceDefinition { + resource_type, + relations, + permissions, + }); + } + "role" => { + let name = first_string_arg(node).ok_or_else(|| { + AuthzError::InvalidPolicy( + "role node requires a string argument (e.g. role \"vm_admin\")".into(), + ) + })?; + + let mut permissions = Vec::new(); + let mut includes = Vec::new(); + + if let Some(children) = node.children() { + for child in children.nodes() { + match child.name().value() { + "permissions" => { + permissions = dash_list(child); + } + "includes" => { + includes = dash_list(child); + } + other => { + return Err(AuthzError::InvalidPolicy(format!( + "unexpected child `{other}` in role `{name}` (expected `permissions` or `includes`)" + ))); + } + } + } + } + + policy.roles.push(RoleDef { + name, + permissions, + includes, + }); + } + "rule" => { + let name = first_string_arg(node).ok_or_else(|| { + AuthzError::InvalidPolicy( + "rule node requires a string argument (e.g. rule \"MyRule\" effect=\"allow\")" + .into(), + ) + })?; + + let effect = node + .get("effect") + .and_then(|v| v.as_string()) + .unwrap_or("allow") + .to_string(); + + let mut permissions = Vec::new(); + let mut principals = Vec::new(); + let mut condition = None; + + if let Some(children) = node.children() { + for child in children.nodes() { + match child.name().value() { + "permissions" => { + permissions = dash_list(child); + } + "principals" => { + principals = dash_list(child); + } + "condition" => { + condition = first_string_arg(child); + } + other => { + return Err(AuthzError::InvalidPolicy(format!( + "unexpected child `{other}` in rule `{name}`" + ))); + } + } + } + } + + policy.rules.push(PolicyRule { + name, + effect, + permissions, + principals, + condition, + }); + } + "grant" => { + let relation = first_string_arg(node).ok_or_else(|| { + AuthzError::InvalidGrant( + "grant node requires a relation argument (e.g. grant \"vm_admin\" on=\"vm/vm-123\" to=\"user/alice\")" + .into(), + ) + })?; + + let on = node + .get("on") + .and_then(|v| v.as_string()) + .ok_or_else(|| { + AuthzError::InvalidGrant(format!( + "grant `{relation}` missing `on` property (e.g. on=\"vm/vm-123\")" + )) + })?; + + let to = node + .get("to") + .and_then(|v| v.as_string()) + .ok_or_else(|| { + AuthzError::InvalidGrant(format!( + "grant `{relation}` missing `to` property (e.g. to=\"user/alice\")" + )) + })?; + + let obj = ObjectRef::parse(on).ok_or_else(|| { + AuthzError::InvalidGrant(format!( + "invalid object reference `{on}` in grant `{relation}` (expected \"type/id\")" + )) + })?; + + let subj = SubjectRef::parse(to).ok_or_else(|| { + AuthzError::InvalidGrant(format!( + "invalid subject reference `{to}` in grant `{relation}` (expected \"type/id\" or \"type/id#relation\")" + )) + })?; + + policy.grants.push(GrantTuple { + relation, + object_type: obj.object_type, + object_id: obj.object_id, + subject_type: subj.subject_type, + subject_id: subj.subject_id, + subject_relation: subj.relation, + }); + } + other => { + // Ignore comments and unknown top-level nodes with a warning + tracing::warn!("ignoring unknown top-level KDL node `{other}`"); + } + } + } + + Ok(policy) +} + +/// Extract the first string argument from a KDL node. +fn first_string_arg(node: &kdl::KdlNode) -> Option { + node.entries() + .iter() + .find(|e| e.name().is_none()) + .and_then(|e| e.value().as_string()) + .map(|s| s.to_string()) +} + +/// Extract dash-list children: nodes named "-" whose first argument is a string. +/// Example KDL: +/// ```kdl +/// permissions { +/// - "start" +/// - "stop" +/// } +/// ``` +fn dash_list(node: &kdl::KdlNode) -> Vec { + let Some(children) = node.children() else { + return Vec::new(); + }; + children + .nodes() + .iter() + .filter(|n| n.name().value() == "-") + .filter_map(|n| first_string_arg(n)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_resource() { + let kdl = r#" +resource "vm" { + relations { + - "owner" + - "viewer" + } + permissions { + - "start" + - "stop" + - "view_console" + } +} +"#; + let policy = parse_kdl_document(kdl).unwrap(); + assert_eq!(policy.resources.len(), 1); + let res = &policy.resources[0]; + assert_eq!(res.resource_type, "vm"); + assert_eq!(res.relations, vec!["owner", "viewer"]); + assert_eq!(res.permissions, vec!["start", "stop", "view_console"]); + } + + #[test] + fn test_parse_role_with_includes() { + let kdl = r#" +role "vm_viewer" { + permissions { + - "vm:view_console" + } +} + +role "vm_admin" { + includes { + - "vm_viewer" + } + permissions { + - "vm:start" + - "vm:stop" + } +} +"#; + let policy = parse_kdl_document(kdl).unwrap(); + assert_eq!(policy.roles.len(), 2); + + let admin = &policy.roles[1]; + assert_eq!(admin.name, "vm_admin"); + assert_eq!(admin.includes, vec!["vm_viewer"]); + assert_eq!(admin.permissions, vec!["vm:start", "vm:stop"]); + } + + #[test] + fn test_parse_rule_with_condition() { + let kdl = r#" +rule "AllowFinanceViewDuringBusinessHours" effect="allow" { + permissions { + - "invoice:view" + } + principals { + - "group:finance" + } + condition "request.time.hour >= 9 && request.time.hour < 17" +} +"#; + let policy = parse_kdl_document(kdl).unwrap(); + assert_eq!(policy.rules.len(), 1); + let rule = &policy.rules[0]; + assert_eq!(rule.name, "AllowFinanceViewDuringBusinessHours"); + assert_eq!(rule.effect, "allow"); + assert_eq!(rule.permissions, vec!["invoice:view"]); + assert_eq!(rule.principals, vec!["group:finance"]); + assert_eq!( + rule.condition.as_deref(), + Some("request.time.hour >= 9 && request.time.hour < 17") + ); + } + + #[test] + fn test_parse_grant_direct() { + let kdl = r#" +grant "vm_admin" on="vm/vm-123" to="user/alice" +"#; + let policy = parse_kdl_document(kdl).unwrap(); + assert_eq!(policy.grants.len(), 1); + let g = &policy.grants[0]; + assert_eq!(g.relation, "vm_admin"); + assert_eq!(g.object_type, "vm"); + assert_eq!(g.object_id, "vm-123"); + assert_eq!(g.subject_type, "user"); + assert_eq!(g.subject_id, "alice"); + assert!(g.subject_relation.is_none()); + } + + #[test] + fn test_parse_grant_userset() { + let kdl = r#" +grant "vm_viewer" on="vm/vm-456" to="group/engineers#member" +"#; + let policy = parse_kdl_document(kdl).unwrap(); + assert_eq!(policy.grants.len(), 1); + let g = &policy.grants[0]; + assert_eq!(g.relation, "vm_viewer"); + assert_eq!(g.subject_type, "group"); + assert_eq!(g.subject_id, "engineers"); + assert_eq!(g.subject_relation.as_deref(), Some("member")); + } + + #[test] + fn test_parse_full_vm_policy() { + let kdl = r#" +resource "vm" { + relations { + - "owner" + - "viewer" + } + permissions { + - "start" + - "stop" + - "view_console" + } +} + +role "vm_viewer" { + permissions { + - "vm:view_console" + } +} + +role "vm_admin" { + includes { + - "vm_viewer" + } + permissions { + - "vm:start" + - "vm:stop" + } +} + +grant "vm_admin" on="vm/vm-123" to="user/alice" +grant "vm_viewer" on="vm/vm-456" to="group/engineers#member" +"#; + let policy = parse_kdl_document(kdl).unwrap(); + assert_eq!(policy.resources.len(), 1); + assert_eq!(policy.roles.len(), 2); + assert_eq!(policy.grants.len(), 2); + } + + #[test] + fn test_parse_missing_grant_on() { + let kdl = r#"grant "admin" to="user/alice""#; + let err = parse_kdl_document(kdl).unwrap_err(); + assert!(matches!(err, AuthzError::InvalidGrant(_))); + } + + #[test] + fn test_parse_missing_grant_to() { + let kdl = r#"grant "admin" on="vm/vm-1""#; + let err = parse_kdl_document(kdl).unwrap_err(); + assert!(matches!(err, AuthzError::InvalidGrant(_))); + } +} diff --git a/src/authz/types.rs b/src/authz/types.rs new file mode 100644 index 0000000..5b865f6 --- /dev/null +++ b/src/authz/types.rs @@ -0,0 +1,285 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Reference to an object: "type/id" e.g. "vm/vm-123" +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ObjectRef { + pub object_type: String, + pub object_id: String, +} + +impl ObjectRef { + pub fn parse(s: &str) -> Option { + let (t, id) = s.split_once('/')?; + if t.is_empty() || id.is_empty() { + return None; + } + Some(Self { + object_type: t.to_string(), + object_id: id.to_string(), + }) + } +} + +impl std::fmt::Display for ObjectRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}/{}", self.object_type, self.object_id) + } +} + +/// Reference to a subject: "type/id" or "type/id#relation" for usersets +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SubjectRef { + pub subject_type: String, + pub subject_id: String, + /// Optional relation for userset references like "group/engineers#member" + pub relation: Option, +} + +impl SubjectRef { + pub fn parse(s: &str) -> Option { + // Try "type/id#relation" first + if let Some((type_id, relation)) = s.split_once('#') { + let (t, id) = type_id.split_once('/')?; + if t.is_empty() || id.is_empty() || relation.is_empty() { + return None; + } + Some(Self { + subject_type: t.to_string(), + subject_id: id.to_string(), + relation: Some(relation.to_string()), + }) + } else { + let (t, id) = s.split_once('/')?; + if t.is_empty() || id.is_empty() { + return None; + } + Some(Self { + subject_type: t.to_string(), + subject_id: id.to_string(), + relation: None, + }) + } + } + + /// Returns "type/id" without the relation part. + pub fn as_direct(&self) -> String { + format!("{}/{}", self.subject_type, self.subject_id) + } +} + +impl std::fmt::Display for SubjectRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}/{}", self.subject_type, self.subject_id)?; + if let Some(rel) = &self.relation { + write!(f, "#{}", rel)?; + } + Ok(()) + } +} + +/// Indexed collection of relationship tuples for fast lookup. +#[derive(Debug, Clone, Default)] +pub struct TupleIndex { + /// (object_type, object_id, relation) -> list of subjects + by_object: HashMap<(String, String, String), Vec>, + /// (subject_type, subject_id) -> list of (object, relation) + by_subject: HashMap<(String, String), Vec<(ObjectRef, String)>>, +} + +impl TupleIndex { + pub fn new() -> Self { + Self::default() + } + + pub fn insert(&mut self, object: &ObjectRef, relation: &str, subject: &SubjectRef) { + self.by_object + .entry(( + object.object_type.clone(), + object.object_id.clone(), + relation.to_string(), + )) + .or_default() + .push(subject.clone()); + + self.by_subject + .entry((subject.subject_type.clone(), subject.subject_id.clone())) + .or_default() + .push((object.clone(), relation.to_string())); + } + + /// Get all subjects that have `relation` on the given object. + pub fn subjects_for( + &self, + object_type: &str, + object_id: &str, + relation: &str, + ) -> &[SubjectRef] { + self.by_object + .get(&( + object_type.to_string(), + object_id.to_string(), + relation.to_string(), + )) + .map(|v| v.as_slice()) + .unwrap_or(&[]) + } + + /// Get all (object, relation) pairs where the given subject appears. + pub fn objects_for(&self, subject_type: &str, subject_id: &str) -> &[(ObjectRef, String)] { + self.by_subject + .get(&(subject_type.to_string(), subject_id.to_string())) + .map(|v| v.as_slice()) + .unwrap_or(&[]) + } + + pub fn tuple_count(&self) -> usize { + self.by_object.values().map(|v| v.len()).sum() + } +} + +// ---------- API request/response types ---------- + +#[derive(Debug, Deserialize)] +pub struct CheckRequest { + /// e.g. "user/alice" + pub principal: String, + /// e.g. "vm:start" + pub permission: String, + /// e.g. "vm/vm-123" + pub resource: String, + /// Optional context for ABAC condition evaluation + #[serde(default)] + pub context: serde_json::Value, +} + +#[derive(Debug, Serialize)] +pub struct CheckResponse { + pub allowed: bool, +} + +#[derive(Debug, Deserialize)] +pub struct ExpandRequest { + /// e.g. "vm:start" + pub permission: String, + /// e.g. "vm/vm-123" + pub resource: String, +} + +#[derive(Debug, Serialize)] +pub struct ExpandResponse { + pub subjects: Vec, +} + +// ---------- Policy domain types ---------- + +#[derive(Debug, Clone)] +pub struct ResourceDefinition { + pub resource_type: String, + pub relations: Vec, + pub permissions: Vec, +} + +#[derive(Debug, Clone)] +pub struct RoleDef { + pub name: String, + /// Fully-qualified permissions like "vm:start" + pub permissions: Vec, + /// Other role names this role includes (inherits from) + pub includes: Vec, +} + +#[derive(Debug, Clone)] +pub struct PolicyRule { + pub name: String, + /// "allow" or "deny" + pub effect: String, + /// Fully-qualified permissions like "invoice:view" + pub permissions: Vec, + /// Principal patterns like "group:finance" + pub principals: Vec, + /// Optional condition expression (raw string, compiled on load) + pub condition: Option, +} + +/// A single relationship tuple parsed from a `grant` KDL node. +#[derive(Debug, Clone)] +pub struct GrantTuple { + pub relation: String, + pub object_type: String, + pub object_id: String, + pub subject_type: String, + pub subject_id: String, + /// Optional relation on the subject for userset references + pub subject_relation: Option, +} + +/// Intermediate result from parsing a single KDL file. +#[derive(Debug, Clone, Default)] +pub struct ParsedPolicy { + pub resources: Vec, + pub roles: Vec, + pub rules: Vec, + pub grants: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_object_ref_parse() { + let r = ObjectRef::parse("vm/vm-123").unwrap(); + assert_eq!(r.object_type, "vm"); + assert_eq!(r.object_id, "vm-123"); + assert_eq!(r.to_string(), "vm/vm-123"); + + assert!(ObjectRef::parse("noslash").is_none()); + assert!(ObjectRef::parse("/id").is_none()); + assert!(ObjectRef::parse("type/").is_none()); + } + + #[test] + fn test_subject_ref_parse_direct() { + let s = SubjectRef::parse("user/alice").unwrap(); + assert_eq!(s.subject_type, "user"); + assert_eq!(s.subject_id, "alice"); + assert!(s.relation.is_none()); + assert_eq!(s.as_direct(), "user/alice"); + } + + #[test] + fn test_subject_ref_parse_userset() { + let s = SubjectRef::parse("group/engineers#member").unwrap(); + assert_eq!(s.subject_type, "group"); + assert_eq!(s.subject_id, "engineers"); + assert_eq!(s.relation.as_deref(), Some("member")); + assert_eq!(s.to_string(), "group/engineers#member"); + } + + #[test] + fn test_tuple_index() { + let mut idx = TupleIndex::new(); + let obj = ObjectRef { + object_type: "vm".into(), + object_id: "vm-1".into(), + }; + let subj = SubjectRef { + subject_type: "user".into(), + subject_id: "alice".into(), + relation: None, + }; + idx.insert(&obj, "owner", &subj); + + let subjects = idx.subjects_for("vm", "vm-1", "owner"); + assert_eq!(subjects.len(), 1); + assert_eq!(subjects[0].subject_id, "alice"); + + let objects = idx.objects_for("user", "alice"); + assert_eq!(objects.len(), 1); + assert_eq!(objects[0].0.object_id, "vm-1"); + assert_eq!(objects[0].1, "owner"); + + assert_eq!(idx.tuple_count(), 1); + } +} diff --git a/src/authz/web.rs b/src/authz/web.rs new file mode 100644 index 0000000..dee9cff --- /dev/null +++ b/src/authz/web.rs @@ -0,0 +1,43 @@ +use std::sync::Arc; + +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::routing::{get, post}; +use axum::{Json, Router}; + +use crate::authz::engine; +use crate::authz::types::{CheckRequest, CheckResponse, ExpandRequest, ExpandResponse}; +use crate::authz::AuthzState; + +pub fn router(state: Arc) -> Router { + Router::new() + .route("/v1/check", post(handle_check)) + .route("/v1/expand", post(handle_expand)) + .route("/healthz", get(health)) + .with_state(state) +} + +async fn handle_check( + State(state): State>, + Json(req): Json, +) -> impl IntoResponse { + match engine::check(&state, &req.principal, &req.permission, &req.resource, &req.context) { + Ok(allowed) => Json(CheckResponse { allowed }).into_response(), + Err(e) => e.into_response(), + } +} + +async fn handle_expand( + State(state): State>, + Json(req): Json, +) -> impl IntoResponse { + match engine::expand(&state, &req.permission, &req.resource) { + Ok(subjects) => Json(ExpandResponse { subjects }).into_response(), + Err(e) => e.into_response(), + } +} + +async fn health() -> impl IntoResponse { + (StatusCode::OK, "ok") +} diff --git a/src/lib.rs b/src/lib.rs index b4fe3ae..4cf4af7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ pub mod admin_graphql; pub mod admin_mutations; +pub mod authz; pub mod entities; pub mod errors; pub mod jobs; diff --git a/src/settings.rs b/src/settings.rs index 762e38d..34901d4 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -9,6 +9,8 @@ pub struct Settings { pub keys: Keys, #[serde(default)] pub federation: Federation, + #[serde(default)] + pub authz: AuthzSettings, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -55,6 +57,32 @@ pub struct Federation { pub trust_anchors: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthzSettings { + /// Enable the authorization policy service. Default: false. + #[serde(default)] + pub enabled: bool, + /// Port for the authz REST API (defaults to main port + 2). + pub port: Option, + /// Directory containing `.kdl` policy files. + #[serde(default = "default_policies_dir")] + pub policies_dir: PathBuf, +} + +fn default_policies_dir() -> PathBuf { + PathBuf::from("policies") +} + +impl Default for AuthzSettings { + fn default() -> Self { + Self { + enabled: false, + port: None, + policies_dir: default_policies_dir(), + } + } +} + impl Default for Server { fn default() -> Self { Self { diff --git a/src/web.rs b/src/web.rs index f5d2a35..612b433 100644 --- a/src/web.rs +++ b/src/web.rs @@ -190,6 +190,32 @@ pub async fn serve( .expect("Admin server failed"); }); + // Start authorization policy server (if enabled) + if state.settings.authz.enabled { + let authz_state = std::sync::Arc::new( + crate::authz::loader::load_policies(&state.settings.authz.policies_dir) + .map_err(|e| miette::miette!("failed to load authz policies: {e}"))?, + ); + let authz_port = state + .settings + .authz + .port + .unwrap_or(state.settings.server.port + 2); + let authz_addr: SocketAddr = format!("{}:{}", state.settings.server.host, authz_port) + .parse() + .map_err(|e| miette::miette!("bad authz addr: {e}"))?; + let authz_router = crate::authz::web::router(authz_state); + let authz_listener = tokio::net::TcpListener::bind(authz_addr) + .await + .into_diagnostic()?; + tracing::info!(%authz_addr, "Authorization policy API listening"); + tokio::spawn(async move { + axum::serve(authz_listener, authz_router) + .await + .expect("Authz server failed"); + }); + } + // Start public server tracing::info!(%public_addr, "Public API listening"); tracing::warn!("Rate limiting should be configured at the reverse proxy level for production");