Skip to main content

pylon_policy/
lib.rs

1use pylon_auth::AuthContext;
2use pylon_kernel::{AppManifest, ManifestPolicy};
3
4// ---------------------------------------------------------------------------
5// Policy evaluation
6// ---------------------------------------------------------------------------
7
8/// Result of a policy check.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum PolicyResult {
11    Allowed,
12    Denied { policy_name: String, reason: String },
13}
14
15/// Kind of entity access being checked. Drives which `allow_*` expression
16/// the engine pulls from each manifest policy.
17#[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
42/// A policy engine that evaluates manifest policies against auth context.
43///
44/// Policy `allow` expressions are evaluated with simple pattern matching:
45/// - `"auth.userId != null"` — requires authenticated user
46/// - `"auth.userId == data.authorId"` — requires user matches data field
47/// - `"auth.userId == input.authorId"` — requires user matches input field
48/// - `"true"` — always allowed
49///
50/// This is NOT a full expression evaluator. It handles the common patterns
51/// from the manifest contract. Complex expressions are treated as denied
52/// with a clear message.
53pub struct PolicyEngine {
54    entity_policies: Vec<ManifestPolicy>,
55    action_policies: Vec<ManifestPolicy>,
56}
57
58impl PolicyEngine {
59    /// Build a policy engine from a manifest.
60    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    /// Which kind of entity access is being checked. Lets the engine pick
80    /// the most specific `allow_*` expression from a manifest policy and
81    /// fall back through the override chain when no specific rule is set.
82    fn expr_for<'a>(policy: &'a ManifestPolicy, action: EntityAction) -> &'a str {
83        // Resolution order (most specific first):
84        //   read   → allow_read                      → allow
85        //   insert → allow_insert → allow_write      → allow
86        //   update → allow_update → allow_write      → allow
87        //   delete → allow_delete → allow_write      → allow
88        //
89        // An empty string means "no expression provided" → falls through.
90        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        // Admin bypasses all policies.
119        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            // Empty expression means "no rule at this level" — skip. A
136            // policy without any applicable rule defers to the next
137            // policy rather than silently denying.
138            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    /// Check if an entity read is allowed for the given auth context.
161    /// `data` is the row being accessed (for field-level checks).
162    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    /// Check if an entity write (insert/update/delete) is allowed.
172    ///
173    /// `data` is the incoming payload (for insert/update) or the existing row
174    /// (for delete). Delegates to the specific insert/update/delete path
175    /// when the caller knows the operation; kept as a generic entry point
176    /// for legacy call sites that don't discriminate.
177    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    /// Check if an entity insert is allowed. `data` is the incoming row.
187    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    /// Check if an entity update is allowed. `data` should be the existing
197    /// row so ownership checks like `data.authorId == auth.userId` evaluate
198    /// against truth instead of the incoming patch.
199    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    /// Check if an entity delete is allowed. `data` is the row about to be
209    /// removed so delete-gates can look at the row's author/tenant fields.
210    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    /// Check if an action execution is allowed.
220    /// `input` is the action input data.
221    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/// Parse a comma-separated list of quoted strings (single or double quotes).
258///
259/// `"a", 'b,c', "d"` → `["a", "b,c", "d"]`. Respects quote boundaries so a
260/// comma inside a quoted string is treated as part of the string, not a
261/// separator. Used for `hasAnyRole` so role names containing commas aren't
262/// silently split. Returns an error on unterminated strings or unquoted
263/// tokens.
264#[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        // Skip whitespace and commas between items.
271        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; // skip closing quote
295    }
296    Ok(out)
297}
298
299/// Evaluate an `allow` expression against auth context and data.
300///
301/// Supports the following grammar (informal):
302/// ```text
303///   expr    := or
304///   or      := and ("||" and)*
305///   and     := not ("&&" not)*
306///   not     := "!" not | primary
307///   primary := "true" | "false"
308///            | "(" expr ")"
309///            | call
310///            | path (("==" | "!=") atom)?
311///   atom    := "null" | "true" | "false" | string | path
312///   call    := "auth.hasRole" "(" string ")"
313///            | "auth.hasAnyRole" "(" string ("," string)* ")"
314///   path    := IDENT ("." IDENT)*    // auth.userId, data.author.id, etc.
315/// ```
316/// Existing primitives (`auth.userId != null`, `auth.isAdmin`,
317/// `auth.hasRole(...)`, `auth.hasAnyRole(...)`, `auth.userId == data.<path>`)
318/// are special cases of the grammar; old schemas keep working unchanged.
319fn 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// ---------------------------------------------------------------------------
361// Expression parser
362// ---------------------------------------------------------------------------
363
364#[derive(Debug, Clone, PartialEq, Eq)]
365enum Token {
366    True,
367    False,
368    Null,
369    And, // &&
370    Or,  // ||
371    Not, // !
372    Eq,  // ==
373    Neq, // !=
374    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                // Parse the literal as chars (not bytes) so multi-byte UTF-8
438                // round-trips intact. Previously `unescaped.push(b as char)`
439                // mangled anything outside ASCII: `"é"` became two garbage
440                // chars. Only a fixed escape set is honored; unknown escapes
441                // now error rather than silently dropping the backslash
442                // (old behavior turned `"\n"` into `"n"`).
443                let quote = c as char;
444                // Skip opening quote, then walk the rest of the string as
445                // a char iterator. Build `unescaped` directly — we don't
446                // need a raw slice anymore since escapes are resolved inline.
447                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    /// `auth.hasRole("x")`
516    HasRole(String),
517    /// `auth.hasAnyRole("a", "b", ...)`
518    HasAnyRole(Vec<String>),
519    /// A path like `auth.userId` or `data.author.id`.
520    Path(Vec<String>),
521    /// A string literal.
522    Str(String),
523    /// `null` literal.
524    Null,
525    /// Degenerate: bare `auth.isAdmin` etc. resolves to a boolean.
526    Bool(bool),
527}
528
529/// Cap recursive descent so a pathological input like `((((...!x))))` can't
530/// stack-overflow the server thread. 64 is far beyond any realistic policy —
531/// a hand-authored expression rarely nests more than 3–4 levels.
532const 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    /// Enter one level of recursion, erroring if we exceed the cap.
566    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    /// Comparison binds LOOSER than `!`, so `!x == null` parses as
607    /// `(!x) == null` — matching conventional precedence in languages like
608    /// JS/Rust. Previously `parse_primary` ate `== null` greedily, causing
609    /// `!x == null` to evaluate as `!(x == null)` which is almost never
610    /// what a rule author intends.
611    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                // Two cases: path, or function call.
673                if matches!(self.peek(), Some(Token::LParen)) {
674                    // Function call. Only two functions are built in.
675                    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                // Comparison (==, !=) is handled by parse_comparison above —
686                // intentionally NOT consumed here, so `!x == null` parses as
687                // `(!x) == null` instead of `!(x == null)`.
688                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                // Bare value as boolean expression.
846                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                        // Non-empty string is truthy (matches JS-ish intuition).
852                        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            // Nested boolean ops evaluate to Bool.
870            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                // Unknown top-level — treat as null so policies fail closed
887                // rather than authorizing based on unresolved identifiers.
888                let _ = other;
889                Value::Null
890            }
891        }
892    }
893
894    fn resolve_auth(&self, parts: &[String]) -> Value {
895        // `auth.<field>` must name EXACTLY one field. Previously trailing
896        // segments like `auth.isAdmin.foo` or `auth.userId.x.y` silently
897        // resolved to the base field, over-broadening the allowed paths
898        // and masking typos. Require len == 1.
899        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        // Mixed types are never equal (no coercion).
943        _ => false,
944    }
945}
946
947// ---------------------------------------------------------------------------
948// Tests
949// ---------------------------------------------------------------------------
950
951#[cfg(test)]
952mod tests {
953    use super::*;
954    use pylon_kernel::ManifestPolicy;
955
956    // -----------------------------------------------------------------------
957    // New expression grammar: &&, ||, !, parens, nested paths
958    // -----------------------------------------------------------------------
959
960    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"); // not alice
981        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        // Non-admin authed user; data owner is alice; check passes via owner.
993        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        // Admin short-circuits even when not the owner.
1003        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        // Should evaluate as: admin OR (authed AND public)
1029        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        // Fail-closed: an unknown top-level identifier becomes null, so a
1086        // comparison against anything non-null is false.
1087        let auth = AuthContext::admin();
1088        let r = evaluate_allow("zzz.field == \"x\"", &auth, None, None);
1089        assert!(!r.is_allowed());
1090    }
1091
1092    // -----------------------------------------------------------------------
1093    // Regression tests from the 2026 policy review.
1094    // -----------------------------------------------------------------------
1095
1096    #[test]
1097    fn string_escape_n_is_newline() {
1098        // Prior bug: byte-wise unescape turned `\n` into the letter `n`.
1099        // Now the scanner honors the standard escape set and preserves UTF-8.
1100        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        // Previously `\q` silently collapsed to `q`. Now it's a parse error
1110        // that fails closed — authors get loud feedback instead of a
1111        // subtly-wrong rule.
1112        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        // Prior bug: `unescaped.push(b as char)` mangled `é` into garbage.
1120        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        // Prior bug: `!auth.isAdmin == false` parsed as `!(auth.isAdmin == false)`,
1128        // so an anonymous caller (whose isAdmin is false) would be DENIED
1129        // because !(false == false) == !(true) == false. With correct
1130        // precedence `(!auth.isAdmin) == false` is (!false) == false == true
1131        // only when isAdmin is true.
1132        let anon = AuthContext::anonymous();
1133        let admin = AuthContext::admin();
1134        // For anonymous: (!false) == false  ->  true == false -> false
1135        let r = evaluate_allow("!auth.isAdmin == false", &anon, None, None);
1136        assert!(!r.is_allowed(), "anon: (!false) == false should be false");
1137        // For admin: (!true) == false  ->  false == false -> true
1138        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        // Prior bug: `auth.isAdmin.foo` resolved as if it were `auth.isAdmin`
1145        // because `resolve_auth` only looked at the first segment. Now extra
1146        // segments return Null, which makes the comparison false.
1147        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        // Prior bug: no depth cap; 10_000 parens would stack-overflow.
1157        // Now the parser returns an error well before that, and
1158        // evaluate_allow converts it to Denied.
1159        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        // The cap must not break realistic expressions. 10 levels is fine.
1168        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        // This is the whole point of the fix.
1192        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    // Synthesizes a manifest with the three policies these tests
1209    // exercise (one entity-read owner check, one authenticated-only
1210    // action, one input-owner action). Keeping it in-memory means the
1211    // tests don't break when the example app drops or restructures
1212    // its policies — the assertions describe a fixed policy shape,
1213    // and that shape lives here next to the assertions.
1214    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); // ownerReadTodos
1250        assert_eq!(engine.action_policies.len(), 2); // authenticatedCreate, ownerToggle
1251    }
1252
1253    #[test]
1254    fn no_policies_allows_access() {
1255        let engine = PolicyEngine::from_manifest(&test_manifest());
1256        let auth = AuthContext::anonymous();
1257        // User entity has no policies.
1258        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        // Owner access allowed.
1283        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        // Non-owner denied.
1289        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        // toggleTodo requires auth.userId == input.authorId
1299        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    // -- Admin bypass --
1333
1334    #[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    // -- Expression edge cases --
1359
1360    #[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, // no data
1366            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, // no input
1378        );
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}