Implement file-driven authorization policy service (ReBAC + ABAC)

Add a Zanzibar-style relationship-based access control engine with
OPA-style ABAC condition evaluation. Policies, roles, resources, and
grants are defined in KDL files loaded from a configured directory at
startup. Exposes a read-only REST API (POST /v1/check, /v1/expand,
GET /healthz) on a dedicated port when authz.enabled = true.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Till Wegmueller 2026-02-08 18:34:14 +01:00
parent 95a55c5f24
commit e0ca87f867
No known key found for this signature in database
13 changed files with 2504 additions and 3 deletions

61
Cargo.lock generated
View file

@ -510,6 +510,7 @@ dependencies = [
"clap", "clap",
"config", "config",
"josekit", "josekit",
"kdl",
"miette", "miette",
"migration", "migration",
"oauth2", "oauth2",
@ -2211,6 +2212,17 @@ dependencies = [
"serde", "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]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@ -2501,6 +2513,20 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "num-bigint" name = "num-bigint"
version = "0.4.6" version = "0.4.6"
@ -2527,6 +2553,15 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.1.0" version = "0.1.0"
@ -2564,6 +2599,17 @@ dependencies = [
"num-traits", "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]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@ -4801,7 +4847,7 @@ dependencies = [
"serde_spanned", "serde_spanned",
"toml_datetime 0.6.11", "toml_datetime 0.6.11",
"toml_write", "toml_write",
"winnow", "winnow 0.7.13",
] ]
[[package]] [[package]]
@ -4813,7 +4859,7 @@ dependencies = [
"indexmap 2.12.1", "indexmap 2.12.1",
"toml_datetime 0.7.3", "toml_datetime 0.7.3",
"toml_parser", "toml_parser",
"winnow", "winnow 0.7.13",
] ]
[[package]] [[package]]
@ -4822,7 +4868,7 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e"
dependencies = [ dependencies = [
"winnow", "winnow 0.7.13",
] ]
[[package]] [[package]]
@ -5708,6 +5754,15 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.6.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d71a593cc5c42ad7876e2c1fda56f314f3754c084128833e64f1345ff8a03a"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "0.7.13" version = "0.7.13"

View file

@ -72,6 +72,9 @@ async-graphql-axum = "7"
tokio-cron-scheduler = "0.13" tokio-cron-scheduler = "0.13"
bincode = "2.0.1" bincode = "2.0.1"
# Policy / authorization engine
kdl = "6"
[dev-dependencies] [dev-dependencies]
# Existing OIDC/OAuth testing # Existing OIDC/OAuth testing
openidconnect = { version = "4", features = ["reqwest-blocking"] } openidconnect = { version = "4", features = ["reqwest-blocking"] }

722
src/authz/condition.rs Normal file
View file

@ -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<String>),
BinOp {
op: BinOp,
left: Box<Expr>,
right: Box<Expr>,
},
UnaryNot(Box<Expr>),
In {
element: Box<Expr>,
collection: Box<Expr>,
},
}
#[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<Token>,
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<Vec<Token>, AuthzError> {
let mut tokens = Vec::new();
let chars: Vec<char> = 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<Token>) -> Self {
Self { tokens, pos: 0 }
}
fn peek(&self) -> Option<&Token> {
self.tokens.get(self.pos)
}
fn advance(&mut self) -> Option<Token> {
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<Expr, AuthzError> {
self.parse_or()
}
/// or_expr = and_expr ("||" and_expr)*
fn parse_or(&mut self) -> Result<Expr, AuthzError> {
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<Expr, AuthzError> {
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<Expr, AuthzError> {
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<Expr, AuthzError> {
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<Expr, AuthzError> {
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<Expr, AuthzError> {
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<bool, AuthzError> {
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<EvalResult>),
Null,
}
impl EvalResult {
fn as_f64(&self) -> Option<f64> {
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<EvalResult, AuthzError> {
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());
}
}

468
src/authz/engine.rs Normal file
View file

@ -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<bool, AuthzError> {
// 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<bool, AuthzError> {
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<String>,
depth: usize,
) -> Result<bool, AuthzError> {
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<Vec<String>, 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<String> = 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<String>,
visited: &mut HashSet<String>,
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());
}
}

86
src/authz/errors.rs Normal file
View file

@ -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 \"<name>\" {{ relations {{ ... }} permissions {{ ... }} }}")
)]
UndefinedResourceType(String),
#[error("Undefined role `{0}`")]
#[diagnostic(
code(barycenter::authz::undefined_role),
help("Define the role with: role \"<name>\" {{ 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()
}
}

378
src/authz/loader.rs Normal file
View file

@ -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<AuthzState, AuthzError> {
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<ParsedPolicy>) -> Result<AuthzState, AuthzError> {
let mut resources: HashMap<String, ResourceDefinition> = HashMap::new();
let mut roles: HashMap<String, RoleDef> = HashMap::new();
let mut rules: Vec<PolicyRule> = Vec::new();
let mut grants: Vec<GrantTuple> = 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<String, RoleDef>) -> 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<String, RoleDef>,
visited: &mut HashSet<String>,
in_stack: &mut HashSet<String>,
) -> 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<String, RoleDef>) -> HashMap<String, Vec<String>> {
let mut map: HashMap<String, Vec<String>> = 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<String, RoleDef>,
visited: &mut HashSet<String>,
) -> Vec<String> {
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<String> = 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(_)));
}
}

26
src/authz/mod.rs Normal file
View file

@ -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<String, ResourceDefinition>,
/// role_name -> RoleDef (permissions + includes)
pub roles: HashMap<String, RoleDef>,
/// ABAC rules
pub rules: Vec<PolicyRule>,
/// 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<String, Vec<String>>,
}

380
src/authz/policy.rs Normal file
View file

@ -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<ParsedPolicy, AuthzError> {
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<String> {
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<String> {
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(_)));
}
}

285
src/authz/types.rs Normal file
View file

@ -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<Self> {
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<String>,
}
impl SubjectRef {
pub fn parse(s: &str) -> Option<Self> {
// 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<SubjectRef>>,
/// (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<String>,
}
// ---------- Policy domain types ----------
#[derive(Debug, Clone)]
pub struct ResourceDefinition {
pub resource_type: String,
pub relations: Vec<String>,
pub permissions: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct RoleDef {
pub name: String,
/// Fully-qualified permissions like "vm:start"
pub permissions: Vec<String>,
/// Other role names this role includes (inherits from)
pub includes: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct PolicyRule {
pub name: String,
/// "allow" or "deny"
pub effect: String,
/// Fully-qualified permissions like "invoice:view"
pub permissions: Vec<String>,
/// Principal patterns like "group:finance"
pub principals: Vec<String>,
/// Optional condition expression (raw string, compiled on load)
pub condition: Option<String>,
}
/// 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<String>,
}
/// Intermediate result from parsing a single KDL file.
#[derive(Debug, Clone, Default)]
pub struct ParsedPolicy {
pub resources: Vec<ResourceDefinition>,
pub roles: Vec<RoleDef>,
pub rules: Vec<PolicyRule>,
pub grants: Vec<GrantTuple>,
}
#[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);
}
}

43
src/authz/web.rs Normal file
View file

@ -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<AuthzState>) -> 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<Arc<AuthzState>>,
Json(req): Json<CheckRequest>,
) -> 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<Arc<AuthzState>>,
Json(req): Json<ExpandRequest>,
) -> 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")
}

View file

@ -5,6 +5,7 @@
pub mod admin_graphql; pub mod admin_graphql;
pub mod admin_mutations; pub mod admin_mutations;
pub mod authz;
pub mod entities; pub mod entities;
pub mod errors; pub mod errors;
pub mod jobs; pub mod jobs;

View file

@ -9,6 +9,8 @@ pub struct Settings {
pub keys: Keys, pub keys: Keys,
#[serde(default)] #[serde(default)]
pub federation: Federation, pub federation: Federation,
#[serde(default)]
pub authz: AuthzSettings,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -55,6 +57,32 @@ pub struct Federation {
pub trust_anchors: Vec<String>, pub trust_anchors: Vec<String>,
} }
#[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<u16>,
/// 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 { impl Default for Server {
fn default() -> Self { fn default() -> Self {
Self { Self {

View file

@ -190,6 +190,32 @@ pub async fn serve(
.expect("Admin server failed"); .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 // Start public server
tracing::info!(%public_addr, "Public API listening"); tracing::info!(%public_addr, "Public API listening");
tracing::warn!("Rate limiting should be configured at the reverse proxy level for production"); tracing::warn!("Rate limiting should be configured at the reverse proxy level for production");