Skip to main content

what_core/policy/
mod.rs

1//! Declarative collection authorization policies.
2//!
3//! Policies are declared per collection in `what.toml` `[collections.<name>]`
4//! sections and govern who may create/read/update/delete records, plus record
5//! ownership, tenant scoping, and field-level restrictions. Collections without
6//! an explicit policy get an owner-protected default (create = "all",
7//! update/delete = "owner", read = "all").
8//!
9//! Enforcement lives in the server handlers: [`CollectionPolicy::allows_create`]
10//! / [`allows_mutation`](CollectionPolicy::allows_mutation) gate writes,
11//! [`read_scope`](CollectionPolicy::read_scope) forces a WHERE clause on reads,
12//! and [`stamp_owner`](CollectionPolicy::stamp_owner) / [`sanitize_input`] keep
13//! server-managed fields trustworthy.
14
15use crate::auth::UserContext;
16use crate::config::CollectionPolicyConfig;
17use crate::sessions::Session;
18use crate::{Error, Result};
19use serde_json::{Map, Value};
20use std::collections::HashMap;
21
22/// A single term in a rule (rules are an OR of terms).
23#[derive(Debug, Clone, PartialEq)]
24pub enum RuleTerm {
25    /// Anyone, including anonymous visitors.
26    All,
27    /// Any authenticated user.
28    User,
29    /// The record's owner (mutations/reads only — invalid for create).
30    Owner,
31    /// No one.
32    Nobody,
33    /// A named role.
34    Role(String),
35}
36
37impl std::fmt::Display for RuleTerm {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        match self {
40            RuleTerm::All => write!(f, "all"),
41            RuleTerm::User => write!(f, "user"),
42            RuleTerm::Owner => write!(f, "owner"),
43            RuleTerm::Nobody => write!(f, "none"),
44            RuleTerm::Role(r) => write!(f, "{r}"),
45        }
46    }
47}
48
49/// An access rule — a disjunction (OR) of terms.
50#[derive(Debug, Clone, PartialEq)]
51pub struct Rule {
52    terms: Vec<RuleTerm>,
53}
54
55impl Rule {
56    /// Parse a rule expression like `"owner, admin"`. `ctx` is a dotted path
57    /// (e.g. `collections.notes.update`) used in error messages. `allow_owner`
58    /// is false for create rules (a record has no owner before it exists).
59    pub fn parse(s: &str, ctx: &str, allow_owner: bool) -> Result<Rule> {
60        let raw: Vec<String> = s
61            .split(',')
62            .map(|t| t.trim().to_string())
63            .filter(|t| !t.is_empty())
64            .collect();
65        if raw.is_empty() {
66            return Err(Error::Config(format!("{ctx}: empty rule")));
67        }
68
69        let mut terms = Vec::new();
70        for word in &raw {
71            let term = match word.as_str() {
72                "all" => RuleTerm::All,
73                "user" => RuleTerm::User,
74                "none" => RuleTerm::Nobody,
75                "owner" => {
76                    if !allow_owner {
77                        return Err(Error::Config(format!(
78                            "{ctx}: \"owner\" is not valid for a create rule (a record has no owner until it is created)"
79                        )));
80                    }
81                    RuleTerm::Owner
82                }
83                other => RuleTerm::Role(other.to_string()),
84            };
85            terms.push(term);
86        }
87
88        // Reserved words cannot be combined with anything else.
89        if terms.contains(&RuleTerm::All) && terms.len() > 1 {
90            return Err(Error::Config(format!(
91                "{ctx}: \"all\" cannot be combined with other terms"
92            )));
93        }
94        if terms.contains(&RuleTerm::Nobody) && terms.len() > 1 {
95            return Err(Error::Config(format!(
96                "{ctx}: \"none\" cannot be combined with other terms"
97            )));
98        }
99
100        Ok(Rule { terms })
101    }
102
103    /// Rule that allows everyone.
104    pub fn all() -> Rule {
105        Rule { terms: vec![RuleTerm::All] }
106    }
107
108    /// Rule that allows only the record owner.
109    pub fn owner() -> Rule {
110        Rule { terms: vec![RuleTerm::Owner] }
111    }
112
113    /// True if this rule allows anyone unconditionally.
114    pub fn is_all(&self) -> bool {
115        self.terms == [RuleTerm::All]
116    }
117
118    /// True if any term references ownership.
119    fn has_owner(&self) -> bool {
120        self.terms.contains(&RuleTerm::Owner)
121    }
122
123    /// True if any non-owner term matches this actor (i.e. the actor may act on
124    /// records regardless of ownership).
125    fn non_owner_match(&self, actor: &Actor) -> bool {
126        self.terms.iter().any(|t| match t {
127            RuleTerm::All => true,
128            RuleTerm::User => actor.authenticated,
129            RuleTerm::Role(r) => actor.roles.iter().any(|ar| ar == r),
130            RuleTerm::Owner | RuleTerm::Nobody => false,
131        })
132    }
133}
134
135impl std::fmt::Display for Rule {
136    /// Render the rule as its config vocabulary (round-trips `Rule::parse`).
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        let terms: Vec<String> = self.terms.iter().map(|t| t.to_string()).collect();
139        write!(f, "{}", terms.join(", "))
140    }
141}
142
143/// Ownership mode for a collection.
144#[derive(Debug, Clone, PartialEq)]
145pub enum OwnerMode {
146    /// Stamp `_owner` on create.
147    Auto,
148    /// No ownership tracking.
149    None,
150}
151
152impl std::fmt::Display for OwnerMode {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        match self {
155            OwnerMode::Auto => write!(f, "auto"),
156            OwnerMode::None => write!(f, "none"),
157        }
158    }
159}
160
161/// Result of a mutation authorization check.
162#[derive(Debug, Clone, PartialEq)]
163pub enum Decision {
164    /// The mutation is allowed.
165    Allow,
166    /// Allowed only because the record predates ownership (implicit policy,
167    /// unowned record) — callers should warn in dev mode.
168    AllowLegacy,
169    /// The mutation is denied.
170    Deny,
171}
172
173/// The read scope to apply to a fetch for a collection.
174#[derive(Debug, Clone, PartialEq)]
175pub enum ReadScope {
176    /// No restriction.
177    All,
178    /// No matching identity — return nothing.
179    Deny,
180    /// Filter expressions to AND into the query (each is the filter
181    /// mini-language: comma = OR, `&` = AND).
182    Filters(Vec<String>),
183}
184
185/// Which write a mutation performs.
186#[derive(Debug, Clone, Copy, PartialEq)]
187pub enum MutationKind {
188    Update,
189    Delete,
190}
191
192/// A compiled collection policy.
193#[derive(Debug, Clone)]
194pub struct CollectionPolicy {
195    pub owner_mode: OwnerMode,
196    pub create: Rule,
197    pub update: Rule,
198    pub delete: Rule,
199    pub read: Rule,
200    pub filter: Option<String>,
201    pub readonly_fields: Vec<String>,
202    pub private_fields: Vec<String>,
203    /// Whether this policy was explicitly declared (vs the implicit default).
204    pub explicit: bool,
205}
206
207impl Default for CollectionPolicy {
208    /// The implicit owner-protected default for undeclared collections.
209    fn default() -> Self {
210        CollectionPolicy {
211            owner_mode: OwnerMode::Auto,
212            create: Rule::all(),
213            update: Rule::owner(),
214            delete: Rule::owner(),
215            read: Rule::all(),
216            filter: None,
217            readonly_fields: Vec::new(),
218            private_fields: Vec::new(),
219            explicit: false,
220        }
221    }
222}
223
224impl CollectionPolicy {
225    /// Compile a raw config entry for collection `name`.
226    fn compile(name: &str, cfg: &CollectionPolicyConfig) -> Result<Self> {
227        let owner_mode = match cfg.owner.as_deref() {
228            None | Some("auto") => OwnerMode::Auto,
229            Some("none") => OwnerMode::None,
230            Some(other) => {
231                return Err(Error::Config(format!(
232                    "collections.{name}.owner: expected \"auto\" or \"none\", got \"{other}\""
233                )));
234            }
235        };
236
237        let create = match &cfg.create {
238            Some(s) => Rule::parse(s, &format!("collections.{name}.create"), false)?,
239            None => Rule::all(),
240        };
241        let update = match &cfg.update {
242            Some(s) => Rule::parse(s, &format!("collections.{name}.update"), true)?,
243            None => Rule::owner(),
244        };
245        let delete = match &cfg.delete {
246            Some(s) => Rule::parse(s, &format!("collections.{name}.delete"), true)?,
247            None => Rule::owner(),
248        };
249        let read = match &cfg.read {
250            Some(s) => Rule::parse(s, &format!("collections.{name}.read"), true)?,
251            None => Rule::all(),
252        };
253
254        Ok(CollectionPolicy {
255            owner_mode,
256            create,
257            update,
258            delete,
259            read,
260            filter: cfg.filter.clone().filter(|f| !f.trim().is_empty()),
261            readonly_fields: cfg.fields.readonly.clone(),
262            private_fields: cfg.fields.private.clone(),
263            explicit: true,
264        })
265    }
266
267    fn mutation_rule(&self, kind: MutationKind) -> &Rule {
268        match kind {
269            MutationKind::Update => &self.update,
270            MutationKind::Delete => &self.delete,
271        }
272    }
273
274    /// Whether reads of this collection are restricted in any way.
275    pub fn is_read_scoped(&self) -> bool {
276        !self.read.is_all() || self.filter.is_some()
277    }
278
279    /// Authorize a create.
280    pub fn allows_create(&self, actor: &Actor) -> bool {
281        self.create.non_owner_match(actor)
282    }
283
284    /// Authorize an update/delete against the existing record.
285    ///
286    /// `resolved_filter` is the tenant filter with `#var#` already resolved; if
287    /// present, the record must also satisfy it (otherwise a tenant could mutate
288    /// another tenant's row by guessing its id).
289    pub fn allows_mutation(
290        &self,
291        actor: &Actor,
292        record: Option<&Value>,
293        kind: MutationKind,
294        resolved_filter: Option<&str>,
295    ) -> Decision {
296        // A missing record makes the mutation a safe no-op.
297        let Some(record) = record else {
298            return Decision::Allow;
299        };
300
301        // Tenant scope: the record must be inside the actor's tenant.
302        if let Some(filter) = resolved_filter {
303            if !record_matches_filter(record, filter) {
304                return Decision::Deny;
305            }
306        }
307
308        let rule = self.mutation_rule(kind);
309
310        // Non-owner terms (all/user/role) grant access regardless of ownership.
311        if rule.non_owner_match(actor) {
312            return Decision::Allow;
313        }
314
315        if rule.has_owner() {
316            let record_owner = record.get("_owner").and_then(|v| v.as_str());
317            match record_owner {
318                Some(owner) if actor.owner_keys().iter().any(|k| k == owner) => {
319                    return Decision::Allow;
320                }
321                None => {
322                    // Unowned record: legacy-modifiable only under the implicit
323                    // default; explicit policies stay strict.
324                    if !self.explicit {
325                        return Decision::AllowLegacy;
326                    }
327                }
328                Some(_) => {}
329            }
330        }
331
332        Decision::Deny
333    }
334
335    /// Compute the read scope for `actor`. `filter_ctx` supplies `#var#` values
336    /// (user/session) for the tenant filter.
337    pub fn read_scope(&self, actor: &Actor, filter_ctx: &HashMap<String, Value>) -> ReadScope {
338        let mut filters: Vec<String> = Vec::new();
339
340        // Tenant filter (fail closed if any #var# is unresolved).
341        if let Some(raw) = &self.filter {
342            let resolved = crate::parser::replace_variables(raw, filter_ctx);
343            if resolved.contains('#') {
344                return ReadScope::Deny;
345            }
346            filters.push(resolved);
347        }
348
349        if self.read.is_all() {
350            return if filters.is_empty() {
351                ReadScope::All
352            } else {
353                ReadScope::Filters(filters)
354            };
355        }
356
357        // A non-owner term (user/role) grants the full row-set (subject to the
358        // tenant filter above).
359        if self.read.non_owner_match(actor) {
360            return if filters.is_empty() {
361                ReadScope::All
362            } else {
363                ReadScope::Filters(filters)
364            };
365        }
366
367        // Owner scope: restrict to the actor's own records.
368        if self.read.has_owner() {
369            let keys = actor.owner_keys();
370            if keys.is_empty() {
371                return ReadScope::Deny;
372            }
373            // Comma = OR in the filter mini-language.
374            let owner_or = keys
375                .iter()
376                .map(|k| format!("_owner={k}"))
377                .collect::<Vec<_>>()
378                .join(",");
379            filters.push(owner_or);
380            return ReadScope::Filters(filters);
381        }
382
383        // No term matched (e.g. read = "none", or role rule with no match).
384        ReadScope::Deny
385    }
386
387    /// The resolved tenant filter for mutation checks, or None.
388    pub fn resolved_filter(&self, filter_ctx: &HashMap<String, Value>) -> Option<String> {
389        let raw = self.filter.as_ref()?;
390        let resolved = crate::parser::replace_variables(raw, filter_ctx);
391        if resolved.contains('#') {
392            // Unresolved — treat as "matches nothing" by returning a filter no
393            // record satisfies. Callers use this only for mutation gating.
394            return Some("_owner=\u{0}__unresolved__".to_string());
395        }
396        Some(resolved)
397    }
398
399    /// Stamp `_owner` into a new record's field map (create only).
400    pub fn stamp_owner(&self, map: &mut Map<String, Value>, actor: &Actor) {
401        if self.owner_mode == OwnerMode::None {
402            return;
403        }
404        if let Some(key) = actor.primary_owner_key() {
405            map.insert("_owner".to_string(), Value::String(key));
406        }
407    }
408
409    /// Stamp the tenant filter field(s) into a new record on create, forcing them
410    /// to the actor's resolved values. The `filter` (e.g. `org=#user.org#`) gates
411    /// reads/updates/deletes but NOT create, and the tenant field arrives from
412    /// client form data — so without this a tenant-A user could POST `org=B` and
413    /// inject a record into tenant B. Only the simple `field=value` form is
414    /// stamped; an unresolved or multi-term (`,` OR) filter is left alone (the
415    /// record then falls outside every tenant read scope — fail-safe).
416    pub fn stamp_tenant(&self, map: &mut Map<String, Value>, filter_ctx: &HashMap<String, Value>) {
417        let Some(resolved) = self.resolved_filter(filter_ctx) else {
418            return;
419        };
420        if resolved.contains('#') || resolved.contains(',') || resolved.contains('\u{0}') {
421            return;
422        }
423        if let Some((field, value)) = resolved.split_once('=') {
424            let field = field.trim();
425            if !field.is_empty() {
426                map.insert(field.to_string(), Value::String(value.trim().to_string()));
427            }
428        }
429    }
430
431    /// Remove client-supplied values the server manages: the `_owner` field
432    /// (never trust it from input) and any `fields.readonly`.
433    pub fn sanitize_input(&self, map: &mut Map<String, Value>) {
434        map.remove("_owner");
435        for field in &self.readonly_fields {
436            map.remove(field);
437        }
438    }
439}
440
441/// Registry of compiled collection policies, keyed by collection name.
442#[derive(Debug, Clone)]
443pub struct PolicyRegistry {
444    map: HashMap<String, CollectionPolicy>,
445    default_policy: CollectionPolicy,
446}
447
448impl PolicyRegistry {
449    /// Compile all policies from config, failing loud on any semantic error.
450    pub fn from_config(cfg: &HashMap<String, CollectionPolicyConfig>) -> Result<Self> {
451        let mut map = HashMap::new();
452        for (name, entry) in cfg {
453            map.insert(name.clone(), CollectionPolicy::compile(name, entry)?);
454        }
455        Ok(PolicyRegistry {
456            map,
457            default_policy: CollectionPolicy::default(),
458        })
459    }
460
461    /// An empty registry (all collections get the implicit default).
462    pub fn empty() -> Self {
463        PolicyRegistry {
464            map: HashMap::new(),
465            default_policy: CollectionPolicy::default(),
466        }
467    }
468
469    /// Look up a collection's policy, falling back to the implicit default.
470    pub fn get(&self, collection: &str) -> &CollectionPolicy {
471        self.map.get(collection).unwrap_or(&self.default_policy)
472    }
473
474    /// Iterate over explicitly configured collections.
475    pub fn configured(&self) -> impl Iterator<Item = (&str, &CollectionPolicy)> {
476        self.map.iter().map(|(k, v)| (k.as_str(), v))
477    }
478
479    /// Whether reads of this collection are scoped (used to scrub base context).
480    pub fn is_read_scoped(&self, collection: &str) -> bool {
481        self.get(collection).is_read_scoped()
482    }
483}
484
485/// The acting principal for a request.
486#[derive(Debug, Clone)]
487pub struct Actor {
488    pub authenticated: bool,
489    pub user_sub: Option<String>,
490    pub roles: Vec<String>,
491    /// `session:<hash>` owner key for anonymous ownership.
492    pub session_key: Option<String>,
493}
494
495impl Actor {
496    /// An anonymous actor (no session, no auth).
497    pub fn anonymous() -> Actor {
498        Actor {
499            authenticated: false,
500            user_sub: None,
501            roles: Vec::new(),
502            session_key: None,
503        }
504    }
505
506    /// Build an actor from the request's user context and session.
507    pub fn from_parts(user: &UserContext, session: Option<&Session>) -> Actor {
508        let user_sub = if user.authenticated {
509            match user.sub() {
510                // Filter mini-language metacharacters would corrupt owner
511                // filters — skip the user key and let session ownership stand.
512                Some(s) if s.contains([',', '&', '=', '<', '>']) => {
513                    tracing::warn!(
514                        target: "what::policy",
515                        "user sub contains filter metacharacters; skipping user ownership key"
516                    );
517                    None
518                }
519                other => other,
520            }
521        } else {
522            None
523        };
524        let session_key = session.map(|s| format!("session:{}", &s.id[..s.id.len().min(32)]));
525        Actor {
526            authenticated: user.authenticated,
527            user_sub,
528            roles: if user.authenticated { user.roles() } else { Vec::new() },
529            session_key,
530        }
531    }
532
533    /// All owner keys this actor can claim (`user:<sub>` and/or `session:<hash>`).
534    pub fn owner_keys(&self) -> Vec<String> {
535        let mut keys = Vec::new();
536        if let Some(sub) = &self.user_sub {
537            keys.push(format!("user:{sub}"));
538        }
539        if let Some(sk) = &self.session_key {
540            keys.push(sk.clone());
541        }
542        keys
543    }
544
545    /// The key used to stamp new records: the user key if authenticated, else
546    /// the session key.
547    pub fn primary_owner_key(&self) -> Option<String> {
548        if let Some(sub) = &self.user_sub {
549            return Some(format!("user:{sub}"));
550        }
551        self.session_key.clone()
552    }
553}
554
555/// Strip `fields.private` from a JSON array of records (or a single record).
556pub fn strip_private_fields(items: &mut Value, private: &[String]) {
557    if private.is_empty() {
558        return;
559    }
560    match items {
561        Value::Array(arr) => {
562            for item in arr.iter_mut() {
563                if let Value::Object(map) = item {
564                    for f in private {
565                        map.remove(f);
566                    }
567                }
568            }
569        }
570        Value::Object(map) => {
571            for f in private {
572                map.remove(f);
573            }
574        }
575        _ => {}
576    }
577}
578
579/// Scrub the base template context (built from the entire store): drop any
580/// read-scoped collection entirely (fetch directives re-add it scoped) and
581/// strip private fields from the rest. Collections with the implicit default
582/// (read = "all", no private fields) are untouched.
583pub fn scrub_base_context(reg: &PolicyRegistry, ctx: &mut HashMap<String, Value>) {
584    for (name, policy) in reg.configured() {
585        if policy.is_read_scoped() {
586            ctx.remove(name);
587        } else if !policy.private_fields.is_empty() {
588            if let Some(v) = ctx.get_mut(name) {
589                strip_private_fields(v, &policy.private_fields);
590            }
591        }
592    }
593}
594
595/// Evaluate a record against a filter expression, mirroring the SQL builder's
596/// semantics exactly (comma = OR groups, `&` = AND within a group, ops
597/// `>= <= > < =`). Used to enforce tenant scoping on mutations by id.
598pub fn record_matches_filter(record: &Value, filter_expr: &str) -> bool {
599    let obj = match record {
600        Value::Object(m) => m,
601        _ => return false,
602    };
603
604    // OR across comma-separated groups.
605    for group in filter_expr.split(',') {
606        if group.trim().is_empty() {
607            continue;
608        }
609        let mut group_ok = true;
610        for cond in group.split('&') {
611            let cond = cond.trim();
612            if cond.is_empty() {
613                continue;
614            }
615            if !eval_condition(obj, cond) {
616                group_ok = false;
617                break;
618            }
619        }
620        if group_ok {
621            return true;
622        }
623    }
624    false
625}
626
627fn eval_condition(obj: &Map<String, Value>, cond: &str) -> bool {
628    // Order matters: check two-char ops before single-char.
629    for (op, is_ge, is_le, is_gt, is_lt, is_eq) in [
630        (">=", true, false, false, false, false),
631        ("<=", false, true, false, false, false),
632        (">", false, false, true, false, false),
633        ("<", false, false, false, true, false),
634        ("=", false, false, false, false, true),
635    ] {
636        if let Some((field, val)) = cond.split_once(op) {
637            let field = field.trim();
638            let val = val.trim();
639            let actual = obj.get(field);
640            let actual_str = actual.map(value_to_string).unwrap_or_default();
641
642            // Try numeric comparison first, fall back to string.
643            let (an, vn) = (actual_str.parse::<f64>(), val.parse::<f64>());
644            return if let (Ok(a), Ok(v)) = (an, vn) {
645                if is_ge {
646                    a >= v
647                } else if is_le {
648                    a <= v
649                } else if is_gt {
650                    a > v
651                } else if is_lt {
652                    a < v
653                } else {
654                    a == v
655                }
656            } else if is_eq {
657                actual_str == val
658            } else if is_ge {
659                actual_str >= val.to_string()
660            } else if is_le {
661                actual_str <= val.to_string()
662            } else if is_gt {
663                actual_str > val.to_string()
664            } else {
665                actual_str < val.to_string()
666            };
667        }
668    }
669    false
670}
671
672fn value_to_string(v: &Value) -> String {
673    match v {
674        Value::String(s) => s.clone(),
675        Value::Number(n) => n.to_string(),
676        Value::Bool(b) => b.to_string(),
677        Value::Null => String::new(),
678        other => other.to_string(),
679    }
680}
681
682#[cfg(test)]
683mod tests {
684    use super::*;
685    use serde_json::json;
686
687    fn cfg(f: impl FnOnce(&mut CollectionPolicyConfig)) -> CollectionPolicyConfig {
688        let mut c = CollectionPolicyConfig::default();
689        f(&mut c);
690        c
691    }
692
693    fn user_actor(sub: &str, roles: &[&str]) -> Actor {
694        Actor {
695            authenticated: true,
696            user_sub: Some(sub.to_string()),
697            roles: roles.iter().map(|s| s.to_string()).collect(),
698            session_key: None,
699        }
700    }
701
702    fn session_actor(id: &str) -> Actor {
703        Actor {
704            authenticated: false,
705            user_sub: None,
706            roles: Vec::new(),
707            session_key: Some(format!("session:{id}")),
708        }
709    }
710
711    #[test]
712    fn rule_parse_basic() {
713        assert!(Rule::parse("all", "x", true).unwrap().is_all());
714        assert_eq!(
715            Rule::parse("owner, admin", "x", true).unwrap().terms,
716            vec![RuleTerm::Owner, RuleTerm::Role("admin".into())]
717        );
718        assert_eq!(
719            Rule::parse("editor,admin", "x", false).unwrap().terms,
720            vec![RuleTerm::Role("editor".into()), RuleTerm::Role("admin".into())]
721        );
722    }
723
724    #[test]
725    fn rule_parse_failures() {
726        assert!(Rule::parse("owner", "collections.x.create", false).is_err());
727        assert!(Rule::parse("all, admin", "x", true).is_err());
728        assert!(Rule::parse("none, user", "x", true).is_err());
729        assert!(Rule::parse("", "x", true).is_err());
730    }
731
732    #[test]
733    fn default_policy_is_owner_protected() {
734        let p = CollectionPolicy::default();
735        assert!(p.create.is_all());
736        assert_eq!(p.update, Rule::owner());
737        assert_eq!(p.delete, Rule::owner());
738        assert!(p.read.is_all());
739        assert!(!p.explicit);
740        assert!(!p.is_read_scoped());
741    }
742
743    #[test]
744    fn create_authorization() {
745        let p = CollectionPolicy::compile("notes", &cfg(|c| c.create = Some("user".into()))).unwrap();
746        assert!(!p.allows_create(&Actor::anonymous()));
747        assert!(p.allows_create(&user_actor("alice", &[])));
748
749        let roles = CollectionPolicy::compile("a", &cfg(|c| c.create = Some("editor".into()))).unwrap();
750        assert!(!roles.allows_create(&user_actor("bob", &[])));
751        assert!(roles.allows_create(&user_actor("bob", &["editor"])));
752    }
753
754    #[test]
755    fn update_owner_matching() {
756        let p = CollectionPolicy::default(); // update = owner, implicit
757        let rec = json!({"_owner": "user:alice", "title": "x"});
758        assert_eq!(
759            p.allows_mutation(&user_actor("alice", &[]), Some(&rec), MutationKind::Update, None),
760            Decision::Allow
761        );
762        assert_eq!(
763            p.allows_mutation(&user_actor("bob", &[]), Some(&rec), MutationKind::Update, None),
764            Decision::Deny
765        );
766    }
767
768    #[test]
769    fn legacy_unowned_record() {
770        let implicit = CollectionPolicy::default();
771        let rec = json!({"title": "no owner"});
772        assert_eq!(
773            implicit.allows_mutation(&Actor::anonymous(), Some(&rec), MutationKind::Delete, None),
774            Decision::AllowLegacy
775        );
776
777        // Explicit policy stays strict.
778        let explicit = CollectionPolicy::compile("n", &cfg(|c| c.update = Some("owner".into()))).unwrap();
779        assert_eq!(
780            explicit.allows_mutation(&session_actor("abc"), Some(&rec), MutationKind::Update, None),
781            Decision::Deny
782        );
783    }
784
785    #[test]
786    fn missing_record_is_safe_noop() {
787        let p = CollectionPolicy::default();
788        assert_eq!(
789            p.allows_mutation(&Actor::anonymous(), None, MutationKind::Delete, None),
790            Decision::Allow
791        );
792    }
793
794    #[test]
795    fn role_delete_rule() {
796        let p = CollectionPolicy::compile("n", &cfg(|c| c.delete = Some("owner, admin".into()))).unwrap();
797        let rec = json!({"_owner": "user:alice"});
798        // Non-owner admin passes.
799        assert_eq!(
800            p.allows_mutation(&user_actor("carol", &["admin"]), Some(&rec), MutationKind::Delete, None),
801            Decision::Allow
802        );
803        // Non-owner non-admin denied.
804        assert_eq!(
805            p.allows_mutation(&user_actor("carol", &[]), Some(&rec), MutationKind::Delete, None),
806            Decision::Deny
807        );
808    }
809
810    #[test]
811    fn tenant_filter_gates_mutation() {
812        let p = CollectionPolicy::compile("n", &cfg(|c| {
813            c.filter = Some("org=acme".into());
814            c.update = Some("user".into());
815        })).unwrap();
816        let mine = json!({"org": "acme"});
817        let theirs = json!({"org": "other"});
818        assert_eq!(
819            p.allows_mutation(&user_actor("a", &[]), Some(&mine), MutationKind::Update, Some("org=acme")),
820            Decision::Allow
821        );
822        assert_eq!(
823            p.allows_mutation(&user_actor("a", &[]), Some(&theirs), MutationKind::Update, Some("org=acme")),
824            Decision::Deny
825        );
826    }
827
828    #[test]
829    fn read_scope_permutations() {
830        let empty = HashMap::new();
831
832        // read = all
833        assert_eq!(CollectionPolicy::default().read_scope(&Actor::anonymous(), &empty), ReadScope::All);
834
835        // read = owner, has session
836        let owner_read = CollectionPolicy::compile("n", &cfg(|c| c.read = Some("owner".into()))).unwrap();
837        match owner_read.read_scope(&session_actor("abc"), &empty) {
838            ReadScope::Filters(f) => assert_eq!(f, vec!["_owner=session:abc".to_string()]),
839            other => panic!("expected filters, got {other:?}"),
840        }
841
842        // read = owner, no identity → deny
843        assert_eq!(owner_read.read_scope(&Actor::anonymous(), &empty), ReadScope::Deny);
844
845        // read = user, authenticated → all
846        let user_read = CollectionPolicy::compile("n", &cfg(|c| c.read = Some("user".into()))).unwrap();
847        assert_eq!(user_read.read_scope(&user_actor("a", &[]), &empty), ReadScope::All);
848        assert_eq!(user_read.read_scope(&Actor::anonymous(), &empty), ReadScope::Deny);
849    }
850
851    #[test]
852    fn read_scope_tenant_filter() {
853        let mut ctx = HashMap::new();
854        ctx.insert("user".to_string(), json!({"org_id": "acme"}));
855        let p = CollectionPolicy::compile("n", &cfg(|c| c.filter = Some("org_id=#user.org_id#".into()))).unwrap();
856        match p.read_scope(&user_actor("a", &[]), &ctx) {
857            ReadScope::Filters(f) => assert_eq!(f, vec!["org_id=acme".to_string()]),
858            other => panic!("expected filters, got {other:?}"),
859        }
860        // Unresolved var → deny.
861        assert_eq!(p.read_scope(&user_actor("a", &[]), &HashMap::new()), ReadScope::Deny);
862    }
863
864    #[test]
865    fn owner_and_tenant_combine() {
866        let mut ctx = HashMap::new();
867        ctx.insert("user".to_string(), json!({"org_id": "acme"}));
868        let p = CollectionPolicy::compile("n", &cfg(|c| {
869            c.read = Some("owner".into());
870            c.filter = Some("org_id=#user.org_id#".into());
871        })).unwrap();
872        let actor = user_actor("alice", &[]);
873        match p.read_scope(&actor, &ctx) {
874            ReadScope::Filters(f) => {
875                assert_eq!(f, vec!["org_id=acme".to_string(), "_owner=user:alice".to_string()]);
876            }
877            other => panic!("expected filters, got {other:?}"),
878        }
879    }
880
881    #[test]
882    fn stamp_and_sanitize() {
883        let p = CollectionPolicy::compile("n", &cfg(|c| c.fields.readonly = vec!["price".into()])).unwrap();
884        let mut map = Map::new();
885        map.insert("title".into(), json!("hi"));
886        map.insert("_owner".into(), json!("user:evil")); // spoof attempt
887        map.insert("price".into(), json!("0")); // readonly
888        p.sanitize_input(&mut map);
889        assert!(!map.contains_key("_owner"));
890        assert!(!map.contains_key("price"));
891
892        p.stamp_owner(&mut map, &user_actor("alice", &[]));
893        assert_eq!(map.get("_owner"), Some(&json!("user:alice")));
894    }
895
896    #[test]
897    fn owner_mode_none_skips_stamp() {
898        let p = CollectionPolicy::compile("n", &cfg(|c| c.owner = Some("none".into()))).unwrap();
899        let mut map = Map::new();
900        p.stamp_owner(&mut map, &user_actor("alice", &[]));
901        assert!(!map.contains_key("_owner"));
902    }
903
904    #[test]
905    fn actor_owner_keys() {
906        let both = Actor {
907            authenticated: true,
908            user_sub: Some("alice".into()),
909            roles: vec![],
910            session_key: Some("session:abc".into()),
911        };
912        assert_eq!(both.owner_keys(), vec!["user:alice", "session:abc"]);
913        assert_eq!(both.primary_owner_key(), Some("user:alice".into()));
914        assert_eq!(session_actor("abc").primary_owner_key(), Some("session:abc".into()));
915    }
916
917    #[test]
918    fn record_matches_filter_parity() {
919        let rec = json!({"org": "acme", "count": 5});
920        assert!(record_matches_filter(&rec, "org=acme"));
921        assert!(!record_matches_filter(&rec, "org=other"));
922        assert!(record_matches_filter(&rec, "count>=5"));
923        assert!(record_matches_filter(&rec, "count>3"));
924        assert!(!record_matches_filter(&rec, "count>5"));
925        // OR groups
926        assert!(record_matches_filter(&rec, "org=other,org=acme"));
927        // AND within group
928        assert!(record_matches_filter(&rec, "org=acme&count=5"));
929        assert!(!record_matches_filter(&rec, "org=acme&count=9"));
930    }
931
932    #[test]
933    fn strip_private() {
934        let mut items = json!([{"email": "a@x.com", "name": "A"}, {"email": "b@x.com", "name": "B"}]);
935        strip_private_fields(&mut items, &["email".to_string()]);
936        assert_eq!(items, json!([{"name": "A"}, {"name": "B"}]));
937    }
938
939    #[test]
940    fn registry_defaults_and_lookup() {
941        let mut cfg_map = HashMap::new();
942        cfg_map.insert("notes".to_string(), cfg(|c| c.read = Some("owner".into())));
943        let reg = PolicyRegistry::from_config(&cfg_map).unwrap();
944        assert!(reg.get("notes").explicit);
945        assert!(reg.is_read_scoped("notes"));
946        // Unknown collection → implicit default.
947        assert!(!reg.get("other").explicit);
948        assert!(!reg.is_read_scoped("other"));
949    }
950
951    #[test]
952    fn registry_fails_loud() {
953        let mut cfg_map = HashMap::new();
954        cfg_map.insert("bad".to_string(), cfg(|c| c.create = Some("owner".into())));
955        assert!(PolicyRegistry::from_config(&cfg_map).is_err());
956    }
957
958    #[test]
959    fn scrub_base_context_drops_scoped() {
960        let mut cfg_map = HashMap::new();
961        cfg_map.insert("secret".to_string(), cfg(|c| c.read = Some("owner".into())));
962        cfg_map.insert("public".to_string(), cfg(|c| c.fields.private = vec!["ssn".into()]));
963        let reg = PolicyRegistry::from_config(&cfg_map).unwrap();
964
965        let mut ctx = HashMap::new();
966        ctx.insert("secret".to_string(), json!([{"x": 1}]));
967        ctx.insert("public".to_string(), json!([{"name": "A", "ssn": "123"}]));
968        ctx.insert("open".to_string(), json!([{"y": 2}]));
969
970        scrub_base_context(&reg, &mut ctx);
971        assert!(!ctx.contains_key("secret"));
972        assert_eq!(ctx.get("public"), Some(&json!([{"name": "A"}])));
973        assert_eq!(ctx.get("open"), Some(&json!([{"y": 2}])));
974    }
975}