mirror of
https://github.com/CloudNebulaProject/barycenter.git
synced 2026-04-10 13:10:42 +00:00
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:
parent
95a55c5f24
commit
e0ca87f867
13 changed files with 2504 additions and 3 deletions
61
Cargo.lock
generated
61
Cargo.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
722
src/authz/condition.rs
Normal 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
468
src/authz/engine.rs
Normal 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
86
src/authz/errors.rs
Normal 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
378
src/authz/loader.rs
Normal 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
26
src/authz/mod.rs
Normal 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
380
src/authz/policy.rs
Normal 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
285
src/authz/types.rs
Normal 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
43
src/authz/web.rs
Normal 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")
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
26
src/web.rs
26
src/web.rs
|
|
@ -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");
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue