use pylon_auth::AuthContext;
use pylon_kernel::{AppManifest, ManifestPolicy};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PolicyResult {
Allowed,
Denied { policy_name: String, reason: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EntityAction {
Read,
Insert,
Update,
Delete,
}
impl EntityAction {
fn as_str(self) -> &'static str {
match self {
Self::Read => "read",
Self::Insert => "insert",
Self::Update => "update",
Self::Delete => "delete",
}
}
}
impl PolicyResult {
pub fn is_allowed(&self) -> bool {
matches!(self, PolicyResult::Allowed)
}
}
pub struct PolicyEngine {
entity_policies: Vec<ManifestPolicy>,
action_policies: Vec<ManifestPolicy>,
}
impl PolicyEngine {
pub fn from_manifest(manifest: &AppManifest) -> Self {
let mut entity_policies = Vec::new();
let mut action_policies = Vec::new();
for policy in &manifest.policies {
if policy.entity.is_some() {
entity_policies.push(policy.clone());
}
if policy.action.is_some() {
action_policies.push(policy.clone());
}
}
Self {
entity_policies,
action_policies,
}
}
fn expr_for<'a>(policy: &'a ManifestPolicy, action: EntityAction) -> &'a str {
let pick = |primary: &'a Option<String>, secondary: &'a Option<String>| -> &'a str {
if let Some(s) = primary.as_deref() {
if !s.is_empty() {
return s;
}
}
if let Some(s) = secondary.as_deref() {
if !s.is_empty() {
return s;
}
}
policy.allow.as_str()
};
match action {
EntityAction::Read => pick(&policy.allow_read, &None),
EntityAction::Insert => pick(&policy.allow_insert, &policy.allow_write),
EntityAction::Update => pick(&policy.allow_update, &policy.allow_write),
EntityAction::Delete => pick(&policy.allow_delete, &policy.allow_write),
}
}
fn check_entity(
&self,
entity_name: &str,
action: EntityAction,
auth: &AuthContext,
data: Option<&serde_json::Value>,
) -> PolicyResult {
if auth.is_admin {
return PolicyResult::Allowed;
}
let policies: Vec<&ManifestPolicy> = self
.entity_policies
.iter()
.filter(|p| p.entity.as_deref() == Some(entity_name))
.collect();
if policies.is_empty() {
return PolicyResult::Allowed;
}
for policy in &policies {
let expr = Self::expr_for(policy, action);
if expr.is_empty() {
continue;
}
match evaluate_allow(expr, auth, data, None) {
PolicyResult::Denied { .. } => {
return PolicyResult::Denied {
policy_name: policy.name.clone(),
reason: format!(
"Policy \"{}\" denied ({}): {}",
policy.name,
action.as_str(),
expr
),
};
}
PolicyResult::Allowed => {}
}
}
PolicyResult::Allowed
}
pub fn check_entity_read(
&self,
entity_name: &str,
auth: &AuthContext,
data: Option<&serde_json::Value>,
) -> PolicyResult {
self.check_entity(entity_name, EntityAction::Read, auth, data)
}
pub fn check_entity_write(
&self,
entity_name: &str,
auth: &AuthContext,
data: Option<&serde_json::Value>,
) -> PolicyResult {
self.check_entity(entity_name, EntityAction::Insert, auth, data)
}
pub fn check_entity_insert(
&self,
entity_name: &str,
auth: &AuthContext,
data: Option<&serde_json::Value>,
) -> PolicyResult {
self.check_entity(entity_name, EntityAction::Insert, auth, data)
}
pub fn check_entity_update(
&self,
entity_name: &str,
auth: &AuthContext,
data: Option<&serde_json::Value>,
) -> PolicyResult {
self.check_entity(entity_name, EntityAction::Update, auth, data)
}
pub fn check_entity_delete(
&self,
entity_name: &str,
auth: &AuthContext,
data: Option<&serde_json::Value>,
) -> PolicyResult {
self.check_entity(entity_name, EntityAction::Delete, auth, data)
}
pub fn check_action(
&self,
action_name: &str,
auth: &AuthContext,
input: Option<&serde_json::Value>,
) -> PolicyResult {
if auth.is_admin {
return PolicyResult::Allowed;
}
let policies: Vec<&ManifestPolicy> = self
.action_policies
.iter()
.filter(|p| p.action.as_deref() == Some(action_name))
.collect();
if policies.is_empty() {
return PolicyResult::Allowed;
}
for policy in &policies {
match evaluate_allow(&policy.allow, auth, None, input) {
PolicyResult::Denied { .. } => {
return PolicyResult::Denied {
policy_name: policy.name.clone(),
reason: format!("Policy \"{}\" denied: {}", policy.name, policy.allow),
};
}
PolicyResult::Allowed => {}
}
}
PolicyResult::Allowed
}
}
#[cfg(test)]
fn parse_quoted_string_list(s: &str) -> Result<Vec<String>, String> {
let mut out: Vec<String> = Vec::new();
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
while i < bytes.len() && (bytes[i].is_ascii_whitespace() || bytes[i] == b',') {
i += 1;
}
if i >= bytes.len() {
break;
}
let quote = bytes[i];
if quote != b'"' && quote != b'\'' {
return Err(format!(
"expected quoted string at byte {i}, got {:?}",
quote as char
));
}
i += 1;
let start = i;
while i < bytes.len() && bytes[i] != quote {
i += 1;
}
if i >= bytes.len() {
return Err("unterminated quoted string".into());
}
let piece = &s[start..i];
out.push(piece.to_string());
i += 1; }
Ok(out)
}
fn evaluate_allow(
expr: &str,
auth: &AuthContext,
data: Option<&serde_json::Value>,
input: Option<&serde_json::Value>,
) -> PolicyResult {
let tokens = match tokenize(expr) {
Ok(t) => t,
Err(e) => {
return PolicyResult::Denied {
policy_name: String::new(),
reason: format!("Policy parse error: {e} (in {expr:?})"),
};
}
};
let mut parser = Parser::new(&tokens);
let ast = match parser.parse_expr() {
Ok(a) => a,
Err(e) => {
return PolicyResult::Denied {
policy_name: String::new(),
reason: format!("Policy parse error: {e} (in {expr:?})"),
};
}
};
if !parser.at_end() {
return PolicyResult::Denied {
policy_name: String::new(),
reason: format!("Trailing tokens in expression: {expr:?}"),
};
}
let env = EvalEnv { auth, data, input };
match env.eval(&ast) {
EvalResult::True => PolicyResult::Allowed,
EvalResult::False(reason) => PolicyResult::Denied {
policy_name: String::new(),
reason,
},
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Token {
True,
False,
Null,
And, Or, Not, Eq, Neq, LParen,
RParen,
Comma,
Ident(String),
Str(String),
}
fn tokenize(src: &str) -> Result<Vec<Token>, String> {
let mut out = Vec::new();
let bytes = src.as_bytes();
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
match c {
b' ' | b'\t' | b'\n' | b'\r' => {
i += 1;
}
b'(' => {
out.push(Token::LParen);
i += 1;
}
b')' => {
out.push(Token::RParen);
i += 1;
}
b',' => {
out.push(Token::Comma);
i += 1;
}
b'&' => {
if i + 1 < bytes.len() && bytes[i + 1] == b'&' {
out.push(Token::And);
i += 2;
} else {
return Err("single `&` — did you mean `&&`?".into());
}
}
b'|' => {
if i + 1 < bytes.len() && bytes[i + 1] == b'|' {
out.push(Token::Or);
i += 2;
} else {
return Err("single `|` — did you mean `||`?".into());
}
}
b'=' => {
if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
out.push(Token::Eq);
i += 2;
} else {
return Err("single `=` — did you mean `==`?".into());
}
}
b'!' => {
if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
out.push(Token::Neq);
i += 2;
} else {
out.push(Token::Not);
i += 1;
}
}
b'"' | b'\'' => {
let quote = c as char;
let rest = &src[i + 1..];
let mut chars = rest.char_indices();
let mut unescaped = String::new();
let mut closed_at: Option<usize> = None;
while let Some((rel, ch)) = chars.next() {
if ch == quote {
closed_at = Some(i + 1 + rel + ch.len_utf8());
break;
}
if ch == '\\' {
let (_rel2, esc) = chars
.next()
.ok_or_else(|| "unterminated string literal".to_string())?;
match esc {
'\\' => unescaped.push('\\'),
'"' => unescaped.push('"'),
'\'' => unescaped.push('\''),
'n' => unescaped.push('\n'),
'r' => unescaped.push('\r'),
't' => unescaped.push('\t'),
'0' => unescaped.push('\0'),
other => {
return Err(format!("unknown string escape `\\{other}`"));
}
}
} else {
unescaped.push(ch);
}
}
let close = closed_at.ok_or_else(|| "unterminated string literal".to_string())?;
out.push(Token::Str(unescaped));
i = close;
}
c if c.is_ascii_alphabetic() || c == b'_' => {
let start = i;
while i < bytes.len() {
let ch = bytes[i];
if ch.is_ascii_alphanumeric() || ch == b'_' || ch == b'.' {
i += 1;
} else {
break;
}
}
let word = &src[start..i];
match word {
"true" => out.push(Token::True),
"false" => out.push(Token::False),
"null" => out.push(Token::Null),
_ => out.push(Token::Ident(word.to_string())),
}
}
other => {
return Err(format!("unexpected character {:?}", other as char));
}
}
}
Ok(out)
}
#[derive(Debug, Clone)]
enum Ast {
True,
False,
Not(Box<Ast>),
And(Box<Ast>, Box<Ast>),
Or(Box<Ast>, Box<Ast>),
Eq(Box<Ast>, Box<Ast>),
Neq(Box<Ast>, Box<Ast>),
HasRole(String),
HasAnyRole(Vec<String>),
Path(Vec<String>),
Str(String),
Null,
Bool(bool),
}
const MAX_PARSE_DEPTH: usize = 64;
struct Parser<'a> {
tokens: &'a [Token],
pos: usize,
depth: usize,
}
impl<'a> Parser<'a> {
fn new(tokens: &'a [Token]) -> Self {
Self {
tokens,
pos: 0,
depth: 0,
}
}
fn at_end(&self) -> bool {
self.pos >= self.tokens.len()
}
fn peek(&self) -> Option<&Token> {
self.tokens.get(self.pos)
}
fn bump(&mut self) -> Option<&Token> {
let t = self.tokens.get(self.pos);
if t.is_some() {
self.pos += 1;
}
t
}
fn enter(&mut self) -> Result<(), String> {
self.depth += 1;
if self.depth > MAX_PARSE_DEPTH {
return Err(format!(
"policy expression nested deeper than {MAX_PARSE_DEPTH} levels"
));
}
Ok(())
}
fn leave(&mut self) {
self.depth -= 1;
}
fn parse_expr(&mut self) -> Result<Ast, String> {
self.parse_or()
}
fn parse_or(&mut self) -> Result<Ast, String> {
self.enter()?;
let mut lhs = self.parse_and()?;
while matches!(self.peek(), Some(Token::Or)) {
self.bump();
let rhs = self.parse_and()?;
lhs = Ast::Or(Box::new(lhs), Box::new(rhs));
}
self.leave();
Ok(lhs)
}
fn parse_and(&mut self) -> Result<Ast, String> {
let mut lhs = self.parse_comparison()?;
while matches!(self.peek(), Some(Token::And)) {
self.bump();
let rhs = self.parse_comparison()?;
lhs = Ast::And(Box::new(lhs), Box::new(rhs));
}
Ok(lhs)
}
fn parse_comparison(&mut self) -> Result<Ast, String> {
let lhs = self.parse_not()?;
match self.peek() {
Some(Token::Eq) => {
self.bump();
let rhs = self.parse_atom()?;
Ok(Ast::Eq(Box::new(lhs), Box::new(rhs)))
}
Some(Token::Neq) => {
self.bump();
let rhs = self.parse_atom()?;
Ok(Ast::Neq(Box::new(lhs), Box::new(rhs)))
}
_ => Ok(lhs),
}
}
fn parse_not(&mut self) -> Result<Ast, String> {
if matches!(self.peek(), Some(Token::Not)) {
self.bump();
self.enter()?;
let inner = self.parse_not()?;
self.leave();
return Ok(Ast::Not(Box::new(inner)));
}
self.parse_primary()
}
fn parse_primary(&mut self) -> Result<Ast, String> {
match self.peek().cloned() {
Some(Token::True) => {
self.bump();
Ok(Ast::True)
}
Some(Token::False) => {
self.bump();
Ok(Ast::False)
}
Some(Token::Null) => {
self.bump();
Ok(Ast::Null)
}
Some(Token::Str(s)) => {
self.bump();
Ok(Ast::Str(s))
}
Some(Token::LParen) => {
self.bump();
self.enter()?;
let inner = self.parse_expr()?;
self.leave();
match self.peek() {
Some(Token::RParen) => {
self.bump();
}
_ => return Err("expected `)`".into()),
}
Ok(inner)
}
Some(Token::Ident(name)) => {
self.bump();
if matches!(self.peek(), Some(Token::LParen)) {
self.bump();
let args = self.parse_string_args()?;
match self.peek() {
Some(Token::RParen) => {
self.bump();
}
_ => return Err("expected `)` after function args".into()),
}
return self.build_call(&name, args);
}
Ok(Ast::Path(split_path(&name)))
}
Some(other) => Err(format!("unexpected token {other:?}")),
None => Err("unexpected end of expression".into()),
}
}
fn parse_string_args(&mut self) -> Result<Vec<String>, String> {
let mut out = Vec::new();
loop {
match self.peek().cloned() {
Some(Token::Str(s)) => {
self.bump();
out.push(s);
}
Some(Token::RParen) => break,
Some(other) => {
return Err(format!("expected quoted string argument, got {other:?}"));
}
None => return Err("unexpected end inside function args".into()),
}
match self.peek() {
Some(Token::Comma) => {
self.bump();
}
Some(Token::RParen) => break,
_ => break,
}
}
Ok(out)
}
fn build_call(&mut self, name: &str, args: Vec<String>) -> Result<Ast, String> {
match name {
"auth.hasRole" => {
if args.len() != 1 {
return Err("auth.hasRole takes exactly one string argument".into());
}
Ok(Ast::HasRole(args.into_iter().next().unwrap()))
}
"auth.hasAnyRole" => {
if args.is_empty() {
return Err("auth.hasAnyRole takes at least one argument".into());
}
Ok(Ast::HasAnyRole(args))
}
other => Err(format!("unknown function \"{other}(...)\"")),
}
}
fn parse_atom(&mut self) -> Result<Ast, String> {
match self.peek().cloned() {
Some(Token::Null) => {
self.bump();
Ok(Ast::Null)
}
Some(Token::True) => {
self.bump();
Ok(Ast::Bool(true))
}
Some(Token::False) => {
self.bump();
Ok(Ast::Bool(false))
}
Some(Token::Str(s)) => {
self.bump();
Ok(Ast::Str(s))
}
Some(Token::Ident(name)) => {
self.bump();
Ok(Ast::Path(split_path(&name)))
}
Some(other) => Err(format!("expected atom, got {other:?}")),
None => Err("unexpected end of expression in atom".into()),
}
}
}
fn split_path(s: &str) -> Vec<String> {
s.split('.').map(|p| p.to_string()).collect()
}
struct EvalEnv<'a> {
auth: &'a AuthContext,
data: Option<&'a serde_json::Value>,
input: Option<&'a serde_json::Value>,
}
#[derive(Debug)]
enum EvalResult {
True,
False(String),
}
#[derive(Debug, Clone)]
enum Value {
Str(String),
Bool(bool),
Null,
}
impl<'a> EvalEnv<'a> {
fn eval(&self, ast: &Ast) -> EvalResult {
match ast {
Ast::True => EvalResult::True,
Ast::False => EvalResult::False("Expression is false".into()),
Ast::Not(inner) => match self.eval(inner) {
EvalResult::True => EvalResult::False("Negated expression was true".into()),
EvalResult::False(_) => EvalResult::True,
},
Ast::And(l, r) => match self.eval(l) {
EvalResult::False(reason) => EvalResult::False(reason),
EvalResult::True => self.eval(r),
},
Ast::Or(l, r) => match self.eval(l) {
EvalResult::True => EvalResult::True,
EvalResult::False(reason_l) => match self.eval(r) {
EvalResult::True => EvalResult::True,
EvalResult::False(reason_r) => {
EvalResult::False(format!("{reason_l}; and {reason_r}"))
}
},
},
Ast::Eq(l, r) => {
let lv = self.value_of(l);
let rv = self.value_of(r);
if values_eq(&lv, &rv) {
EvalResult::True
} else {
EvalResult::False(format!("{lv:?} != {rv:?}"))
}
}
Ast::Neq(l, r) => {
let lv = self.value_of(l);
let rv = self.value_of(r);
if values_eq(&lv, &rv) {
EvalResult::False(format!("{lv:?} == {rv:?}"))
} else {
EvalResult::True
}
}
Ast::HasRole(role) => {
if self.auth.has_role(role) {
EvalResult::True
} else {
EvalResult::False(format!("Missing required role \"{role}\""))
}
}
Ast::HasAnyRole(roles) => {
let refs: Vec<&str> = roles.iter().map(|s| s.as_str()).collect();
if self.auth.has_any_role(&refs) {
EvalResult::True
} else {
EvalResult::False(format!("Missing any of required roles: {refs:?}"))
}
}
Ast::Path(_) | Ast::Str(_) | Ast::Null | Ast::Bool(_) => {
match self.value_of(ast) {
Value::Bool(true) => EvalResult::True,
Value::Bool(false) => EvalResult::False("Expression evaluated to false".into()),
Value::Null => EvalResult::False("Expression evaluated to null".into()),
Value::Str(s) => {
if s.is_empty() {
EvalResult::False("Empty string".into())
} else {
EvalResult::True
}
}
}
}
}
}
fn value_of(&self, ast: &Ast) -> Value {
match ast {
Ast::Null => Value::Null,
Ast::Str(s) => Value::Str(s.clone()),
Ast::Bool(b) => Value::Bool(*b),
Ast::Path(parts) => self.resolve_path(parts),
other => match self.eval(other) {
EvalResult::True => Value::Bool(true),
EvalResult::False(_) => Value::Bool(false),
},
}
}
fn resolve_path(&self, parts: &[String]) -> Value {
if parts.is_empty() {
return Value::Null;
}
match parts[0].as_str() {
"auth" => self.resolve_auth(&parts[1..]),
"data" => self.resolve_json(self.data, &parts[1..]),
"input" => self.resolve_json(self.input, &parts[1..]),
other => {
let _ = other;
Value::Null
}
}
}
fn resolve_auth(&self, parts: &[String]) -> Value {
if parts.len() != 1 {
return Value::Null;
}
match parts[0].as_str() {
"userId" | "user_id" => match &self.auth.user_id {
Some(s) => Value::Str(s.clone()),
None => Value::Null,
},
"isAdmin" | "is_admin" => Value::Bool(self.auth.is_admin),
"tenantId" | "tenant_id" => match &self.auth.tenant_id {
Some(s) => Value::Str(s.clone()),
None => Value::Null,
},
_ => Value::Null,
}
}
fn resolve_json(&self, root: Option<&serde_json::Value>, parts: &[String]) -> Value {
let mut cur = match root {
Some(v) => v,
None => return Value::Null,
};
for p in parts {
cur = match cur.get(p) {
Some(v) => v,
None => return Value::Null,
};
}
match cur {
serde_json::Value::String(s) => Value::Str(s.clone()),
serde_json::Value::Bool(b) => Value::Bool(*b),
serde_json::Value::Null => Value::Null,
serde_json::Value::Number(n) => Value::Str(n.to_string()),
_ => Value::Null,
}
}
}
fn values_eq(a: &Value, b: &Value) -> bool {
match (a, b) {
(Value::Null, Value::Null) => true,
(Value::Str(x), Value::Str(y)) => x == y,
(Value::Bool(x), Value::Bool(y)) => x == y,
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use pylon_kernel::ManifestPolicy;
fn alice_owns(post_author: &str) -> (AuthContext, serde_json::Value) {
let auth = AuthContext::authenticated("alice".into());
let data = serde_json::json!({ "authorId": post_author, "status": "draft" });
(auth, data)
}
#[test]
fn conjunction_needs_both_sides() {
let (auth, data) = alice_owns("alice");
let r = evaluate_allow(
"auth.userId != null && auth.userId == data.authorId",
&auth,
Some(&data),
None,
);
assert!(matches!(r, PolicyResult::Allowed));
}
#[test]
fn conjunction_fails_when_either_fails() {
let (auth, data) = alice_owns("bob"); let r = evaluate_allow(
"auth.userId != null && auth.userId == data.authorId",
&auth,
Some(&data),
None,
);
assert!(!r.is_allowed());
}
#[test]
fn disjunction_allows_admin_or_owner() {
let (auth, data) = alice_owns("alice");
let r = evaluate_allow(
"auth.isAdmin || auth.userId == data.authorId",
&auth,
Some(&data),
None,
);
assert!(matches!(r, PolicyResult::Allowed));
let admin = AuthContext::admin();
let r2 = evaluate_allow(
"auth.isAdmin || auth.userId == data.authorId",
&admin,
Some(&data),
None,
);
assert!(matches!(r2, PolicyResult::Allowed));
}
#[test]
fn negation_inverts_bool() {
let auth = AuthContext::anonymous();
let r = evaluate_allow("!auth.isAdmin", &auth, None, None);
assert!(matches!(r, PolicyResult::Allowed));
let admin = AuthContext::admin();
let r2 = evaluate_allow("!auth.isAdmin", &admin, None, None);
assert!(!r2.is_allowed());
}
#[test]
fn parentheses_group_correctly() {
let auth = AuthContext::anonymous();
let data = serde_json::json!({ "public": true });
let expr = "auth.isAdmin || (auth.userId != null && data.public == true)";
assert!(!evaluate_allow(expr, &auth, Some(&data), None).is_allowed());
let authed = AuthContext::authenticated("alice".into());
assert!(evaluate_allow(expr, &authed, Some(&data), None).is_allowed());
}
#[test]
fn nested_data_path() {
let auth = AuthContext::authenticated("alice".into());
let data = serde_json::json!({ "author": { "id": "alice" } });
assert!(
evaluate_allow("auth.userId == data.author.id", &auth, Some(&data), None).is_allowed()
);
}
#[test]
fn null_comparison() {
let auth = AuthContext::authenticated("alice".into());
let data = serde_json::json!({ "deletedAt": null });
assert!(evaluate_allow("data.deletedAt == null", &auth, Some(&data), None).is_allowed());
}
#[test]
fn string_literal_equality() {
let auth = AuthContext::authenticated("alice".into());
let data = serde_json::json!({ "status": "published" });
assert!(
evaluate_allow("data.status == \"published\"", &auth, Some(&data), None).is_allowed()
);
assert!(!evaluate_allow("data.status == \"draft\"", &auth, Some(&data), None).is_allowed());
}
#[test]
fn tenant_predicate() {
let auth = AuthContext::authenticated("alice".into()).with_tenant("acme".into());
let data = serde_json::json!({ "tenantId": "acme" });
assert!(
evaluate_allow("auth.tenantId == data.tenantId", &auth, Some(&data), None).is_allowed()
);
let data2 = serde_json::json!({ "tenantId": "other" });
assert!(
!evaluate_allow("auth.tenantId == data.tenantId", &auth, Some(&data2), None)
.is_allowed()
);
}
#[test]
fn malformed_expression_denies_closed() {
let auth = AuthContext::admin();
let r = evaluate_allow("auth.userId == ", &auth, None, None);
assert!(!r.is_allowed(), "parse error must fail closed");
}
#[test]
fn unknown_identifier_resolves_to_null() {
let auth = AuthContext::admin();
let r = evaluate_allow("zzz.field == \"x\"", &auth, None, None);
assert!(!r.is_allowed());
}
#[test]
fn string_escape_n_is_newline() {
let auth = AuthContext::anonymous();
let data = serde_json::json!({ "note": "line1\nline2" });
assert!(
evaluate_allow("data.note == \"line1\\nline2\"", &auth, Some(&data), None).is_allowed()
);
}
#[test]
fn string_escape_unknown_is_error() {
let auth = AuthContext::anonymous();
let r = evaluate_allow("data.x == \"\\q\"", &auth, None, None);
assert!(!r.is_allowed());
}
#[test]
fn string_literal_preserves_utf8() {
let auth = AuthContext::anonymous();
let data = serde_json::json!({ "name": "café" });
assert!(evaluate_allow("data.name == \"café\"", &auth, Some(&data), None).is_allowed());
}
#[test]
fn not_precedence_binds_tighter_than_eq() {
let anon = AuthContext::anonymous();
let admin = AuthContext::admin();
let r = evaluate_allow("!auth.isAdmin == false", &anon, None, None);
assert!(!r.is_allowed(), "anon: (!false) == false should be false");
let r2 = evaluate_allow("!auth.isAdmin == false", &admin, None, None);
assert!(r2.is_allowed(), "admin: (!true) == false should be true");
}
#[test]
fn auth_path_rejects_extra_segments() {
let admin = AuthContext::admin();
let r = evaluate_allow("auth.isAdmin.foo == true", &admin, None, None);
assert!(!r.is_allowed(), "extra segment must resolve to null");
let r2 = evaluate_allow("auth.userId.x == \"anyone\"", &admin, None, None);
assert!(!r2.is_allowed());
}
#[test]
fn deep_nesting_rejected_not_panicking() {
let auth = AuthContext::anonymous();
let expr = format!("{}true{}", "(".repeat(200), ")".repeat(200));
let r = evaluate_allow(&expr, &auth, None, None);
assert!(!r.is_allowed(), "deep nesting must deny closed, not panic");
}
#[test]
fn moderate_nesting_still_parses() {
let auth = AuthContext::anonymous();
let expr = format!("{}true{}", "(".repeat(10), ")".repeat(10));
assert!(evaluate_allow(&expr, &auth, None, None).is_allowed());
}
#[test]
fn parse_quoted_list_single_role() {
assert_eq!(
parse_quoted_string_list("\"admin\"").unwrap(),
vec!["admin"]
);
}
#[test]
fn parse_quoted_list_two_roles() {
assert_eq!(
parse_quoted_string_list("'billing', 'admin'").unwrap(),
vec!["billing", "admin"]
);
}
#[test]
fn parse_quoted_list_comma_inside_string_is_literal() {
assert_eq!(
parse_quoted_string_list("\"billing,admin\"").unwrap(),
vec!["billing,admin"]
);
}
#[test]
fn parse_quoted_list_rejects_unquoted() {
assert!(parse_quoted_string_list("admin").is_err());
}
#[test]
fn parse_quoted_list_rejects_unterminated() {
assert!(parse_quoted_string_list("\"unterminated").is_err());
}
fn test_manifest() -> AppManifest {
let owner_read_todos = pylon_kernel::ManifestPolicy {
name: "ownerReadTodos".into(),
entity: Some("Todo".into()),
allow_read: Some("auth.userId == data.authorId".into()),
..Default::default()
};
let authenticated_create = pylon_kernel::ManifestPolicy {
name: "authenticatedCreate".into(),
action: Some("createTodo".into()),
allow: "auth.userId != null".into(),
..Default::default()
};
let owner_toggle = pylon_kernel::ManifestPolicy {
name: "ownerToggle".into(),
action: Some("toggleTodo".into()),
allow: "auth.userId == input.authorId".into(),
..Default::default()
};
AppManifest {
manifest_version: 1,
name: "todo-app".into(),
version: "0.1.0".into(),
entities: vec![],
routes: vec![],
queries: vec![],
actions: vec![],
policies: vec![owner_read_todos, authenticated_create, owner_toggle],
auth: Default::default(),
}
}
#[test]
fn engine_from_manifest() {
let engine = PolicyEngine::from_manifest(&test_manifest());
assert_eq!(engine.entity_policies.len(), 1); assert_eq!(engine.action_policies.len(), 2); }
#[test]
fn no_policies_allows_access() {
let engine = PolicyEngine::from_manifest(&test_manifest());
let auth = AuthContext::anonymous();
let result = engine.check_entity_read("User", &auth, None);
assert!(result.is_allowed());
}
#[test]
fn auth_required_denies_anonymous() {
let engine = PolicyEngine::from_manifest(&test_manifest());
let auth = AuthContext::anonymous();
let result = engine.check_action("createTodo", &auth, None);
assert!(!result.is_allowed());
}
#[test]
fn auth_required_allows_authenticated() {
let engine = PolicyEngine::from_manifest(&test_manifest());
let auth = AuthContext::authenticated("user-1".into());
let result = engine.check_action("createTodo", &auth, None);
assert!(result.is_allowed());
}
#[test]
fn owner_check_on_entity() {
let engine = PolicyEngine::from_manifest(&test_manifest());
let auth = AuthContext::authenticated("user-1".into());
let data = serde_json::json!({"authorId": "user-1"});
let result = engine.check_entity_read("Todo", &auth, Some(&data));
assert!(result.is_allowed());
let auth = AuthContext::authenticated("user-2".into());
let result = engine.check_entity_read("Todo", &auth, Some(&data));
assert!(!result.is_allowed());
}
#[test]
fn owner_check_on_action_input() {
let engine = PolicyEngine::from_manifest(&test_manifest());
let auth = AuthContext::authenticated("user-1".into());
let input = serde_json::json!({"authorId": "user-1", "todoId": "todo-1"});
let result = engine.check_action("toggleTodo", &auth, Some(&input));
assert!(result.is_allowed());
let auth = AuthContext::authenticated("user-2".into());
let result = engine.check_action("toggleTodo", &auth, Some(&input));
assert!(!result.is_allowed());
}
#[test]
fn true_expression_always_allows() {
let result = evaluate_allow("true", &AuthContext::anonymous(), None, None);
assert!(result.is_allowed());
}
#[test]
fn false_expression_always_denies() {
let result = evaluate_allow("false", &AuthContext::anonymous(), None, None);
assert!(!result.is_allowed());
}
#[test]
fn unknown_expression_denies() {
let result = evaluate_allow(
"some.complex.expression",
&AuthContext::anonymous(),
None,
None,
);
assert!(!result.is_allowed());
}
#[test]
fn admin_bypasses_entity_policy() {
let engine = PolicyEngine::from_manifest(&test_manifest());
let admin = AuthContext::admin();
let result = engine.check_entity_read("Todo", &admin, None);
assert!(result.is_allowed());
}
#[test]
fn admin_bypasses_action_policy() {
let engine = PolicyEngine::from_manifest(&test_manifest());
let admin = AuthContext::admin();
let result = engine.check_action("createTodo", &admin, None);
assert!(result.is_allowed());
}
#[test]
fn non_admin_still_denied() {
let engine = PolicyEngine::from_manifest(&test_manifest());
let anon = AuthContext::anonymous();
let result = engine.check_action("createTodo", &anon, None);
assert!(!result.is_allowed());
}
#[test]
fn data_field_check_without_data() {
let result = evaluate_allow(
"auth.userId == data.authorId",
&AuthContext::authenticated("user-1".into()),
None, None,
);
assert!(!result.is_allowed());
}
#[test]
fn input_field_check_without_input() {
let result = evaluate_allow(
"auth.userId == input.authorId",
&AuthContext::authenticated("user-1".into()),
None,
None, );
assert!(!result.is_allowed());
}
#[test]
fn data_field_user_mismatch() {
let data = serde_json::json!({"authorId": "other-user"});
let result = evaluate_allow(
"auth.userId == data.authorId",
&AuthContext::authenticated("user-1".into()),
Some(&data),
None,
);
assert!(!result.is_allowed());
}
#[test]
fn input_field_user_mismatch() {
let input = serde_json::json!({"authorId": "other-user"});
let result = evaluate_allow(
"auth.userId == input.authorId",
&AuthContext::authenticated("user-1".into()),
None,
Some(&input),
);
assert!(!result.is_allowed());
}
#[test]
fn data_field_anonymous_denied() {
let data = serde_json::json!({"authorId": "user-1"});
let result = evaluate_allow(
"auth.userId == data.authorId",
&AuthContext::anonymous(),
Some(&data),
None,
);
assert!(!result.is_allowed());
}
}