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    /// Remove client-supplied values the server manages: the `_owner` field
410    /// (never trust it from input) and any `fields.readonly`.
411    pub fn sanitize_input(&self, map: &mut Map<String, Value>) {
412        map.remove("_owner");
413        for field in &self.readonly_fields {
414            map.remove(field);
415        }
416    }
417}
418
419/// Registry of compiled collection policies, keyed by collection name.
420#[derive(Debug, Clone)]
421pub struct PolicyRegistry {
422    map: HashMap<String, CollectionPolicy>,
423    default_policy: CollectionPolicy,
424}
425
426impl PolicyRegistry {
427    /// Compile all policies from config, failing loud on any semantic error.
428    pub fn from_config(cfg: &HashMap<String, CollectionPolicyConfig>) -> Result<Self> {
429        let mut map = HashMap::new();
430        for (name, entry) in cfg {
431            map.insert(name.clone(), CollectionPolicy::compile(name, entry)?);
432        }
433        Ok(PolicyRegistry {
434            map,
435            default_policy: CollectionPolicy::default(),
436        })
437    }
438
439    /// An empty registry (all collections get the implicit default).
440    pub fn empty() -> Self {
441        PolicyRegistry {
442            map: HashMap::new(),
443            default_policy: CollectionPolicy::default(),
444        }
445    }
446
447    /// Look up a collection's policy, falling back to the implicit default.
448    pub fn get(&self, collection: &str) -> &CollectionPolicy {
449        self.map.get(collection).unwrap_or(&self.default_policy)
450    }
451
452    /// Iterate over explicitly configured collections.
453    pub fn configured(&self) -> impl Iterator<Item = (&str, &CollectionPolicy)> {
454        self.map.iter().map(|(k, v)| (k.as_str(), v))
455    }
456
457    /// Whether reads of this collection are scoped (used to scrub base context).
458    pub fn is_read_scoped(&self, collection: &str) -> bool {
459        self.get(collection).is_read_scoped()
460    }
461}
462
463/// The acting principal for a request.
464#[derive(Debug, Clone)]
465pub struct Actor {
466    pub authenticated: bool,
467    pub user_sub: Option<String>,
468    pub roles: Vec<String>,
469    /// `session:<hash>` owner key for anonymous ownership.
470    pub session_key: Option<String>,
471}
472
473impl Actor {
474    /// An anonymous actor (no session, no auth).
475    pub fn anonymous() -> Actor {
476        Actor {
477            authenticated: false,
478            user_sub: None,
479            roles: Vec::new(),
480            session_key: None,
481        }
482    }
483
484    /// Build an actor from the request's user context and session.
485    pub fn from_parts(user: &UserContext, session: Option<&Session>) -> Actor {
486        let user_sub = if user.authenticated {
487            match user.sub() {
488                // Filter mini-language metacharacters would corrupt owner
489                // filters — skip the user key and let session ownership stand.
490                Some(s) if s.contains([',', '&', '=', '<', '>']) => {
491                    tracing::warn!(
492                        target: "what::policy",
493                        "user sub contains filter metacharacters; skipping user ownership key"
494                    );
495                    None
496                }
497                other => other,
498            }
499        } else {
500            None
501        };
502        let session_key = session.map(|s| format!("session:{}", &s.id[..s.id.len().min(32)]));
503        Actor {
504            authenticated: user.authenticated,
505            user_sub,
506            roles: if user.authenticated { user.roles() } else { Vec::new() },
507            session_key,
508        }
509    }
510
511    /// All owner keys this actor can claim (`user:<sub>` and/or `session:<hash>`).
512    pub fn owner_keys(&self) -> Vec<String> {
513        let mut keys = Vec::new();
514        if let Some(sub) = &self.user_sub {
515            keys.push(format!("user:{sub}"));
516        }
517        if let Some(sk) = &self.session_key {
518            keys.push(sk.clone());
519        }
520        keys
521    }
522
523    /// The key used to stamp new records: the user key if authenticated, else
524    /// the session key.
525    pub fn primary_owner_key(&self) -> Option<String> {
526        if let Some(sub) = &self.user_sub {
527            return Some(format!("user:{sub}"));
528        }
529        self.session_key.clone()
530    }
531}
532
533/// Strip `fields.private` from a JSON array of records (or a single record).
534pub fn strip_private_fields(items: &mut Value, private: &[String]) {
535    if private.is_empty() {
536        return;
537    }
538    match items {
539        Value::Array(arr) => {
540            for item in arr.iter_mut() {
541                if let Value::Object(map) = item {
542                    for f in private {
543                        map.remove(f);
544                    }
545                }
546            }
547        }
548        Value::Object(map) => {
549            for f in private {
550                map.remove(f);
551            }
552        }
553        _ => {}
554    }
555}
556
557/// Scrub the base template context (built from the entire store): drop any
558/// read-scoped collection entirely (fetch directives re-add it scoped) and
559/// strip private fields from the rest. Collections with the implicit default
560/// (read = "all", no private fields) are untouched.
561pub fn scrub_base_context(reg: &PolicyRegistry, ctx: &mut HashMap<String, Value>) {
562    for (name, policy) in reg.configured() {
563        if policy.is_read_scoped() {
564            ctx.remove(name);
565        } else if !policy.private_fields.is_empty() {
566            if let Some(v) = ctx.get_mut(name) {
567                strip_private_fields(v, &policy.private_fields);
568            }
569        }
570    }
571}
572
573/// Evaluate a record against a filter expression, mirroring the SQL builder's
574/// semantics exactly (comma = OR groups, `&` = AND within a group, ops
575/// `>= <= > < =`). Used to enforce tenant scoping on mutations by id.
576pub fn record_matches_filter(record: &Value, filter_expr: &str) -> bool {
577    let obj = match record {
578        Value::Object(m) => m,
579        _ => return false,
580    };
581
582    // OR across comma-separated groups.
583    for group in filter_expr.split(',') {
584        if group.trim().is_empty() {
585            continue;
586        }
587        let mut group_ok = true;
588        for cond in group.split('&') {
589            let cond = cond.trim();
590            if cond.is_empty() {
591                continue;
592            }
593            if !eval_condition(obj, cond) {
594                group_ok = false;
595                break;
596            }
597        }
598        if group_ok {
599            return true;
600        }
601    }
602    false
603}
604
605fn eval_condition(obj: &Map<String, Value>, cond: &str) -> bool {
606    // Order matters: check two-char ops before single-char.
607    for (op, is_ge, is_le, is_gt, is_lt, is_eq) in [
608        (">=", true, false, false, false, false),
609        ("<=", false, true, false, false, false),
610        (">", false, false, true, false, false),
611        ("<", false, false, false, true, false),
612        ("=", false, false, false, false, true),
613    ] {
614        if let Some((field, val)) = cond.split_once(op) {
615            let field = field.trim();
616            let val = val.trim();
617            let actual = obj.get(field);
618            let actual_str = actual.map(value_to_string).unwrap_or_default();
619
620            // Try numeric comparison first, fall back to string.
621            let (an, vn) = (actual_str.parse::<f64>(), val.parse::<f64>());
622            return if let (Ok(a), Ok(v)) = (an, vn) {
623                if is_ge {
624                    a >= v
625                } else if is_le {
626                    a <= v
627                } else if is_gt {
628                    a > v
629                } else if is_lt {
630                    a < v
631                } else {
632                    a == v
633                }
634            } else if is_eq {
635                actual_str == val
636            } else if is_ge {
637                actual_str >= val.to_string()
638            } else if is_le {
639                actual_str <= val.to_string()
640            } else if is_gt {
641                actual_str > val.to_string()
642            } else {
643                actual_str < val.to_string()
644            };
645        }
646    }
647    false
648}
649
650fn value_to_string(v: &Value) -> String {
651    match v {
652        Value::String(s) => s.clone(),
653        Value::Number(n) => n.to_string(),
654        Value::Bool(b) => b.to_string(),
655        Value::Null => String::new(),
656        other => other.to_string(),
657    }
658}
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663    use serde_json::json;
664
665    fn cfg(f: impl FnOnce(&mut CollectionPolicyConfig)) -> CollectionPolicyConfig {
666        let mut c = CollectionPolicyConfig::default();
667        f(&mut c);
668        c
669    }
670
671    fn user_actor(sub: &str, roles: &[&str]) -> Actor {
672        Actor {
673            authenticated: true,
674            user_sub: Some(sub.to_string()),
675            roles: roles.iter().map(|s| s.to_string()).collect(),
676            session_key: None,
677        }
678    }
679
680    fn session_actor(id: &str) -> Actor {
681        Actor {
682            authenticated: false,
683            user_sub: None,
684            roles: Vec::new(),
685            session_key: Some(format!("session:{id}")),
686        }
687    }
688
689    #[test]
690    fn rule_parse_basic() {
691        assert!(Rule::parse("all", "x", true).unwrap().is_all());
692        assert_eq!(
693            Rule::parse("owner, admin", "x", true).unwrap().terms,
694            vec![RuleTerm::Owner, RuleTerm::Role("admin".into())]
695        );
696        assert_eq!(
697            Rule::parse("editor,admin", "x", false).unwrap().terms,
698            vec![RuleTerm::Role("editor".into()), RuleTerm::Role("admin".into())]
699        );
700    }
701
702    #[test]
703    fn rule_parse_failures() {
704        assert!(Rule::parse("owner", "collections.x.create", false).is_err());
705        assert!(Rule::parse("all, admin", "x", true).is_err());
706        assert!(Rule::parse("none, user", "x", true).is_err());
707        assert!(Rule::parse("", "x", true).is_err());
708    }
709
710    #[test]
711    fn default_policy_is_owner_protected() {
712        let p = CollectionPolicy::default();
713        assert!(p.create.is_all());
714        assert_eq!(p.update, Rule::owner());
715        assert_eq!(p.delete, Rule::owner());
716        assert!(p.read.is_all());
717        assert!(!p.explicit);
718        assert!(!p.is_read_scoped());
719    }
720
721    #[test]
722    fn create_authorization() {
723        let p = CollectionPolicy::compile("notes", &cfg(|c| c.create = Some("user".into()))).unwrap();
724        assert!(!p.allows_create(&Actor::anonymous()));
725        assert!(p.allows_create(&user_actor("alice", &[])));
726
727        let roles = CollectionPolicy::compile("a", &cfg(|c| c.create = Some("editor".into()))).unwrap();
728        assert!(!roles.allows_create(&user_actor("bob", &[])));
729        assert!(roles.allows_create(&user_actor("bob", &["editor"])));
730    }
731
732    #[test]
733    fn update_owner_matching() {
734        let p = CollectionPolicy::default(); // update = owner, implicit
735        let rec = json!({"_owner": "user:alice", "title": "x"});
736        assert_eq!(
737            p.allows_mutation(&user_actor("alice", &[]), Some(&rec), MutationKind::Update, None),
738            Decision::Allow
739        );
740        assert_eq!(
741            p.allows_mutation(&user_actor("bob", &[]), Some(&rec), MutationKind::Update, None),
742            Decision::Deny
743        );
744    }
745
746    #[test]
747    fn legacy_unowned_record() {
748        let implicit = CollectionPolicy::default();
749        let rec = json!({"title": "no owner"});
750        assert_eq!(
751            implicit.allows_mutation(&Actor::anonymous(), Some(&rec), MutationKind::Delete, None),
752            Decision::AllowLegacy
753        );
754
755        // Explicit policy stays strict.
756        let explicit = CollectionPolicy::compile("n", &cfg(|c| c.update = Some("owner".into()))).unwrap();
757        assert_eq!(
758            explicit.allows_mutation(&session_actor("abc"), Some(&rec), MutationKind::Update, None),
759            Decision::Deny
760        );
761    }
762
763    #[test]
764    fn missing_record_is_safe_noop() {
765        let p = CollectionPolicy::default();
766        assert_eq!(
767            p.allows_mutation(&Actor::anonymous(), None, MutationKind::Delete, None),
768            Decision::Allow
769        );
770    }
771
772    #[test]
773    fn role_delete_rule() {
774        let p = CollectionPolicy::compile("n", &cfg(|c| c.delete = Some("owner, admin".into()))).unwrap();
775        let rec = json!({"_owner": "user:alice"});
776        // Non-owner admin passes.
777        assert_eq!(
778            p.allows_mutation(&user_actor("carol", &["admin"]), Some(&rec), MutationKind::Delete, None),
779            Decision::Allow
780        );
781        // Non-owner non-admin denied.
782        assert_eq!(
783            p.allows_mutation(&user_actor("carol", &[]), Some(&rec), MutationKind::Delete, None),
784            Decision::Deny
785        );
786    }
787
788    #[test]
789    fn tenant_filter_gates_mutation() {
790        let p = CollectionPolicy::compile("n", &cfg(|c| {
791            c.filter = Some("org=acme".into());
792            c.update = Some("user".into());
793        })).unwrap();
794        let mine = json!({"org": "acme"});
795        let theirs = json!({"org": "other"});
796        assert_eq!(
797            p.allows_mutation(&user_actor("a", &[]), Some(&mine), MutationKind::Update, Some("org=acme")),
798            Decision::Allow
799        );
800        assert_eq!(
801            p.allows_mutation(&user_actor("a", &[]), Some(&theirs), MutationKind::Update, Some("org=acme")),
802            Decision::Deny
803        );
804    }
805
806    #[test]
807    fn read_scope_permutations() {
808        let empty = HashMap::new();
809
810        // read = all
811        assert_eq!(CollectionPolicy::default().read_scope(&Actor::anonymous(), &empty), ReadScope::All);
812
813        // read = owner, has session
814        let owner_read = CollectionPolicy::compile("n", &cfg(|c| c.read = Some("owner".into()))).unwrap();
815        match owner_read.read_scope(&session_actor("abc"), &empty) {
816            ReadScope::Filters(f) => assert_eq!(f, vec!["_owner=session:abc".to_string()]),
817            other => panic!("expected filters, got {other:?}"),
818        }
819
820        // read = owner, no identity → deny
821        assert_eq!(owner_read.read_scope(&Actor::anonymous(), &empty), ReadScope::Deny);
822
823        // read = user, authenticated → all
824        let user_read = CollectionPolicy::compile("n", &cfg(|c| c.read = Some("user".into()))).unwrap();
825        assert_eq!(user_read.read_scope(&user_actor("a", &[]), &empty), ReadScope::All);
826        assert_eq!(user_read.read_scope(&Actor::anonymous(), &empty), ReadScope::Deny);
827    }
828
829    #[test]
830    fn read_scope_tenant_filter() {
831        let mut ctx = HashMap::new();
832        ctx.insert("user".to_string(), json!({"org_id": "acme"}));
833        let p = CollectionPolicy::compile("n", &cfg(|c| c.filter = Some("org_id=#user.org_id#".into()))).unwrap();
834        match p.read_scope(&user_actor("a", &[]), &ctx) {
835            ReadScope::Filters(f) => assert_eq!(f, vec!["org_id=acme".to_string()]),
836            other => panic!("expected filters, got {other:?}"),
837        }
838        // Unresolved var → deny.
839        assert_eq!(p.read_scope(&user_actor("a", &[]), &HashMap::new()), ReadScope::Deny);
840    }
841
842    #[test]
843    fn owner_and_tenant_combine() {
844        let mut ctx = HashMap::new();
845        ctx.insert("user".to_string(), json!({"org_id": "acme"}));
846        let p = CollectionPolicy::compile("n", &cfg(|c| {
847            c.read = Some("owner".into());
848            c.filter = Some("org_id=#user.org_id#".into());
849        })).unwrap();
850        let actor = user_actor("alice", &[]);
851        match p.read_scope(&actor, &ctx) {
852            ReadScope::Filters(f) => {
853                assert_eq!(f, vec!["org_id=acme".to_string(), "_owner=user:alice".to_string()]);
854            }
855            other => panic!("expected filters, got {other:?}"),
856        }
857    }
858
859    #[test]
860    fn stamp_and_sanitize() {
861        let p = CollectionPolicy::compile("n", &cfg(|c| c.fields.readonly = vec!["price".into()])).unwrap();
862        let mut map = Map::new();
863        map.insert("title".into(), json!("hi"));
864        map.insert("_owner".into(), json!("user:evil")); // spoof attempt
865        map.insert("price".into(), json!("0")); // readonly
866        p.sanitize_input(&mut map);
867        assert!(!map.contains_key("_owner"));
868        assert!(!map.contains_key("price"));
869
870        p.stamp_owner(&mut map, &user_actor("alice", &[]));
871        assert_eq!(map.get("_owner"), Some(&json!("user:alice")));
872    }
873
874    #[test]
875    fn owner_mode_none_skips_stamp() {
876        let p = CollectionPolicy::compile("n", &cfg(|c| c.owner = Some("none".into()))).unwrap();
877        let mut map = Map::new();
878        p.stamp_owner(&mut map, &user_actor("alice", &[]));
879        assert!(!map.contains_key("_owner"));
880    }
881
882    #[test]
883    fn actor_owner_keys() {
884        let both = Actor {
885            authenticated: true,
886            user_sub: Some("alice".into()),
887            roles: vec![],
888            session_key: Some("session:abc".into()),
889        };
890        assert_eq!(both.owner_keys(), vec!["user:alice", "session:abc"]);
891        assert_eq!(both.primary_owner_key(), Some("user:alice".into()));
892        assert_eq!(session_actor("abc").primary_owner_key(), Some("session:abc".into()));
893    }
894
895    #[test]
896    fn record_matches_filter_parity() {
897        let rec = json!({"org": "acme", "count": 5});
898        assert!(record_matches_filter(&rec, "org=acme"));
899        assert!(!record_matches_filter(&rec, "org=other"));
900        assert!(record_matches_filter(&rec, "count>=5"));
901        assert!(record_matches_filter(&rec, "count>3"));
902        assert!(!record_matches_filter(&rec, "count>5"));
903        // OR groups
904        assert!(record_matches_filter(&rec, "org=other,org=acme"));
905        // AND within group
906        assert!(record_matches_filter(&rec, "org=acme&count=5"));
907        assert!(!record_matches_filter(&rec, "org=acme&count=9"));
908    }
909
910    #[test]
911    fn strip_private() {
912        let mut items = json!([{"email": "a@x.com", "name": "A"}, {"email": "b@x.com", "name": "B"}]);
913        strip_private_fields(&mut items, &["email".to_string()]);
914        assert_eq!(items, json!([{"name": "A"}, {"name": "B"}]));
915    }
916
917    #[test]
918    fn registry_defaults_and_lookup() {
919        let mut cfg_map = HashMap::new();
920        cfg_map.insert("notes".to_string(), cfg(|c| c.read = Some("owner".into())));
921        let reg = PolicyRegistry::from_config(&cfg_map).unwrap();
922        assert!(reg.get("notes").explicit);
923        assert!(reg.is_read_scoped("notes"));
924        // Unknown collection → implicit default.
925        assert!(!reg.get("other").explicit);
926        assert!(!reg.is_read_scoped("other"));
927    }
928
929    #[test]
930    fn registry_fails_loud() {
931        let mut cfg_map = HashMap::new();
932        cfg_map.insert("bad".to_string(), cfg(|c| c.create = Some("owner".into())));
933        assert!(PolicyRegistry::from_config(&cfg_map).is_err());
934    }
935
936    #[test]
937    fn scrub_base_context_drops_scoped() {
938        let mut cfg_map = HashMap::new();
939        cfg_map.insert("secret".to_string(), cfg(|c| c.read = Some("owner".into())));
940        cfg_map.insert("public".to_string(), cfg(|c| c.fields.private = vec!["ssn".into()]));
941        let reg = PolicyRegistry::from_config(&cfg_map).unwrap();
942
943        let mut ctx = HashMap::new();
944        ctx.insert("secret".to_string(), json!([{"x": 1}]));
945        ctx.insert("public".to_string(), json!([{"name": "A", "ssn": "123"}]));
946        ctx.insert("open".to_string(), json!([{"y": 2}]));
947
948        scrub_base_context(&reg, &mut ctx);
949        assert!(!ctx.contains_key("secret"));
950        assert_eq!(ctx.get("public"), Some(&json!([{"name": "A"}])));
951        assert_eq!(ctx.get("open"), Some(&json!([{"y": 2}])));
952    }
953}