1use pylon_auth::AuthContext;
2use pylon_kernel::{AppManifest, ManifestPolicy};
3
4#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum PolicyResult {
11 Allowed,
12 Denied { policy_name: String, reason: String },
13}
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18enum EntityAction {
19 Read,
20 Insert,
21 Update,
22 Delete,
23}
24
25impl EntityAction {
26 fn as_str(self) -> &'static str {
27 match self {
28 Self::Read => "read",
29 Self::Insert => "insert",
30 Self::Update => "update",
31 Self::Delete => "delete",
32 }
33 }
34}
35
36impl PolicyResult {
37 pub fn is_allowed(&self) -> bool {
38 matches!(self, PolicyResult::Allowed)
39 }
40}
41
42pub struct PolicyEngine {
54 entity_policies: Vec<ManifestPolicy>,
55 action_policies: Vec<ManifestPolicy>,
56}
57
58impl PolicyEngine {
59 pub fn from_manifest(manifest: &AppManifest) -> Self {
61 let mut entity_policies = Vec::new();
62 let mut action_policies = Vec::new();
63
64 for policy in &manifest.policies {
65 if policy.entity.is_some() {
66 entity_policies.push(policy.clone());
67 }
68 if policy.action.is_some() {
69 action_policies.push(policy.clone());
70 }
71 }
72
73 Self {
74 entity_policies,
75 action_policies,
76 }
77 }
78
79 fn expr_for<'a>(policy: &'a ManifestPolicy, action: EntityAction) -> &'a str {
83 let pick = |primary: &'a Option<String>, secondary: &'a Option<String>| -> &'a str {
91 if let Some(s) = primary.as_deref() {
92 if !s.is_empty() {
93 return s;
94 }
95 }
96 if let Some(s) = secondary.as_deref() {
97 if !s.is_empty() {
98 return s;
99 }
100 }
101 policy.allow.as_str()
102 };
103 match action {
104 EntityAction::Read => pick(&policy.allow_read, &None),
105 EntityAction::Insert => pick(&policy.allow_insert, &policy.allow_write),
106 EntityAction::Update => pick(&policy.allow_update, &policy.allow_write),
107 EntityAction::Delete => pick(&policy.allow_delete, &policy.allow_write),
108 }
109 }
110
111 fn check_entity(
112 &self,
113 entity_name: &str,
114 action: EntityAction,
115 auth: &AuthContext,
116 data: Option<&serde_json::Value>,
117 ) -> PolicyResult {
118 if auth.is_admin {
120 return PolicyResult::Allowed;
121 }
122
123 let policies: Vec<&ManifestPolicy> = self
124 .entity_policies
125 .iter()
126 .filter(|p| p.entity.as_deref() == Some(entity_name))
127 .collect();
128
129 if policies.is_empty() {
130 return PolicyResult::Allowed;
131 }
132
133 for policy in &policies {
134 let expr = Self::expr_for(policy, action);
135 if expr.is_empty() {
139 continue;
140 }
141 match evaluate_allow(expr, auth, data, None) {
142 PolicyResult::Denied { .. } => {
143 return PolicyResult::Denied {
144 policy_name: policy.name.clone(),
145 reason: format!(
146 "Policy \"{}\" denied ({}): {}",
147 policy.name,
148 action.as_str(),
149 expr
150 ),
151 };
152 }
153 PolicyResult::Allowed => {}
154 }
155 }
156
157 PolicyResult::Allowed
158 }
159
160 pub fn check_entity_read(
163 &self,
164 entity_name: &str,
165 auth: &AuthContext,
166 data: Option<&serde_json::Value>,
167 ) -> PolicyResult {
168 self.check_entity(entity_name, EntityAction::Read, auth, data)
169 }
170
171 pub fn check_entity_write(
178 &self,
179 entity_name: &str,
180 auth: &AuthContext,
181 data: Option<&serde_json::Value>,
182 ) -> PolicyResult {
183 self.check_entity(entity_name, EntityAction::Insert, auth, data)
184 }
185
186 pub fn check_entity_insert(
188 &self,
189 entity_name: &str,
190 auth: &AuthContext,
191 data: Option<&serde_json::Value>,
192 ) -> PolicyResult {
193 self.check_entity(entity_name, EntityAction::Insert, auth, data)
194 }
195
196 pub fn check_entity_update(
200 &self,
201 entity_name: &str,
202 auth: &AuthContext,
203 data: Option<&serde_json::Value>,
204 ) -> PolicyResult {
205 self.check_entity(entity_name, EntityAction::Update, auth, data)
206 }
207
208 pub fn check_entity_delete(
211 &self,
212 entity_name: &str,
213 auth: &AuthContext,
214 data: Option<&serde_json::Value>,
215 ) -> PolicyResult {
216 self.check_entity(entity_name, EntityAction::Delete, auth, data)
217 }
218
219 pub fn check_action(
222 &self,
223 action_name: &str,
224 auth: &AuthContext,
225 input: Option<&serde_json::Value>,
226 ) -> PolicyResult {
227 if auth.is_admin {
228 return PolicyResult::Allowed;
229 }
230
231 let policies: Vec<&ManifestPolicy> = self
232 .action_policies
233 .iter()
234 .filter(|p| p.action.as_deref() == Some(action_name))
235 .collect();
236
237 if policies.is_empty() {
238 return PolicyResult::Allowed;
239 }
240
241 for policy in &policies {
242 match evaluate_allow(&policy.allow, auth, None, input) {
243 PolicyResult::Denied { .. } => {
244 return PolicyResult::Denied {
245 policy_name: policy.name.clone(),
246 reason: format!("Policy \"{}\" denied: {}", policy.name, policy.allow),
247 };
248 }
249 PolicyResult::Allowed => {}
250 }
251 }
252
253 PolicyResult::Allowed
254 }
255}
256
257#[cfg(test)]
265fn parse_quoted_string_list(s: &str) -> Result<Vec<String>, String> {
266 let mut out: Vec<String> = Vec::new();
267 let bytes = s.as_bytes();
268 let mut i = 0;
269 while i < bytes.len() {
270 while i < bytes.len() && (bytes[i].is_ascii_whitespace() || bytes[i] == b',') {
272 i += 1;
273 }
274 if i >= bytes.len() {
275 break;
276 }
277 let quote = bytes[i];
278 if quote != b'"' && quote != b'\'' {
279 return Err(format!(
280 "expected quoted string at byte {i}, got {:?}",
281 quote as char
282 ));
283 }
284 i += 1;
285 let start = i;
286 while i < bytes.len() && bytes[i] != quote {
287 i += 1;
288 }
289 if i >= bytes.len() {
290 return Err("unterminated quoted string".into());
291 }
292 let piece = &s[start..i];
293 out.push(piece.to_string());
294 i += 1; }
296 Ok(out)
297}
298
299fn evaluate_allow(
320 expr: &str,
321 auth: &AuthContext,
322 data: Option<&serde_json::Value>,
323 input: Option<&serde_json::Value>,
324) -> PolicyResult {
325 let tokens = match tokenize(expr) {
326 Ok(t) => t,
327 Err(e) => {
328 return PolicyResult::Denied {
329 policy_name: String::new(),
330 reason: format!("Policy parse error: {e} (in {expr:?})"),
331 };
332 }
333 };
334 let mut parser = Parser::new(&tokens);
335 let ast = match parser.parse_expr() {
336 Ok(a) => a,
337 Err(e) => {
338 return PolicyResult::Denied {
339 policy_name: String::new(),
340 reason: format!("Policy parse error: {e} (in {expr:?})"),
341 };
342 }
343 };
344 if !parser.at_end() {
345 return PolicyResult::Denied {
346 policy_name: String::new(),
347 reason: format!("Trailing tokens in expression: {expr:?}"),
348 };
349 }
350 let env = EvalEnv { auth, data, input };
351 match env.eval(&ast) {
352 EvalResult::True => PolicyResult::Allowed,
353 EvalResult::False(reason) => PolicyResult::Denied {
354 policy_name: String::new(),
355 reason,
356 },
357 }
358}
359
360#[derive(Debug, Clone, PartialEq, Eq)]
365enum Token {
366 True,
367 False,
368 Null,
369 And, Or, Not, Eq, Neq, LParen,
375 RParen,
376 Comma,
377 Ident(String),
378 Str(String),
379}
380
381fn tokenize(src: &str) -> Result<Vec<Token>, String> {
382 let mut out = Vec::new();
383 let bytes = src.as_bytes();
384 let mut i = 0;
385 while i < bytes.len() {
386 let c = bytes[i];
387 match c {
388 b' ' | b'\t' | b'\n' | b'\r' => {
389 i += 1;
390 }
391 b'(' => {
392 out.push(Token::LParen);
393 i += 1;
394 }
395 b')' => {
396 out.push(Token::RParen);
397 i += 1;
398 }
399 b',' => {
400 out.push(Token::Comma);
401 i += 1;
402 }
403 b'&' => {
404 if i + 1 < bytes.len() && bytes[i + 1] == b'&' {
405 out.push(Token::And);
406 i += 2;
407 } else {
408 return Err("single `&` — did you mean `&&`?".into());
409 }
410 }
411 b'|' => {
412 if i + 1 < bytes.len() && bytes[i + 1] == b'|' {
413 out.push(Token::Or);
414 i += 2;
415 } else {
416 return Err("single `|` — did you mean `||`?".into());
417 }
418 }
419 b'=' => {
420 if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
421 out.push(Token::Eq);
422 i += 2;
423 } else {
424 return Err("single `=` — did you mean `==`?".into());
425 }
426 }
427 b'!' => {
428 if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
429 out.push(Token::Neq);
430 i += 2;
431 } else {
432 out.push(Token::Not);
433 i += 1;
434 }
435 }
436 b'"' | b'\'' => {
437 let quote = c as char;
444 let rest = &src[i + 1..];
448 let mut chars = rest.char_indices();
449 let mut unescaped = String::new();
450 let mut closed_at: Option<usize> = None;
451 while let Some((rel, ch)) = chars.next() {
452 if ch == quote {
453 closed_at = Some(i + 1 + rel + ch.len_utf8());
454 break;
455 }
456 if ch == '\\' {
457 let (_rel2, esc) = chars
458 .next()
459 .ok_or_else(|| "unterminated string literal".to_string())?;
460 match esc {
461 '\\' => unescaped.push('\\'),
462 '"' => unescaped.push('"'),
463 '\'' => unescaped.push('\''),
464 'n' => unescaped.push('\n'),
465 'r' => unescaped.push('\r'),
466 't' => unescaped.push('\t'),
467 '0' => unescaped.push('\0'),
468 other => {
469 return Err(format!("unknown string escape `\\{other}`"));
470 }
471 }
472 } else {
473 unescaped.push(ch);
474 }
475 }
476 let close = closed_at.ok_or_else(|| "unterminated string literal".to_string())?;
477 out.push(Token::Str(unescaped));
478 i = close;
479 }
480 c if c.is_ascii_alphabetic() || c == b'_' => {
481 let start = i;
482 while i < bytes.len() {
483 let ch = bytes[i];
484 if ch.is_ascii_alphanumeric() || ch == b'_' || ch == b'.' {
485 i += 1;
486 } else {
487 break;
488 }
489 }
490 let word = &src[start..i];
491 match word {
492 "true" => out.push(Token::True),
493 "false" => out.push(Token::False),
494 "null" => out.push(Token::Null),
495 _ => out.push(Token::Ident(word.to_string())),
496 }
497 }
498 other => {
499 return Err(format!("unexpected character {:?}", other as char));
500 }
501 }
502 }
503 Ok(out)
504}
505
506#[derive(Debug, Clone)]
507enum Ast {
508 True,
509 False,
510 Not(Box<Ast>),
511 And(Box<Ast>, Box<Ast>),
512 Or(Box<Ast>, Box<Ast>),
513 Eq(Box<Ast>, Box<Ast>),
514 Neq(Box<Ast>, Box<Ast>),
515 HasRole(String),
517 HasAnyRole(Vec<String>),
519 Path(Vec<String>),
521 Str(String),
523 Null,
525 Bool(bool),
527}
528
529const MAX_PARSE_DEPTH: usize = 64;
533
534struct Parser<'a> {
535 tokens: &'a [Token],
536 pos: usize,
537 depth: usize,
538}
539
540impl<'a> Parser<'a> {
541 fn new(tokens: &'a [Token]) -> Self {
542 Self {
543 tokens,
544 pos: 0,
545 depth: 0,
546 }
547 }
548
549 fn at_end(&self) -> bool {
550 self.pos >= self.tokens.len()
551 }
552
553 fn peek(&self) -> Option<&Token> {
554 self.tokens.get(self.pos)
555 }
556
557 fn bump(&mut self) -> Option<&Token> {
558 let t = self.tokens.get(self.pos);
559 if t.is_some() {
560 self.pos += 1;
561 }
562 t
563 }
564
565 fn enter(&mut self) -> Result<(), String> {
567 self.depth += 1;
568 if self.depth > MAX_PARSE_DEPTH {
569 return Err(format!(
570 "policy expression nested deeper than {MAX_PARSE_DEPTH} levels"
571 ));
572 }
573 Ok(())
574 }
575
576 fn leave(&mut self) {
577 self.depth -= 1;
578 }
579
580 fn parse_expr(&mut self) -> Result<Ast, String> {
581 self.parse_or()
582 }
583
584 fn parse_or(&mut self) -> Result<Ast, String> {
585 self.enter()?;
586 let mut lhs = self.parse_and()?;
587 while matches!(self.peek(), Some(Token::Or)) {
588 self.bump();
589 let rhs = self.parse_and()?;
590 lhs = Ast::Or(Box::new(lhs), Box::new(rhs));
591 }
592 self.leave();
593 Ok(lhs)
594 }
595
596 fn parse_and(&mut self) -> Result<Ast, String> {
597 let mut lhs = self.parse_comparison()?;
598 while matches!(self.peek(), Some(Token::And)) {
599 self.bump();
600 let rhs = self.parse_comparison()?;
601 lhs = Ast::And(Box::new(lhs), Box::new(rhs));
602 }
603 Ok(lhs)
604 }
605
606 fn parse_comparison(&mut self) -> Result<Ast, String> {
612 let lhs = self.parse_not()?;
613 match self.peek() {
614 Some(Token::Eq) => {
615 self.bump();
616 let rhs = self.parse_atom()?;
617 Ok(Ast::Eq(Box::new(lhs), Box::new(rhs)))
618 }
619 Some(Token::Neq) => {
620 self.bump();
621 let rhs = self.parse_atom()?;
622 Ok(Ast::Neq(Box::new(lhs), Box::new(rhs)))
623 }
624 _ => Ok(lhs),
625 }
626 }
627
628 fn parse_not(&mut self) -> Result<Ast, String> {
629 if matches!(self.peek(), Some(Token::Not)) {
630 self.bump();
631 self.enter()?;
632 let inner = self.parse_not()?;
633 self.leave();
634 return Ok(Ast::Not(Box::new(inner)));
635 }
636 self.parse_primary()
637 }
638
639 fn parse_primary(&mut self) -> Result<Ast, String> {
640 match self.peek().cloned() {
641 Some(Token::True) => {
642 self.bump();
643 Ok(Ast::True)
644 }
645 Some(Token::False) => {
646 self.bump();
647 Ok(Ast::False)
648 }
649 Some(Token::Null) => {
650 self.bump();
651 Ok(Ast::Null)
652 }
653 Some(Token::Str(s)) => {
654 self.bump();
655 Ok(Ast::Str(s))
656 }
657 Some(Token::LParen) => {
658 self.bump();
659 self.enter()?;
660 let inner = self.parse_expr()?;
661 self.leave();
662 match self.peek() {
663 Some(Token::RParen) => {
664 self.bump();
665 }
666 _ => return Err("expected `)`".into()),
667 }
668 Ok(inner)
669 }
670 Some(Token::Ident(name)) => {
671 self.bump();
672 if matches!(self.peek(), Some(Token::LParen)) {
674 self.bump();
676 let args = self.parse_string_args()?;
677 match self.peek() {
678 Some(Token::RParen) => {
679 self.bump();
680 }
681 _ => return Err("expected `)` after function args".into()),
682 }
683 return self.build_call(&name, args);
684 }
685 Ok(Ast::Path(split_path(&name)))
689 }
690 Some(other) => Err(format!("unexpected token {other:?}")),
691 None => Err("unexpected end of expression".into()),
692 }
693 }
694
695 fn parse_string_args(&mut self) -> Result<Vec<String>, String> {
696 let mut out = Vec::new();
697 loop {
698 match self.peek().cloned() {
699 Some(Token::Str(s)) => {
700 self.bump();
701 out.push(s);
702 }
703 Some(Token::RParen) => break,
704 Some(other) => {
705 return Err(format!("expected quoted string argument, got {other:?}"));
706 }
707 None => return Err("unexpected end inside function args".into()),
708 }
709 match self.peek() {
710 Some(Token::Comma) => {
711 self.bump();
712 }
713 Some(Token::RParen) => break,
714 _ => break,
715 }
716 }
717 Ok(out)
718 }
719
720 fn build_call(&mut self, name: &str, args: Vec<String>) -> Result<Ast, String> {
721 match name {
722 "auth.hasRole" => {
723 if args.len() != 1 {
724 return Err("auth.hasRole takes exactly one string argument".into());
725 }
726 Ok(Ast::HasRole(args.into_iter().next().unwrap()))
727 }
728 "auth.hasAnyRole" => {
729 if args.is_empty() {
730 return Err("auth.hasAnyRole takes at least one argument".into());
731 }
732 Ok(Ast::HasAnyRole(args))
733 }
734 other => Err(format!("unknown function \"{other}(...)\"")),
735 }
736 }
737
738 fn parse_atom(&mut self) -> Result<Ast, String> {
739 match self.peek().cloned() {
740 Some(Token::Null) => {
741 self.bump();
742 Ok(Ast::Null)
743 }
744 Some(Token::True) => {
745 self.bump();
746 Ok(Ast::Bool(true))
747 }
748 Some(Token::False) => {
749 self.bump();
750 Ok(Ast::Bool(false))
751 }
752 Some(Token::Str(s)) => {
753 self.bump();
754 Ok(Ast::Str(s))
755 }
756 Some(Token::Ident(name)) => {
757 self.bump();
758 Ok(Ast::Path(split_path(&name)))
759 }
760 Some(other) => Err(format!("expected atom, got {other:?}")),
761 None => Err("unexpected end of expression in atom".into()),
762 }
763 }
764}
765
766fn split_path(s: &str) -> Vec<String> {
767 s.split('.').map(|p| p.to_string()).collect()
768}
769
770struct EvalEnv<'a> {
771 auth: &'a AuthContext,
772 data: Option<&'a serde_json::Value>,
773 input: Option<&'a serde_json::Value>,
774}
775
776#[derive(Debug)]
777enum EvalResult {
778 True,
779 False(String),
780}
781
782#[derive(Debug, Clone)]
783enum Value {
784 Str(String),
785 Bool(bool),
786 Null,
787}
788
789impl<'a> EvalEnv<'a> {
790 fn eval(&self, ast: &Ast) -> EvalResult {
791 match ast {
792 Ast::True => EvalResult::True,
793 Ast::False => EvalResult::False("Expression is false".into()),
794 Ast::Not(inner) => match self.eval(inner) {
795 EvalResult::True => EvalResult::False("Negated expression was true".into()),
796 EvalResult::False(_) => EvalResult::True,
797 },
798 Ast::And(l, r) => match self.eval(l) {
799 EvalResult::False(reason) => EvalResult::False(reason),
800 EvalResult::True => self.eval(r),
801 },
802 Ast::Or(l, r) => match self.eval(l) {
803 EvalResult::True => EvalResult::True,
804 EvalResult::False(reason_l) => match self.eval(r) {
805 EvalResult::True => EvalResult::True,
806 EvalResult::False(reason_r) => {
807 EvalResult::False(format!("{reason_l}; and {reason_r}"))
808 }
809 },
810 },
811 Ast::Eq(l, r) => {
812 let lv = self.value_of(l);
813 let rv = self.value_of(r);
814 if values_eq(&lv, &rv) {
815 EvalResult::True
816 } else {
817 EvalResult::False(format!("{lv:?} != {rv:?}"))
818 }
819 }
820 Ast::Neq(l, r) => {
821 let lv = self.value_of(l);
822 let rv = self.value_of(r);
823 if values_eq(&lv, &rv) {
824 EvalResult::False(format!("{lv:?} == {rv:?}"))
825 } else {
826 EvalResult::True
827 }
828 }
829 Ast::HasRole(role) => {
830 if self.auth.has_role(role) {
831 EvalResult::True
832 } else {
833 EvalResult::False(format!("Missing required role \"{role}\""))
834 }
835 }
836 Ast::HasAnyRole(roles) => {
837 let refs: Vec<&str> = roles.iter().map(|s| s.as_str()).collect();
838 if self.auth.has_any_role(&refs) {
839 EvalResult::True
840 } else {
841 EvalResult::False(format!("Missing any of required roles: {refs:?}"))
842 }
843 }
844 Ast::Path(_) | Ast::Str(_) | Ast::Null | Ast::Bool(_) => {
845 match self.value_of(ast) {
847 Value::Bool(true) => EvalResult::True,
848 Value::Bool(false) => EvalResult::False("Expression evaluated to false".into()),
849 Value::Null => EvalResult::False("Expression evaluated to null".into()),
850 Value::Str(s) => {
851 if s.is_empty() {
853 EvalResult::False("Empty string".into())
854 } else {
855 EvalResult::True
856 }
857 }
858 }
859 }
860 }
861 }
862
863 fn value_of(&self, ast: &Ast) -> Value {
864 match ast {
865 Ast::Null => Value::Null,
866 Ast::Str(s) => Value::Str(s.clone()),
867 Ast::Bool(b) => Value::Bool(*b),
868 Ast::Path(parts) => self.resolve_path(parts),
869 other => match self.eval(other) {
871 EvalResult::True => Value::Bool(true),
872 EvalResult::False(_) => Value::Bool(false),
873 },
874 }
875 }
876
877 fn resolve_path(&self, parts: &[String]) -> Value {
878 if parts.is_empty() {
879 return Value::Null;
880 }
881 match parts[0].as_str() {
882 "auth" => self.resolve_auth(&parts[1..]),
883 "data" => self.resolve_json(self.data, &parts[1..]),
884 "input" => self.resolve_json(self.input, &parts[1..]),
885 other => {
886 let _ = other;
889 Value::Null
890 }
891 }
892 }
893
894 fn resolve_auth(&self, parts: &[String]) -> Value {
895 if parts.len() != 1 {
900 return Value::Null;
901 }
902 match parts[0].as_str() {
903 "userId" | "user_id" => match &self.auth.user_id {
904 Some(s) => Value::Str(s.clone()),
905 None => Value::Null,
906 },
907 "isAdmin" | "is_admin" => Value::Bool(self.auth.is_admin),
908 "tenantId" | "tenant_id" => match &self.auth.tenant_id {
909 Some(s) => Value::Str(s.clone()),
910 None => Value::Null,
911 },
912 _ => Value::Null,
913 }
914 }
915
916 fn resolve_json(&self, root: Option<&serde_json::Value>, parts: &[String]) -> Value {
917 let mut cur = match root {
918 Some(v) => v,
919 None => return Value::Null,
920 };
921 for p in parts {
922 cur = match cur.get(p) {
923 Some(v) => v,
924 None => return Value::Null,
925 };
926 }
927 match cur {
928 serde_json::Value::String(s) => Value::Str(s.clone()),
929 serde_json::Value::Bool(b) => Value::Bool(*b),
930 serde_json::Value::Null => Value::Null,
931 serde_json::Value::Number(n) => Value::Str(n.to_string()),
932 _ => Value::Null,
933 }
934 }
935}
936
937fn values_eq(a: &Value, b: &Value) -> bool {
938 match (a, b) {
939 (Value::Null, Value::Null) => true,
940 (Value::Str(x), Value::Str(y)) => x == y,
941 (Value::Bool(x), Value::Bool(y)) => x == y,
942 _ => false,
944 }
945}
946
947#[cfg(test)]
952mod tests {
953 use super::*;
954 use pylon_kernel::ManifestPolicy;
955
956 fn alice_owns(post_author: &str) -> (AuthContext, serde_json::Value) {
961 let auth = AuthContext::authenticated("alice".into());
962 let data = serde_json::json!({ "authorId": post_author, "status": "draft" });
963 (auth, data)
964 }
965
966 #[test]
967 fn conjunction_needs_both_sides() {
968 let (auth, data) = alice_owns("alice");
969 let r = evaluate_allow(
970 "auth.userId != null && auth.userId == data.authorId",
971 &auth,
972 Some(&data),
973 None,
974 );
975 assert!(matches!(r, PolicyResult::Allowed));
976 }
977
978 #[test]
979 fn conjunction_fails_when_either_fails() {
980 let (auth, data) = alice_owns("bob"); let r = evaluate_allow(
982 "auth.userId != null && auth.userId == data.authorId",
983 &auth,
984 Some(&data),
985 None,
986 );
987 assert!(!r.is_allowed());
988 }
989
990 #[test]
991 fn disjunction_allows_admin_or_owner() {
992 let (auth, data) = alice_owns("alice");
994 let r = evaluate_allow(
995 "auth.isAdmin || auth.userId == data.authorId",
996 &auth,
997 Some(&data),
998 None,
999 );
1000 assert!(matches!(r, PolicyResult::Allowed));
1001
1002 let admin = AuthContext::admin();
1004 let r2 = evaluate_allow(
1005 "auth.isAdmin || auth.userId == data.authorId",
1006 &admin,
1007 Some(&data),
1008 None,
1009 );
1010 assert!(matches!(r2, PolicyResult::Allowed));
1011 }
1012
1013 #[test]
1014 fn negation_inverts_bool() {
1015 let auth = AuthContext::anonymous();
1016 let r = evaluate_allow("!auth.isAdmin", &auth, None, None);
1017 assert!(matches!(r, PolicyResult::Allowed));
1018
1019 let admin = AuthContext::admin();
1020 let r2 = evaluate_allow("!auth.isAdmin", &admin, None, None);
1021 assert!(!r2.is_allowed());
1022 }
1023
1024 #[test]
1025 fn parentheses_group_correctly() {
1026 let auth = AuthContext::anonymous();
1027 let data = serde_json::json!({ "public": true });
1028 let expr = "auth.isAdmin || (auth.userId != null && data.public == true)";
1030 assert!(!evaluate_allow(expr, &auth, Some(&data), None).is_allowed());
1031
1032 let authed = AuthContext::authenticated("alice".into());
1033 assert!(evaluate_allow(expr, &authed, Some(&data), None).is_allowed());
1034 }
1035
1036 #[test]
1037 fn nested_data_path() {
1038 let auth = AuthContext::authenticated("alice".into());
1039 let data = serde_json::json!({ "author": { "id": "alice" } });
1040 assert!(
1041 evaluate_allow("auth.userId == data.author.id", &auth, Some(&data), None).is_allowed()
1042 );
1043 }
1044
1045 #[test]
1046 fn null_comparison() {
1047 let auth = AuthContext::authenticated("alice".into());
1048 let data = serde_json::json!({ "deletedAt": null });
1049 assert!(evaluate_allow("data.deletedAt == null", &auth, Some(&data), None).is_allowed());
1050 }
1051
1052 #[test]
1053 fn string_literal_equality() {
1054 let auth = AuthContext::authenticated("alice".into());
1055 let data = serde_json::json!({ "status": "published" });
1056 assert!(
1057 evaluate_allow("data.status == \"published\"", &auth, Some(&data), None).is_allowed()
1058 );
1059 assert!(!evaluate_allow("data.status == \"draft\"", &auth, Some(&data), None).is_allowed());
1060 }
1061
1062 #[test]
1063 fn tenant_predicate() {
1064 let auth = AuthContext::authenticated("alice".into()).with_tenant("acme".into());
1065 let data = serde_json::json!({ "tenantId": "acme" });
1066 assert!(
1067 evaluate_allow("auth.tenantId == data.tenantId", &auth, Some(&data), None).is_allowed()
1068 );
1069 let data2 = serde_json::json!({ "tenantId": "other" });
1070 assert!(
1071 !evaluate_allow("auth.tenantId == data.tenantId", &auth, Some(&data2), None)
1072 .is_allowed()
1073 );
1074 }
1075
1076 #[test]
1077 fn malformed_expression_denies_closed() {
1078 let auth = AuthContext::admin();
1079 let r = evaluate_allow("auth.userId == ", &auth, None, None);
1080 assert!(!r.is_allowed(), "parse error must fail closed");
1081 }
1082
1083 #[test]
1084 fn unknown_identifier_resolves_to_null() {
1085 let auth = AuthContext::admin();
1088 let r = evaluate_allow("zzz.field == \"x\"", &auth, None, None);
1089 assert!(!r.is_allowed());
1090 }
1091
1092 #[test]
1097 fn string_escape_n_is_newline() {
1098 let auth = AuthContext::anonymous();
1101 let data = serde_json::json!({ "note": "line1\nline2" });
1102 assert!(
1103 evaluate_allow("data.note == \"line1\\nline2\"", &auth, Some(&data), None).is_allowed()
1104 );
1105 }
1106
1107 #[test]
1108 fn string_escape_unknown_is_error() {
1109 let auth = AuthContext::anonymous();
1113 let r = evaluate_allow("data.x == \"\\q\"", &auth, None, None);
1114 assert!(!r.is_allowed());
1115 }
1116
1117 #[test]
1118 fn string_literal_preserves_utf8() {
1119 let auth = AuthContext::anonymous();
1121 let data = serde_json::json!({ "name": "café" });
1122 assert!(evaluate_allow("data.name == \"café\"", &auth, Some(&data), None).is_allowed());
1123 }
1124
1125 #[test]
1126 fn not_precedence_binds_tighter_than_eq() {
1127 let anon = AuthContext::anonymous();
1133 let admin = AuthContext::admin();
1134 let r = evaluate_allow("!auth.isAdmin == false", &anon, None, None);
1136 assert!(!r.is_allowed(), "anon: (!false) == false should be false");
1137 let r2 = evaluate_allow("!auth.isAdmin == false", &admin, None, None);
1139 assert!(r2.is_allowed(), "admin: (!true) == false should be true");
1140 }
1141
1142 #[test]
1143 fn auth_path_rejects_extra_segments() {
1144 let admin = AuthContext::admin();
1148 let r = evaluate_allow("auth.isAdmin.foo == true", &admin, None, None);
1149 assert!(!r.is_allowed(), "extra segment must resolve to null");
1150 let r2 = evaluate_allow("auth.userId.x == \"anyone\"", &admin, None, None);
1151 assert!(!r2.is_allowed());
1152 }
1153
1154 #[test]
1155 fn deep_nesting_rejected_not_panicking() {
1156 let auth = AuthContext::anonymous();
1160 let expr = format!("{}true{}", "(".repeat(200), ")".repeat(200));
1161 let r = evaluate_allow(&expr, &auth, None, None);
1162 assert!(!r.is_allowed(), "deep nesting must deny closed, not panic");
1163 }
1164
1165 #[test]
1166 fn moderate_nesting_still_parses() {
1167 let auth = AuthContext::anonymous();
1169 let expr = format!("{}true{}", "(".repeat(10), ")".repeat(10));
1170 assert!(evaluate_allow(&expr, &auth, None, None).is_allowed());
1171 }
1172
1173 #[test]
1174 fn parse_quoted_list_single_role() {
1175 assert_eq!(
1176 parse_quoted_string_list("\"admin\"").unwrap(),
1177 vec!["admin"]
1178 );
1179 }
1180
1181 #[test]
1182 fn parse_quoted_list_two_roles() {
1183 assert_eq!(
1184 parse_quoted_string_list("'billing', 'admin'").unwrap(),
1185 vec!["billing", "admin"]
1186 );
1187 }
1188
1189 #[test]
1190 fn parse_quoted_list_comma_inside_string_is_literal() {
1191 assert_eq!(
1193 parse_quoted_string_list("\"billing,admin\"").unwrap(),
1194 vec!["billing,admin"]
1195 );
1196 }
1197
1198 #[test]
1199 fn parse_quoted_list_rejects_unquoted() {
1200 assert!(parse_quoted_string_list("admin").is_err());
1201 }
1202
1203 #[test]
1204 fn parse_quoted_list_rejects_unterminated() {
1205 assert!(parse_quoted_string_list("\"unterminated").is_err());
1206 }
1207
1208 fn test_manifest() -> AppManifest {
1215 let owner_read_todos = pylon_kernel::ManifestPolicy {
1216 name: "ownerReadTodos".into(),
1217 entity: Some("Todo".into()),
1218 allow_read: Some("auth.userId == data.authorId".into()),
1219 ..Default::default()
1220 };
1221 let authenticated_create = pylon_kernel::ManifestPolicy {
1222 name: "authenticatedCreate".into(),
1223 action: Some("createTodo".into()),
1224 allow: "auth.userId != null".into(),
1225 ..Default::default()
1226 };
1227 let owner_toggle = pylon_kernel::ManifestPolicy {
1228 name: "ownerToggle".into(),
1229 action: Some("toggleTodo".into()),
1230 allow: "auth.userId == input.authorId".into(),
1231 ..Default::default()
1232 };
1233 AppManifest {
1234 manifest_version: 1,
1235 name: "todo-app".into(),
1236 version: "0.1.0".into(),
1237 entities: vec![],
1238 routes: vec![],
1239 queries: vec![],
1240 actions: vec![],
1241 policies: vec![owner_read_todos, authenticated_create, owner_toggle],
1242 auth: Default::default(),
1243 }
1244 }
1245
1246 #[test]
1247 fn engine_from_manifest() {
1248 let engine = PolicyEngine::from_manifest(&test_manifest());
1249 assert_eq!(engine.entity_policies.len(), 1); assert_eq!(engine.action_policies.len(), 2); }
1252
1253 #[test]
1254 fn no_policies_allows_access() {
1255 let engine = PolicyEngine::from_manifest(&test_manifest());
1256 let auth = AuthContext::anonymous();
1257 let result = engine.check_entity_read("User", &auth, None);
1259 assert!(result.is_allowed());
1260 }
1261
1262 #[test]
1263 fn auth_required_denies_anonymous() {
1264 let engine = PolicyEngine::from_manifest(&test_manifest());
1265 let auth = AuthContext::anonymous();
1266 let result = engine.check_action("createTodo", &auth, None);
1267 assert!(!result.is_allowed());
1268 }
1269
1270 #[test]
1271 fn auth_required_allows_authenticated() {
1272 let engine = PolicyEngine::from_manifest(&test_manifest());
1273 let auth = AuthContext::authenticated("user-1".into());
1274 let result = engine.check_action("createTodo", &auth, None);
1275 assert!(result.is_allowed());
1276 }
1277
1278 #[test]
1279 fn owner_check_on_entity() {
1280 let engine = PolicyEngine::from_manifest(&test_manifest());
1281
1282 let auth = AuthContext::authenticated("user-1".into());
1284 let data = serde_json::json!({"authorId": "user-1"});
1285 let result = engine.check_entity_read("Todo", &auth, Some(&data));
1286 assert!(result.is_allowed());
1287
1288 let auth = AuthContext::authenticated("user-2".into());
1290 let result = engine.check_entity_read("Todo", &auth, Some(&data));
1291 assert!(!result.is_allowed());
1292 }
1293
1294 #[test]
1295 fn owner_check_on_action_input() {
1296 let engine = PolicyEngine::from_manifest(&test_manifest());
1297
1298 let auth = AuthContext::authenticated("user-1".into());
1300 let input = serde_json::json!({"authorId": "user-1", "todoId": "todo-1"});
1301 let result = engine.check_action("toggleTodo", &auth, Some(&input));
1302 assert!(result.is_allowed());
1303
1304 let auth = AuthContext::authenticated("user-2".into());
1305 let result = engine.check_action("toggleTodo", &auth, Some(&input));
1306 assert!(!result.is_allowed());
1307 }
1308
1309 #[test]
1310 fn true_expression_always_allows() {
1311 let result = evaluate_allow("true", &AuthContext::anonymous(), None, None);
1312 assert!(result.is_allowed());
1313 }
1314
1315 #[test]
1316 fn false_expression_always_denies() {
1317 let result = evaluate_allow("false", &AuthContext::anonymous(), None, None);
1318 assert!(!result.is_allowed());
1319 }
1320
1321 #[test]
1322 fn unknown_expression_denies() {
1323 let result = evaluate_allow(
1324 "some.complex.expression",
1325 &AuthContext::anonymous(),
1326 None,
1327 None,
1328 );
1329 assert!(!result.is_allowed());
1330 }
1331
1332 #[test]
1335 fn admin_bypasses_entity_policy() {
1336 let engine = PolicyEngine::from_manifest(&test_manifest());
1337 let admin = AuthContext::admin();
1338 let result = engine.check_entity_read("Todo", &admin, None);
1339 assert!(result.is_allowed());
1340 }
1341
1342 #[test]
1343 fn admin_bypasses_action_policy() {
1344 let engine = PolicyEngine::from_manifest(&test_manifest());
1345 let admin = AuthContext::admin();
1346 let result = engine.check_action("createTodo", &admin, None);
1347 assert!(result.is_allowed());
1348 }
1349
1350 #[test]
1351 fn non_admin_still_denied() {
1352 let engine = PolicyEngine::from_manifest(&test_manifest());
1353 let anon = AuthContext::anonymous();
1354 let result = engine.check_action("createTodo", &anon, None);
1355 assert!(!result.is_allowed());
1356 }
1357
1358 #[test]
1361 fn data_field_check_without_data() {
1362 let result = evaluate_allow(
1363 "auth.userId == data.authorId",
1364 &AuthContext::authenticated("user-1".into()),
1365 None, None,
1367 );
1368 assert!(!result.is_allowed());
1369 }
1370
1371 #[test]
1372 fn input_field_check_without_input() {
1373 let result = evaluate_allow(
1374 "auth.userId == input.authorId",
1375 &AuthContext::authenticated("user-1".into()),
1376 None,
1377 None, );
1379 assert!(!result.is_allowed());
1380 }
1381
1382 #[test]
1383 fn data_field_user_mismatch() {
1384 let data = serde_json::json!({"authorId": "other-user"});
1385 let result = evaluate_allow(
1386 "auth.userId == data.authorId",
1387 &AuthContext::authenticated("user-1".into()),
1388 Some(&data),
1389 None,
1390 );
1391 assert!(!result.is_allowed());
1392 }
1393
1394 #[test]
1395 fn input_field_user_mismatch() {
1396 let input = serde_json::json!({"authorId": "other-user"});
1397 let result = evaluate_allow(
1398 "auth.userId == input.authorId",
1399 &AuthContext::authenticated("user-1".into()),
1400 None,
1401 Some(&input),
1402 );
1403 assert!(!result.is_allowed());
1404 }
1405
1406 #[test]
1407 fn data_field_anonymous_denied() {
1408 let data = serde_json::json!({"authorId": "user-1"});
1409 let result = evaluate_allow(
1410 "auth.userId == data.authorId",
1411 &AuthContext::anonymous(),
1412 Some(&data),
1413 None,
1414 );
1415 assert!(!result.is_allowed());
1416 }
1417}