1use 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#[derive(Debug, Clone, PartialEq)]
24pub enum RuleTerm {
25 All,
27 User,
29 Owner,
31 Nobody,
33 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#[derive(Debug, Clone, PartialEq)]
51pub struct Rule {
52 terms: Vec<RuleTerm>,
53}
54
55impl Rule {
56 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 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 pub fn all() -> Rule {
105 Rule { terms: vec![RuleTerm::All] }
106 }
107
108 pub fn owner() -> Rule {
110 Rule { terms: vec![RuleTerm::Owner] }
111 }
112
113 pub fn is_all(&self) -> bool {
115 self.terms == [RuleTerm::All]
116 }
117
118 fn has_owner(&self) -> bool {
120 self.terms.contains(&RuleTerm::Owner)
121 }
122
123 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 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#[derive(Debug, Clone, PartialEq)]
145pub enum OwnerMode {
146 Auto,
148 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#[derive(Debug, Clone, PartialEq)]
163pub enum Decision {
164 Allow,
166 AllowLegacy,
169 Deny,
171}
172
173#[derive(Debug, Clone, PartialEq)]
175pub enum ReadScope {
176 All,
178 Deny,
180 Filters(Vec<String>),
183}
184
185#[derive(Debug, Clone, Copy, PartialEq)]
187pub enum MutationKind {
188 Update,
189 Delete,
190}
191
192#[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 pub explicit: bool,
205}
206
207impl Default for CollectionPolicy {
208 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 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 pub fn is_read_scoped(&self) -> bool {
276 !self.read.is_all() || self.filter.is_some()
277 }
278
279 pub fn allows_create(&self, actor: &Actor) -> bool {
281 self.create.non_owner_match(actor)
282 }
283
284 pub fn allows_mutation(
290 &self,
291 actor: &Actor,
292 record: Option<&Value>,
293 kind: MutationKind,
294 resolved_filter: Option<&str>,
295 ) -> Decision {
296 let Some(record) = record else {
298 return Decision::Allow;
299 };
300
301 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 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 if !self.explicit {
325 return Decision::AllowLegacy;
326 }
327 }
328 Some(_) => {}
329 }
330 }
331
332 Decision::Deny
333 }
334
335 pub fn read_scope(&self, actor: &Actor, filter_ctx: &HashMap<String, Value>) -> ReadScope {
338 let mut filters: Vec<String> = Vec::new();
339
340 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 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 if self.read.has_owner() {
369 let keys = actor.owner_keys();
370 if keys.is_empty() {
371 return ReadScope::Deny;
372 }
373 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 ReadScope::Deny
385 }
386
387 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 return Some("_owner=\u{0}__unresolved__".to_string());
395 }
396 Some(resolved)
397 }
398
399 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 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 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#[derive(Debug, Clone)]
443pub struct PolicyRegistry {
444 map: HashMap<String, CollectionPolicy>,
445 default_policy: CollectionPolicy,
446}
447
448impl PolicyRegistry {
449 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 pub fn empty() -> Self {
463 PolicyRegistry {
464 map: HashMap::new(),
465 default_policy: CollectionPolicy::default(),
466 }
467 }
468
469 pub fn get(&self, collection: &str) -> &CollectionPolicy {
471 self.map.get(collection).unwrap_or(&self.default_policy)
472 }
473
474 pub fn configured(&self) -> impl Iterator<Item = (&str, &CollectionPolicy)> {
476 self.map.iter().map(|(k, v)| (k.as_str(), v))
477 }
478
479 pub fn is_read_scoped(&self, collection: &str) -> bool {
481 self.get(collection).is_read_scoped()
482 }
483}
484
485#[derive(Debug, Clone)]
487pub struct Actor {
488 pub authenticated: bool,
489 pub user_sub: Option<String>,
490 pub roles: Vec<String>,
491 pub session_key: Option<String>,
493}
494
495impl Actor {
496 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 pub fn from_parts(user: &UserContext, session: Option<&Session>) -> Actor {
508 let user_sub = if user.authenticated {
509 match user.sub() {
510 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 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 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
555pub 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
579pub 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
595pub 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 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 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 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(); 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 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 assert_eq!(
800 p.allows_mutation(&user_actor("carol", &["admin"]), Some(&rec), MutationKind::Delete, None),
801 Decision::Allow
802 );
803 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 assert_eq!(CollectionPolicy::default().read_scope(&Actor::anonymous(), &empty), ReadScope::All);
834
835 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 assert_eq!(owner_read.read_scope(&Actor::anonymous(), &empty), ReadScope::Deny);
844
845 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 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")); map.insert("price".into(), json!("0")); 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 assert!(record_matches_filter(&rec, "org=other,org=acme"));
927 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 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(®, &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}