Skip to main content

vault_core/
permex.rs

1//! Permission expressions — a small boolean language over vault entries.
2//!
3//! Replaces the flat list of `(scope_type, scope_value, permission)` rows, which
4//! could only ever mean "any of these matches". Admins can now express what they
5//! actually want:
6//!
7//! ```text
8//! project:Alpha AND NOT category:secret
9//! (project:web OR project:api) AND env:production
10//! tag:shared OR type:certificate
11//! ```
12//!
13//! # Grammar
14//!
15//! ```text
16//! expr      := or_expr
17//! or_expr   := and_expr (OR and_expr)*
18//! and_expr  := not_expr (AND not_expr)*
19//! not_expr  := NOT not_expr | primary
20//! primary   := '(' expr ')' | predicate
21//! predicate := field ':' glob
22//! ```
23//!
24//! Precedence is `NOT` > `AND` > `OR`; parentheses override. Operators are
25//! case-insensitive and `&&` / `||` / `!` are accepted as aliases. Adjacency is
26//! **not** implicit AND — an operator is always required, so an expression can
27//! never quietly mean something other than it reads.
28//!
29//! # Fields
30//!
31//! | Field       | Matches against                                    |
32//! |-------------|----------------------------------------------------|
33//! | `vault`     | everything (the value is ignored)                  |
34//! | `project`   | the entry's project ids **and** their display names |
35//! | `category`  | any of the entry's categories                      |
36//! | `tag`       | any of the entry's tags                            |
37//! | `env`       | the entry's environment                            |
38//! | `type`      | the entry's secret type (default `api_key`)        |
39//!
40//! # Two rules worth knowing
41//!
42//! **`field:*` is unconditional.** It means "no constraint on this field",
43//! not "has at least one value matching `*`". Without that, `project:*` would
44//! match unfiled entries (every entry carries the `Universal` catch-all) while
45//! `category:*` would not (an entry can have no categories at all) — the same
46//! wildcard behaving differently depending on the field.
47//!
48//! **A specific project grant is never satisfied by `Universal`.** Every entry
49//! belongs to it, so matching it would silently turn any project grant into a
50//! vault-wide one.
51
52use crate::users::glob_matches;
53use std::fmt;
54
55/// The catch-all project every entry carries.
56const UNIVERSAL: &str = "Universal";
57
58// ── AST ───────────────────────────────────────────────────────────────────────
59
60/// Which part of an entry a predicate tests.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum Field {
63    Vault,
64    Project,
65    Category,
66    Tag,
67    Env,
68    Type,
69}
70
71impl Field {
72    fn parse(s: &str) -> Option<Field> {
73        match s.to_ascii_lowercase().as_str() {
74            "vault" => Some(Field::Vault),
75            "project" | "proj" => Some(Field::Project),
76            "category" | "cat" => Some(Field::Category),
77            "tag" => Some(Field::Tag),
78            "env" | "environment" => Some(Field::Env),
79            "type" | "secrettype" => Some(Field::Type),
80            _ => None,
81        }
82    }
83
84    fn name(self) -> &'static str {
85        match self {
86            Field::Vault => "vault",
87            Field::Project => "project",
88            Field::Category => "category",
89            Field::Tag => "tag",
90            Field::Env => "env",
91            Field::Type => "type",
92        }
93    }
94}
95
96/// A parsed permission expression.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum Expr {
99    Pred { field: Field, glob: String },
100    And(Box<Expr>, Box<Expr>),
101    Or(Box<Expr>, Box<Expr>),
102    Not(Box<Expr>),
103}
104
105impl fmt::Display for Expr {
106    /// Renders back to source form. Round-trips through [`parse`].
107    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108        match self {
109            Expr::Pred { field, glob } => {
110                let needs_quotes = glob.is_empty()
111                    || glob
112                        .chars()
113                        .any(|c| c.is_whitespace() || c == '(' || c == ')' || c == '"');
114                if needs_quotes {
115                    write!(f, "{}:\"{}\"", field.name(), glob.replace('"', "\\\""))
116                } else {
117                    write!(f, "{}:{}", field.name(), glob)
118                }
119            }
120            Expr::And(a, b) => write!(f, "({a} AND {b})"),
121            Expr::Or(a, b) => write!(f, "({a} OR {b})"),
122            Expr::Not(a) => write!(f, "NOT {a}"),
123        }
124    }
125}
126
127// ── Entry view ────────────────────────────────────────────────────────────────
128
129/// The parts of a vault entry an expression can test.
130///
131/// Borrowed rather than owned: evaluation runs once per entry per request, and
132/// the source JSON already outlives it.
133#[derive(Debug, Default, Clone)]
134pub struct EntryView<'a> {
135    pub categories: Vec<&'a str>,
136    pub project_ids: Vec<&'a str>,
137    /// Display names for `project_ids`, so rules can be written against either.
138    pub project_names: Vec<String>,
139    pub tags: Vec<&'a str>,
140    pub environment: Option<&'a str>,
141    pub secret_type: Option<&'a str>,
142}
143
144impl<'a> EntryView<'a> {
145    /// Extracts the testable fields from a raw vault entry.
146    ///
147    /// `project_names` maps project id → display name; ids without a mapping
148    /// fall back to the id itself.
149    pub fn from_entry(
150        entry: &'a serde_json::Value,
151        project_names: &std::collections::HashMap<String, String>,
152    ) -> EntryView<'a> {
153        let strs = |key: &str| -> Vec<&'a str> {
154            entry
155                .get(key)
156                .and_then(|v| v.as_array())
157                .map(|a| a.iter().filter_map(|v| v.as_str()).collect())
158                .unwrap_or_default()
159        };
160        let project_ids = strs("projectIds");
161        let names = project_ids
162            .iter()
163            .map(|id| {
164                project_names
165                    .get(*id)
166                    .cloned()
167                    .unwrap_or_else(|| (*id).to_string())
168            })
169            .collect();
170        EntryView {
171            categories: strs("categories"),
172            project_ids,
173            project_names: names,
174            tags: strs("tags"),
175            environment: entry.get("environment").and_then(|v| v.as_str()),
176            secret_type: entry.get("secretType").and_then(|v| v.as_str()),
177        }
178    }
179}
180
181// ── Evaluation ────────────────────────────────────────────────────────────────
182
183/// Does `expr` grant access to `entry`?
184pub fn eval(expr: &Expr, entry: &EntryView<'_>) -> bool {
185    match expr {
186        Expr::And(a, b) => eval(a, entry) && eval(b, entry),
187        Expr::Or(a, b) => eval(a, entry) || eval(b, entry),
188        Expr::Not(a) => !eval(a, entry),
189        Expr::Pred { field, glob } => eval_pred(*field, glob, entry),
190    }
191}
192
193fn eval_pred(field: Field, glob: &str, e: &EntryView<'_>) -> bool {
194    // `field:*` places no constraint on the field — see the module docs.
195    let wildcard = glob == "*";
196    match field {
197        Field::Vault => true,
198        Field::Category => wildcard || e.categories.iter().any(|c| glob_matches(glob, c)),
199        Field::Tag => wildcard || e.tags.iter().any(|t| glob_matches(glob, t)),
200        Field::Env => wildcard || e.environment.is_some_and(|v| glob_matches(glob, v)),
201        // Entries without an explicit type are api_key by convention.
202        Field::Type => wildcard || glob_matches(glob, e.secret_type.unwrap_or("api_key")),
203        Field::Project => {
204            if wildcard {
205                return true;
206            }
207            // Skip the catch-all: every entry carries it, so allowing it to
208            // match would promote any project grant to vault-wide.
209            e.project_ids.iter().enumerate().any(|(i, id)| {
210                if *id == UNIVERSAL {
211                    return false;
212                }
213                glob_matches(glob, id)
214                    || e.project_names
215                        .get(i)
216                        .is_some_and(|n| glob_matches(glob, n))
217            })
218        }
219    }
220}
221
222// ── Lexer ─────────────────────────────────────────────────────────────────────
223
224#[derive(Debug, Clone, PartialEq, Eq)]
225enum Tok {
226    Pred(Field, String),
227    And,
228    Or,
229    Not,
230    LParen,
231    RParen,
232}
233
234fn lex(src: &str) -> Result<Vec<Tok>, String> {
235    let chars: Vec<char> = src.chars().collect();
236    let mut out = Vec::new();
237    let mut i = 0usize;
238
239    while i < chars.len() {
240        let c = chars[i];
241        if c.is_whitespace() {
242            i += 1;
243            continue;
244        }
245
246        match c {
247            '(' => {
248                out.push(Tok::LParen);
249                i += 1;
250                continue;
251            }
252            ')' => {
253                out.push(Tok::RParen);
254                i += 1;
255                continue;
256            }
257            '!' => {
258                out.push(Tok::Not);
259                i += 1;
260                continue;
261            }
262            '&' => {
263                i += if i + 1 < chars.len() && chars[i + 1] == '&' {
264                    2
265                } else {
266                    1
267                };
268                out.push(Tok::And);
269                continue;
270            }
271            '|' => {
272                i += if i + 1 < chars.len() && chars[i + 1] == '|' {
273                    2
274                } else {
275                    1
276                };
277                out.push(Tok::Or);
278                continue;
279            }
280            _ => {}
281        }
282
283        // A bare word: either an operator keyword or the field half of a predicate.
284        let start = i;
285        while i < chars.len()
286            && !chars[i].is_whitespace()
287            && chars[i] != '('
288            && chars[i] != ')'
289            && chars[i] != ':'
290        {
291            i += 1;
292        }
293        let word: String = chars[start..i].iter().collect();
294        if word.is_empty() {
295            return Err(format!("unexpected character '{c}' at position {start}"));
296        }
297
298        // Not followed by ':' → it must be an operator.
299        if i >= chars.len() || chars[i] != ':' {
300            match word.to_ascii_uppercase().as_str() {
301                "AND" => out.push(Tok::And),
302                "OR" => out.push(Tok::Or),
303                "NOT" => out.push(Tok::Not),
304                _ => {
305                    return Err(format!(
306                        "expected AND, OR, NOT or a `field:value` term, found '{word}'"
307                    ))
308                }
309            }
310            continue;
311        }
312
313        i += 1; // consume ':'
314        let Some(field) = Field::parse(&word) else {
315            return Err(format!(
316                "unknown field '{word}' — expected one of vault, project, category, tag, env, type"
317            ));
318        };
319
320        // Value: quoted (may contain spaces) or bare up to whitespace/paren.
321        let value = if i < chars.len() && chars[i] == '"' {
322            i += 1;
323            let mut v = String::new();
324            loop {
325                if i >= chars.len() {
326                    return Err("unterminated quoted value".to_string());
327                }
328                match chars[i] {
329                    '\\' if i + 1 < chars.len() => {
330                        v.push(chars[i + 1]);
331                        i += 2;
332                    }
333                    '"' => {
334                        i += 1;
335                        break;
336                    }
337                    ch => {
338                        v.push(ch);
339                        i += 1;
340                    }
341                }
342            }
343            v
344        } else {
345            let vs = i;
346            while i < chars.len() && !chars[i].is_whitespace() && chars[i] != '(' && chars[i] != ')'
347            {
348                i += 1;
349            }
350            chars[vs..i].iter().collect()
351        };
352
353        if value.is_empty() {
354            return Err(format!(
355                "field '{word}' has no value — write {word}:* to match everything"
356            ));
357        }
358        out.push(Tok::Pred(field, value));
359    }
360
361    Ok(out)
362}
363
364// ── Parser ────────────────────────────────────────────────────────────────────
365
366struct Parser {
367    toks: Vec<Tok>,
368    pos: usize,
369}
370
371impl Parser {
372    fn peek(&self) -> Option<&Tok> {
373        self.toks.get(self.pos)
374    }
375    fn next(&mut self) -> Option<Tok> {
376        let t = self.toks.get(self.pos).cloned();
377        self.pos += 1;
378        t
379    }
380
381    fn parse_or(&mut self) -> Result<Expr, String> {
382        let mut lhs = self.parse_and()?;
383        while matches!(self.peek(), Some(Tok::Or)) {
384            self.next();
385            let rhs = self.parse_and()?;
386            lhs = Expr::Or(Box::new(lhs), Box::new(rhs));
387        }
388        Ok(lhs)
389    }
390
391    fn parse_and(&mut self) -> Result<Expr, String> {
392        let mut lhs = self.parse_not()?;
393        while matches!(self.peek(), Some(Tok::And)) {
394            self.next();
395            let rhs = self.parse_not()?;
396            lhs = Expr::And(Box::new(lhs), Box::new(rhs));
397        }
398        Ok(lhs)
399    }
400
401    fn parse_not(&mut self) -> Result<Expr, String> {
402        if matches!(self.peek(), Some(Tok::Not)) {
403            self.next();
404            return Ok(Expr::Not(Box::new(self.parse_not()?)));
405        }
406        self.parse_primary()
407    }
408
409    fn parse_primary(&mut self) -> Result<Expr, String> {
410        match self.next() {
411            Some(Tok::Pred(field, glob)) => Ok(Expr::Pred { field, glob }),
412            Some(Tok::LParen) => {
413                let inner = self.parse_or()?;
414                match self.next() {
415                    Some(Tok::RParen) => Ok(inner),
416                    _ => Err("missing closing ')'".to_string()),
417                }
418            }
419            Some(Tok::RParen) => Err("unexpected ')'".to_string()),
420            Some(Tok::And) | Some(Tok::Or) => Err("expression begins with an operator".to_string()),
421            Some(Tok::Not) => unreachable!("handled in parse_not"),
422            None => Err("unexpected end of expression".to_string()),
423        }
424    }
425}
426
427/// Parses a permission expression.
428///
429/// Returns `Err` with a human-readable reason for anything malformed. Callers
430/// must treat a parse failure as **deny** — never as "no restriction".
431pub fn parse(src: &str) -> Result<Expr, String> {
432    let toks = lex(src)?;
433    if toks.is_empty() {
434        return Err("empty expression".to_string());
435    }
436    let mut p = Parser { toks, pos: 0 };
437    let expr = p.parse_or()?;
438    if p.pos != p.toks.len() {
439        return Err("trailing input after the end of the expression".to_string());
440    }
441    Ok(expr)
442}
443
444/// Convenience: parse and evaluate, treating a malformed expression as deny.
445pub fn eval_str(src: &str, entry: &EntryView<'_>) -> bool {
446    parse(src).map(|e| eval(&e, entry)).unwrap_or(false)
447}
448
449// ── Compiling legacy permission rows ──────────────────────────────────────────
450
451/// Builds the expression equivalent to a set of legacy `(scope_type, scope_value)`
452/// rows, which always meant "any of these matches".
453///
454/// Returns `None` when there are no rows — the caller must treat that as
455/// "no grant", not "no restriction".
456pub fn compile_scopes<'a, I>(scopes: I) -> Option<Expr>
457where
458    I: IntoIterator<Item = (&'a str, &'a str)>,
459{
460    let mut acc: Option<Expr> = None;
461    for (scope_type, scope_value) in scopes {
462        let field = match Field::parse(scope_type) {
463            Some(f) => f,
464            None => continue, // unknown legacy scope type: ignore rather than over-grant
465        };
466        let pred = Expr::Pred {
467            field,
468            glob: scope_value.to_string(),
469        };
470        acc = Some(match acc {
471            None => pred,
472            Some(prev) => Expr::Or(Box::new(prev), Box::new(pred)),
473        });
474    }
475    acc
476}
477
478/// Combines a class expression with an individual one.
479///
480/// Both present → **AND**: a class restriction cannot be undone by an individual
481/// grant, which is what makes classes an actual boundary. Exactly one present →
482/// that one. Neither → `None`, meaning no grant at all.
483///
484/// The AND is why "neither" must be `None` rather than a vacuous truth: treating
485/// an absent expression as `true` would make a user with no permissions
486/// whatsoever evaluate to `true AND true` and see everything.
487//
488// (This block documents `combine`, below. It sat above `any_of` for several
489// phases, so `any_of` appeared to be the function that ANDs — it is not.)
490/// ORs two optional expressions, used for "write implies read".
491pub fn any_of(a: Option<Expr>, b: Option<Expr>) -> Option<Expr> {
492    match (a, b) {
493        (Some(x), Some(y)) => Some(Expr::Or(Box::new(x), Box::new(y))),
494        (Some(x), None) | (None, Some(x)) => Some(x),
495        (None, None) => None,
496    }
497}
498
499pub fn combine(class: Option<Expr>, individual: Option<Expr>) -> Option<Expr> {
500    match (class, individual) {
501        (Some(c), Some(i)) => Some(Expr::And(Box::new(c), Box::new(i))),
502        (Some(c), None) => Some(c),
503        (None, Some(i)) => Some(i),
504        (None, None) => None,
505    }
506}
507
508/// Rewrites a permission expression so **every** top-level alternative must
509/// match, instead of any one of them.
510///
511/// This is what "strict write scoping" means in a codebase where scopes became
512/// expressions. `compile_scopes` joins a subject's scopes with `Or`, so a user
513/// scoped to two projects may write anything in *either*. Under strict mode an
514/// entry must satisfy all of them — in practice, be in both.
515///
516/// Only the top-level `Or` chain is rewritten. Nested groups an author wrote by
517/// hand are left alone: `(a OR b) AND c` was deliberate, and silently turning
518/// its inner alternation into a conjunction would change a rule its author
519/// already expressed precisely. Strictness is about the implicit OR that
520/// scope-joining introduced, not about second-guessing explicit logic.
521///
522/// Strict mode can only ever *narrow* what is permitted. That direction matters:
523/// a bug here should lock someone out, not let them through.
524pub fn require_all(expr: Expr) -> Expr {
525    match expr {
526        Expr::Or(a, b) => Expr::And(Box::new(require_all(*a)), Box::new(require_all(*b))),
527        other => other,
528    }
529}
530
531// ── Tests ─────────────────────────────────────────────────────────────────────
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536    use serde_json::json;
537    use std::collections::HashMap;
538
539    fn names(pairs: &[(&str, &str)]) -> HashMap<String, String> {
540        pairs
541            .iter()
542            .map(|(a, b)| (a.to_string(), b.to_string()))
543            .collect()
544    }
545
546    fn view<'a>(e: &'a serde_json::Value, pn: &HashMap<String, String>) -> EntryView<'a> {
547        EntryView::from_entry(e, pn)
548    }
549
550    fn entry() -> serde_json::Value {
551        json!({
552            "provider": "X",
553            "categories": ["dev", "shared"],
554            "projectIds": ["Universal", "p1"],
555            "tags": ["team-a"],
556            "environment": "production",
557            "secretType": "api_key",
558        })
559    }
560
561    #[test]
562    fn matches_the_shared_typescript_parity_fixture() {
563        let fixture: serde_json::Value =
564            serde_json::from_str(include_str!("../../tests/fixtures/parity/permex.json")).unwrap();
565        let projects = fixture["projects"]
566            .as_array()
567            .unwrap()
568            .iter()
569            .filter_map(|p| {
570                Some((
571                    p["id"].as_str()?.to_string(),
572                    p["name"].as_str()?.to_string(),
573                ))
574            })
575            .collect::<HashMap<_, _>>();
576
577        for case in fixture["cases"].as_array().unwrap() {
578            let parse_optional = |key: &str| {
579                case[key]
580                    .as_str()
581                    .filter(|src| !src.is_empty())
582                    .map(parse)
583                    .transpose()
584                    .unwrap_or_else(|err| panic!("{}: {err}", case["name"]))
585            };
586            let mut expr = combine(parse_optional("class"), parse_optional("individual"));
587            if case["strict"].as_bool() == Some(true) {
588                expr = expr.map(require_all);
589            }
590            for (entry, expected) in fixture["entries"]
591                .as_array()
592                .unwrap()
593                .iter()
594                .zip(case["matches"].as_array().unwrap())
595            {
596                assert_eq!(
597                    expr.as_ref()
598                        .is_some_and(|e| eval(e, &EntryView::from_entry(entry, &projects))),
599                    expected.as_bool().unwrap(),
600                    "{}: {}",
601                    case["name"],
602                    entry["id"],
603                );
604            }
605        }
606    }
607
608    // ── Parsing ───────────────────────────────────────────────────────────────
609
610    #[test]
611    fn precedence_is_not_then_and_then_or() {
612        // a OR b AND c  ==  a OR (b AND c)
613        let e = parse("category:a OR category:b AND category:c").unwrap();
614        assert_eq!(e.to_string(), "(category:a OR (category:b AND category:c))");
615    }
616
617    #[test]
618    fn not_binds_tighter_than_and() {
619        let e = parse("NOT category:a AND category:b").unwrap();
620        assert_eq!(e.to_string(), "(NOT category:a AND category:b)");
621    }
622
623    #[test]
624    fn parentheses_override_precedence() {
625        let e = parse("(category:a OR category:b) AND category:c").unwrap();
626        assert_eq!(e.to_string(), "((category:a OR category:b) AND category:c)");
627    }
628
629    #[test]
630    fn symbolic_operators_are_aliases() {
631        assert_eq!(
632            parse("category:a && category:b").unwrap(),
633            parse("category:a AND category:b").unwrap()
634        );
635        assert_eq!(
636            parse("category:a || category:b").unwrap(),
637            parse("category:a OR category:b").unwrap()
638        );
639        assert_eq!(
640            parse("!category:a").unwrap(),
641            parse("NOT category:a").unwrap()
642        );
643    }
644
645    #[test]
646    fn operators_are_case_insensitive() {
647        assert_eq!(
648            parse("category:a and category:b").unwrap(),
649            parse("category:a AND category:b").unwrap()
650        );
651    }
652
653    #[test]
654    fn quoted_values_may_contain_spaces() {
655        let e = parse(r#"project:"My Project""#).unwrap();
656        assert_eq!(
657            e,
658            Expr::Pred {
659                field: Field::Project,
660                glob: "My Project".into()
661            }
662        );
663    }
664
665    #[test]
666    fn field_aliases_resolve() {
667        assert_eq!(parse("proj:a").unwrap(), parse("project:a").unwrap());
668        assert_eq!(parse("cat:a").unwrap(), parse("category:a").unwrap());
669    }
670
671    #[test]
672    fn expressions_round_trip_through_display() {
673        for src in [
674            "project:a",
675            "(project:a AND NOT category:b)",
676            "((project:a OR tag:x) AND env:production)",
677        ] {
678            let once = parse(src).unwrap();
679            let twice = parse(&once.to_string()).unwrap();
680            assert_eq!(once, twice, "round-trip failed for {src}");
681        }
682    }
683
684    // ── Malformed input must fail, never silently permit ──────────────────────
685
686    #[test]
687    fn malformed_expressions_are_rejected() {
688        for bad in [
689            "",                      // empty
690            "category:",             // no value
691            "bogus:a",               // unknown field
692            "category:a AND",        // dangling operator
693            "AND category:a",        // leading operator
694            "(category:a",           // unclosed paren
695            "category:a)",           // stray close
696            "category:a category:b", // adjacency is not implicit AND
697            "just-a-word",           // not a term
698        ] {
699            assert!(parse(bad).is_err(), "expected {bad:?} to be rejected");
700        }
701    }
702
703    #[test]
704    fn eval_str_denies_on_malformed_input() {
705        let pn = names(&[]);
706        let e = entry();
707        assert!(
708            !eval_str("category:a AND", &view(&e, &pn)),
709            "a broken expression must deny, not permit"
710        );
711        assert!(!eval_str("", &view(&e, &pn)));
712    }
713
714    // ── Evaluation ────────────────────────────────────────────────────────────
715
716    #[test]
717    fn predicates_match_their_own_field() {
718        let pn = names(&[("p1", "Alpha")]);
719        let e = entry();
720        let v = view(&e, &pn);
721        assert!(eval_str("category:dev", &v));
722        assert!(
723            eval_str("project:Alpha", &v),
724            "project matches by display name"
725        );
726        assert!(eval_str("project:p1", &v), "project matches by id");
727        assert!(eval_str("tag:team-a", &v));
728        assert!(eval_str("env:production", &v));
729        assert!(eval_str("type:api_key", &v));
730        assert!(!eval_str("category:nope", &v));
731        assert!(!eval_str("env:staging", &v));
732    }
733
734    #[test]
735    fn globs_work_inside_predicates() {
736        let pn = names(&[("p1", "Alpha")]);
737        let e = entry();
738        assert!(eval_str("project:Al*", &view(&e, &pn)));
739        assert!(eval_str("tag:team-?", &view(&e, &pn)));
740        assert!(!eval_str("project:Be*", &view(&e, &pn)));
741    }
742
743    #[test]
744    fn boolean_combinations_evaluate() {
745        let pn = names(&[("p1", "Alpha")]);
746        let e = entry();
747        let v = view(&e, &pn);
748        assert!(eval_str("category:dev AND env:production", &v));
749        assert!(!eval_str("category:dev AND env:staging", &v));
750        assert!(eval_str("category:nope OR tag:team-a", &v));
751        assert!(eval_str("project:Alpha AND NOT category:secret", &v));
752        assert!(!eval_str("project:Alpha AND NOT category:dev", &v));
753        assert!(eval_str(
754            "(category:nope OR project:Alpha) AND env:production",
755            &v
756        ));
757    }
758
759    #[test]
760    fn untyped_entries_are_treated_as_api_key() {
761        let pn = names(&[]);
762        let e = json!({ "provider": "X", "projectIds": ["Universal"] });
763        assert!(eval_str("type:api_key", &view(&e, &pn)));
764    }
765
766    // ── The two rules that bit us before ──────────────────────────────────────
767
768    #[test]
769    fn wildcards_are_unconditional_across_every_field() {
770        let pn = names(&[]);
771        // Unfiled: no categories, no tags, no env, only the Universal project.
772        let e = json!({ "provider": "X", "projectIds": ["Universal"] });
773        let v = view(&e, &pn);
774        for src in [
775            "project:*",
776            "category:*",
777            "tag:*",
778            "env:*",
779            "type:*",
780            "vault:*",
781        ] {
782            assert!(eval_str(src, &v), "{src} must match an unfiled entry");
783        }
784    }
785
786    #[test]
787    fn a_specific_project_grant_is_never_satisfied_by_universal() {
788        let pn = names(&[]);
789        let e = json!({ "provider": "X", "projectIds": ["Universal"] });
790        assert!(
791            !eval_str("project:Universal", &view(&e, &pn)),
792            "matching the catch-all would promote any project grant to vault-wide"
793        );
794    }
795
796    // ── Legacy compilation and composition ────────────────────────────────────
797
798    #[test]
799    fn legacy_rows_compile_to_an_or_chain() {
800        let e = compile_scopes(vec![("project", "Alpha"), ("category", "dev")]).unwrap();
801        assert_eq!(e.to_string(), "(project:Alpha OR category:dev)");
802    }
803
804    #[test]
805    fn compiling_no_rows_yields_no_grant() {
806        assert!(compile_scopes(Vec::<(&str, &str)>::new()).is_none());
807    }
808
809    #[test]
810    fn unknown_legacy_scope_types_are_dropped_not_widened() {
811        // A row we cannot interpret must never become a broader grant.
812        assert!(compile_scopes(vec![("nonsense", "*")]).is_none());
813    }
814
815    #[test]
816    fn combine_ands_class_with_individual() {
817        let c = parse("project:*").unwrap();
818        let i = parse("NOT category:secret").unwrap();
819        let combined = combine(Some(c), Some(i)).unwrap();
820        assert_eq!(combined.to_string(), "(project:* AND NOT category:secret)");
821    }
822
823    #[test]
824    fn a_class_exclusion_cannot_be_undone_individually() {
825        let pn = names(&[("p1", "Alpha")]);
826        let secret =
827            json!({ "provider": "S", "categories": ["secret"], "projectIds": ["Universal", "p1"] });
828        let combined = combine(
829            Some(parse("NOT category:secret").unwrap()), // class says: never secrets
830            Some(parse("project:*").unwrap()),           // individual says: all projects
831        )
832        .unwrap();
833        assert!(
834            !eval(&combined, &view(&secret, &pn)),
835            "the class exclusion must win over the individual grant"
836        );
837    }
838
839    #[test]
840    fn no_expressions_at_all_means_no_grant() {
841        // Guards the emptiness rule: with AND composition, treating absent as
842        // `true` would give a user with no permissions full access.
843        assert!(combine(None, None).is_none());
844    }
845}