1use crate::matcher::PatternMatcher;
14use std::collections::{HashMap, HashSet};
15use vellaveto_types::{
16 is_unicode_format_char, AbacEffect, AbacEntity, AbacOp, AbacPolicy, Action, EvaluationContext,
17 RiskScore,
18};
19
20#[derive(Debug, Clone)]
28enum CompiledPathMatcher {
29 Simple(PatternMatcher),
31 Glob(globset::GlobMatcher),
33}
34
35impl CompiledPathMatcher {
36 fn compile(pattern: &str) -> Option<Self> {
46 let has_glob_meta = pattern.contains('*') || pattern.contains('?') || pattern.contains('[');
47
48 if !has_glob_meta {
49 return Some(CompiledPathMatcher::Simple(PatternMatcher::compile(
51 pattern,
52 )));
53 }
54
55 match globset::GlobBuilder::new(pattern)
58 .literal_separator(true)
59 .build()
60 {
61 Ok(glob) => Some(CompiledPathMatcher::Glob(glob.compile_matcher())),
62 Err(e) => {
63 tracing::error!(
64 pattern = pattern,
65 error = %e,
66 "ABAC path pattern failed to compile as glob — fail-closed (deny)"
67 );
68 None }
70 }
71 }
72
73 fn matches(&self, path: &str) -> bool {
75 match self {
76 CompiledPathMatcher::Simple(m) => m.matches(path),
77 CompiledPathMatcher::Glob(g) => g.is_match(path),
78 }
79 }
80}
81
82const MAX_MEMBERSHIP_DEPTH: usize = 16;
84
85#[derive(Debug, Clone, PartialEq)]
91#[non_exhaustive]
92#[must_use = "ABAC decisions must not be discarded"]
93pub enum AbacDecision {
94 Allow { policy_id: String },
96 Deny { policy_id: String, reason: String },
98 NoMatch,
100}
101
102#[derive(Debug, Clone)]
104pub struct AbacConflict {
105 pub permit_id: String,
106 pub forbid_id: String,
107 pub overlap_description: String,
108}
109
110pub struct AbacEvalContext<'a> {
116 pub eval_ctx: &'a EvaluationContext,
117 pub principal_type: &'a str,
118 pub principal_id: &'a str,
119 pub risk_score: Option<&'a RiskScore>,
120}
121
122struct CompiledPrincipal {
128 principal_type: Option<String>,
129 id_matchers: Vec<PatternMatcher>,
130 claims: Vec<(String, PatternMatcher)>,
131}
132
133struct CompiledAction {
135 matchers: Vec<(PatternMatcher, PatternMatcher)>,
137}
138
139struct CompiledResource {
146 path_matchers: Vec<CompiledPathMatcher>,
147 domain_matchers: Vec<PatternMatcher>,
148 tags: Vec<String>,
149 path_compile_failed: bool,
152}
153
154struct CompiledCondition {
156 field: String,
157 op: AbacOp,
158 value: serde_json::Value,
159}
160
161struct CompiledAbacPolicy {
163 id: String,
164 effect: AbacEffect,
165 priority: i32,
166 principal: CompiledPrincipal,
167 action: CompiledAction,
168 resource: CompiledResource,
169 conditions: Vec<CompiledCondition>,
170}
171
172pub struct EntityStore {
185 entities: HashMap<String, AbacEntity>,
187 memberships: HashMap<String, Vec<String>>,
189}
190
191impl EntityStore {
192 pub fn from_config(entities: &[AbacEntity]) -> Self {
198 let mut map = HashMap::new();
199 let mut memberships = HashMap::new();
200 for entity in entities {
201 let key = format!(
202 "{}::{}",
203 crate::normalize::normalize_full(&entity.entity_type),
204 crate::normalize::normalize_full(&entity.id)
205 );
206 let normalized_parents: Vec<String> = entity
207 .parents
208 .iter()
209 .map(|p| {
210 if let Some((t, i)) = p.split_once("::") {
211 format!(
212 "{}::{}",
213 crate::normalize::normalize_full(t),
214 crate::normalize::normalize_full(i)
215 )
216 } else {
217 crate::normalize::normalize_full(p)
218 }
219 })
220 .collect();
221 memberships.insert(key.clone(), normalized_parents);
222 map.insert(key, entity.clone());
223 }
224 Self {
225 entities: map,
226 memberships,
227 }
228 }
229
230 pub fn lookup(&self, entity_type: &str, id: &str) -> Option<&AbacEntity> {
234 let key = format!(
235 "{}::{}",
236 crate::normalize::normalize_full(entity_type),
237 crate::normalize::normalize_full(id)
238 );
239 self.entities.get(&key)
240 }
241
242 pub fn is_member_of(&self, entity_key: &str, group_key: &str) -> bool {
247 let normalized_entity_key = normalize_entity_key(entity_key);
248 let normalized_group_key = normalize_entity_key(group_key);
249 let mut visited = HashSet::new();
250 self.is_member_of_bounded(
251 &normalized_entity_key,
252 &normalized_group_key,
253 0,
254 &mut visited,
255 )
256 }
257
258 fn is_member_of_bounded(
259 &self,
260 entity_key: &str,
261 group_key: &str,
262 depth: usize,
263 visited: &mut HashSet<String>,
264 ) -> bool {
265 if depth >= MAX_MEMBERSHIP_DEPTH {
266 return false;
267 }
268 if !visited.insert(entity_key.to_string()) {
271 return false;
272 }
273 if let Some(parents) = self.memberships.get(entity_key) {
274 for parent in parents {
275 if parent == group_key {
276 return true;
277 }
278 if self.is_member_of_bounded(parent, group_key, depth + 1, visited) {
279 return true;
280 }
281 }
282 }
283 false
284 }
285}
286
287fn normalize_entity_key(key: &str) -> String {
288 if let Some((entity_type, id)) = key.split_once("::") {
289 format!(
290 "{}::{}",
291 crate::normalize::normalize_full(entity_type),
292 crate::normalize::normalize_full(id)
293 )
294 } else {
295 crate::normalize::normalize_full(key)
296 }
297}
298
299pub struct AbacEngine {
308 compiled: Vec<CompiledAbacPolicy>,
309 entity_store: EntityStore,
310}
311
312const MAX_ABAC_ENTITIES: usize = 10_000;
317
318impl AbacEngine {
319 pub fn new(policies: &[AbacPolicy], entities: &[AbacEntity]) -> Result<Self, String> {
324 let mut compiled = Vec::with_capacity(policies.len());
325 for policy in policies {
326 policy
329 .validate()
330 .map_err(|e| format!("ABAC policy '{}' validation failed: {e}", policy.id))?;
331 compiled.push(compile_policy(policy)?);
332 }
333 compiled.sort_by(|a, b| b.priority.cmp(&a.priority));
335
336 if entities.len() > MAX_ABAC_ENTITIES {
340 return Err(format!(
341 "ABAC entity count {} exceeds maximum {}",
342 entities.len(),
343 MAX_ABAC_ENTITIES
344 ));
345 }
346 for entity in entities {
347 entity.validate().map_err(|e| {
348 format!(
349 "ABAC entity '{}::{}' validation failed: {e}",
350 entity.entity_type, entity.id
351 )
352 })?;
353 }
354
355 let entity_store = EntityStore::from_config(entities);
356 Ok(Self {
357 compiled,
358 entity_store,
359 })
360 }
361
362 #[must_use = "ABAC decisions must not be discarded"]
371 pub fn evaluate(&self, action: &Action, ctx: &AbacEvalContext<'_>) -> AbacDecision {
372 let mut best_permit: Option<&str> = None;
373
374 for policy in &self.compiled {
375 if !matches_principal(&policy.principal, ctx, &self.entity_store) {
376 continue;
377 }
378 if !matches_action(&policy.action, action) {
379 continue;
380 }
381 if !matches_resource(&policy.resource, action) {
382 continue;
383 }
384 if !evaluate_conditions(&policy.conditions, ctx) {
385 continue;
386 }
387
388 match policy.effect {
389 AbacEffect::Forbid => {
390 return AbacDecision::Deny {
394 policy_id: policy.id.clone(),
395 reason: format!("ABAC forbid policy '{}' matched", policy.id),
396 };
397 }
398 AbacEffect::Permit => {
399 if best_permit.is_none() {
400 best_permit = Some(&policy.id);
401 }
402 }
403 }
404 }
405
406 if let Some(id) = best_permit {
407 return AbacDecision::Allow {
408 policy_id: id.to_string(),
409 };
410 }
411
412 AbacDecision::NoMatch
413 }
414
415 pub fn entity_store(&self) -> &EntityStore {
417 &self.entity_store
418 }
419
420 pub fn find_conflicts(&self) -> Vec<AbacConflict> {
422 let mut conflicts = Vec::new();
423 let permits: Vec<_> = self
424 .compiled
425 .iter()
426 .filter(|p| p.effect == AbacEffect::Permit)
427 .collect();
428 let forbids: Vec<_> = self
429 .compiled
430 .iter()
431 .filter(|p| p.effect == AbacEffect::Forbid)
432 .collect();
433
434 for permit in &permits {
435 for forbid in &forbids {
436 if action_patterns_overlap(&permit.action, &forbid.action) {
437 conflicts.push(AbacConflict {
438 permit_id: permit.id.clone(),
439 forbid_id: forbid.id.clone(),
440 overlap_description: format!(
441 "Permit '{}' and Forbid '{}' have overlapping action patterns",
442 permit.id, forbid.id
443 ),
444 });
445 }
446 }
447 }
448 conflicts
449 }
450
451 pub fn policy_count(&self) -> usize {
453 self.compiled.len()
454 }
455}
456
457const KNOWN_CONDITION_FIELDS: &[&str] = &[
463 "principal.type",
464 "principal.id",
465 "risk.score",
466 "context.agent_id",
467 "context.tenant_id",
468 "context.call_chain_depth",
469];
470
471fn compile_policy(policy: &AbacPolicy) -> Result<CompiledAbacPolicy, String> {
472 let principal = compile_principal(&policy.principal);
473 let action = compile_action(&policy.action);
474 let resource = compile_resource(&policy.resource)?;
475
476 let mut conditions = Vec::with_capacity(policy.conditions.len());
480 for c in &policy.conditions {
481 if c.field.is_empty() {
482 return Err(format!(
483 "ABAC policy '{}' has a condition with an empty field name",
484 policy.id
485 ));
486 }
487 if c.field
492 .chars()
493 .any(|ch| ch.is_control() || is_unicode_format_char(ch))
494 {
495 return Err(format!(
496 "ABAC policy '{}' has a condition with control or format characters in field name: {:?}",
497 policy.id,
498 c.field.escape_debug().to_string()
499 ));
500 }
501 if !KNOWN_CONDITION_FIELDS.contains(&c.field.as_str()) && !c.field.starts_with("claims.") {
502 tracing::warn!(
503 policy_id = %policy.id,
504 field = %c.field,
505 "ABAC condition references unknown field — will resolve to null at evaluation time"
506 );
507 }
508 if matches!(c.op, AbacOp::Gt | AbacOp::Lt | AbacOp::Gte | AbacOp::Lte) {
512 match c.value.as_f64() {
513 Some(v) if !v.is_finite() => {
514 return Err(format!(
515 "ABAC policy '{}' condition on field '{}' has non-finite numeric value",
516 policy.id, c.field
517 ));
518 }
519 None => {
520 return Err(format!(
521 "ABAC policy '{}' condition on field '{}' uses numeric operator {:?} but value is not a number",
522 policy.id, c.field, c.op
523 ));
524 }
525 _ => {} }
527 }
528 conditions.push(CompiledCondition {
529 field: c.field.clone(),
530 op: c.op,
531 value: c.value.clone(),
532 });
533 }
534
535 Ok(CompiledAbacPolicy {
536 id: policy.id.clone(),
537 effect: policy.effect,
538 priority: policy.priority,
539 principal,
540 action,
541 resource,
542 conditions,
543 })
544}
545
546fn compile_principal(pc: &vellaveto_types::PrincipalConstraint) -> CompiledPrincipal {
547 CompiledPrincipal {
548 principal_type: pc.principal_type.clone(),
549 id_matchers: pc
550 .id_patterns
551 .iter()
552 .map(|p| PatternMatcher::compile(p))
553 .collect(),
554 claims: pc
555 .claims
556 .iter()
557 .map(|(k, v)| (k.clone(), PatternMatcher::compile(v)))
558 .collect(),
559 }
560}
561
562fn compile_action(ac: &vellaveto_types::ActionConstraint) -> CompiledAction {
563 let matchers = ac
564 .patterns
565 .iter()
566 .map(|p| {
567 if let Some((tool, func)) = p.split_once(':') {
568 (PatternMatcher::compile(tool), PatternMatcher::compile(func))
569 } else {
570 (PatternMatcher::compile(p), PatternMatcher::compile("*"))
572 }
573 })
574 .collect();
575 CompiledAction { matchers }
576}
577
578fn compile_resource(rc: &vellaveto_types::ResourceConstraint) -> Result<CompiledResource, String> {
579 let mut path_matchers = Vec::with_capacity(rc.path_patterns.len());
580 let mut path_compile_failed = false;
581
582 for pattern in &rc.path_patterns {
583 match CompiledPathMatcher::compile(pattern) {
584 Some(m) => path_matchers.push(m),
585 None => {
586 path_compile_failed = true;
590 }
591 }
592 }
593
594 Ok(CompiledResource {
595 path_matchers,
596 domain_matchers: rc
597 .domain_patterns
598 .iter()
599 .map(|p| PatternMatcher::compile(p))
600 .collect(),
601 tags: rc.tags.clone(),
602 path_compile_failed,
603 })
604}
605
606fn matches_principal(
611 principal: &CompiledPrincipal,
612 ctx: &AbacEvalContext<'_>,
613 entity_store: &EntityStore,
614) -> bool {
615 if let Some(ref required_type) = principal.principal_type {
620 let norm_required = crate::normalize::normalize_full(required_type);
621 let norm_ctx_type = crate::normalize::normalize_full(ctx.principal_type);
622 if norm_required != norm_ctx_type {
623 let entity_key = format!(
627 "{}::{}",
628 crate::normalize::normalize_full(ctx.principal_type),
629 crate::normalize::normalize_full(ctx.principal_id)
630 );
631 let group_key = format!(
632 "{}::{}",
633 crate::normalize::normalize_full(required_type),
634 crate::normalize::normalize_full(ctx.principal_id)
635 );
636 if entity_key != group_key && !entity_store.is_member_of(&entity_key, &group_key) {
637 return false;
638 }
639 }
640 }
641
642 if !principal.id_matchers.is_empty() {
648 let norm_id = crate::normalize::normalize_full(ctx.principal_id);
649 if !principal.id_matchers.iter().any(|m| m.matches(&norm_id)) {
650 return false;
651 }
652 }
653
654 for (claim_key, pattern) in &principal.claims {
656 let claim_value = match ctx
660 .eval_ctx
661 .agent_identity
662 .as_ref()
663 .and_then(|id| id.claims.get(claim_key))
664 {
665 Some(v) => v.as_str().unwrap_or(""),
666 None => return false,
667 };
668 let norm_claim = crate::normalize::normalize_full(claim_value);
672 if !pattern.matches(&norm_claim) {
673 return false;
674 }
675 }
676
677 true
678}
679
680fn matches_action(action_constraint: &CompiledAction, action: &Action) -> bool {
681 if action_constraint.matchers.is_empty() {
683 return true;
684 }
685 let norm_tool = crate::normalize::normalize_full(&action.tool);
690 let norm_func = crate::normalize::normalize_full(&action.function);
691 action_constraint.matchers.iter().any(|(tool_m, func_m)| {
692 tool_m.matches_normalized(&norm_tool) && func_m.matches_normalized(&norm_func)
693 })
694}
695
696fn matches_resource(resource: &CompiledResource, action: &Action) -> bool {
697 if resource.path_compile_failed {
700 return false;
701 }
702
703 if !resource.path_matchers.is_empty() {
707 if action.target_paths.is_empty() {
708 return false;
709 }
710 let any_path_matches = action.target_paths.iter().any(|path| {
711 let normalized = match crate::path::normalize_path_bounded(
721 path,
722 crate::path::DEFAULT_MAX_PATH_DECODE_ITERATIONS,
723 ) {
724 Ok(n) => n,
725 Err(e) => {
726 tracing::warn!(
727 path = %path,
728 error = %e,
729 "ABAC resource match: path normalization failed — skipping path (fail-closed)"
730 );
731 return false;
732 }
733 };
734 resource
735 .path_matchers
736 .iter()
737 .any(|m| m.matches(&normalized))
738 });
739 if !any_path_matches {
740 return false;
741 }
742 }
743
744 if !resource.domain_matchers.is_empty() {
751 if action.target_domains.is_empty() {
752 return false;
753 }
754 let any_domain_matches = action.target_domains.iter().any(|domain| {
755 let normalized = match crate::domain::normalize_domain_for_match(domain) {
756 Some(cow) => cow.into_owned(),
757 None => {
758 tracing::warn!(
760 domain = %domain,
761 "ABAC resource domain match: domain failed IDNA normalization — fail-closed"
762 );
763 return false;
764 }
765 };
766 resource
767 .domain_matchers
768 .iter()
769 .any(|m| m.matches(&normalized))
770 });
771 if !any_domain_matches {
772 return false;
773 }
774 }
775
776 if !resource.tags.is_empty() {
779 if let Some(params) = action.parameters.as_object() {
780 for tag in &resource.tags {
781 if !params.contains_key(tag) {
782 return false;
783 }
784 }
785 } else {
786 return false;
787 }
788 }
789
790 true
791}
792
793fn evaluate_conditions(conditions: &[CompiledCondition], ctx: &AbacEvalContext<'_>) -> bool {
794 if conditions.is_empty() {
799 tracing::trace!("ABAC policy has empty conditions array — matches unconditionally");
800 }
801 conditions.iter().all(|c| evaluate_single_condition(c, ctx))
802}
803
804fn evaluate_single_condition(condition: &CompiledCondition, ctx: &AbacEvalContext<'_>) -> bool {
805 let field_value = resolve_field(&condition.field, ctx);
806 match condition.op {
807 AbacOp::Eq => match (field_value.as_str(), condition.value.as_str()) {
811 (Some(a), Some(b)) => {
812 crate::normalize::normalize_full(a) == crate::normalize::normalize_full(b)
813 }
814 _ => field_value == condition.value,
815 },
816 AbacOp::Ne => match (field_value.as_str(), condition.value.as_str()) {
817 (Some(a), Some(b)) => {
818 crate::normalize::normalize_full(a) != crate::normalize::normalize_full(b)
819 }
820 _ => field_value != condition.value,
821 },
822 AbacOp::In => {
823 if let Some(arr) = condition.value.as_array() {
824 if let Some(fv_str) = field_value.as_str() {
825 let norm_fv = crate::normalize::normalize_full(fv_str);
826 arr.iter().any(|v| {
827 v.as_str()
828 .map(|s| crate::normalize::normalize_full(s) == norm_fv)
829 .unwrap_or_else(|| v == &field_value)
830 })
831 } else {
832 arr.contains(&field_value)
833 }
834 } else {
835 false
836 }
837 }
838 AbacOp::NotIn => {
839 if let Some(arr) = condition.value.as_array() {
840 if let Some(fv_str) = field_value.as_str() {
841 let norm_fv = crate::normalize::normalize_full(fv_str);
842 !arr.iter().any(|v| {
843 v.as_str()
844 .map(|s| crate::normalize::normalize_full(s) == norm_fv)
845 .unwrap_or_else(|| v == &field_value)
846 })
847 } else {
848 !arr.contains(&field_value)
849 }
850 } else {
851 false
857 }
858 }
859 AbacOp::Contains => {
860 if let (Some(haystack), Some(needle)) = (field_value.as_str(), condition.value.as_str())
861 {
862 let norm_h = crate::normalize::normalize_full(haystack);
863 let norm_n = crate::normalize::normalize_full(needle);
864 norm_h.contains(&norm_n)
865 } else {
866 false
867 }
868 }
869 AbacOp::StartsWith => {
870 if let (Some(s), Some(prefix)) = (field_value.as_str(), condition.value.as_str()) {
871 let norm_s = crate::normalize::normalize_full(s);
872 let norm_p = crate::normalize::normalize_full(prefix);
873 norm_s.starts_with(&norm_p)
874 } else {
875 false
876 }
877 }
878 AbacOp::Gt => compare_numbers(&field_value, &condition.value, |a, b| a > b),
879 AbacOp::Lt => compare_numbers(&field_value, &condition.value, |a, b| a < b),
880 AbacOp::Gte => compare_numbers(&field_value, &condition.value, |a, b| a >= b),
881 AbacOp::Lte => compare_numbers(&field_value, &condition.value, |a, b| a <= b),
882 }
883}
884
885fn compare_numbers(
886 a: &serde_json::Value,
887 b: &serde_json::Value,
888 cmp: fn(f64, f64) -> bool,
889) -> bool {
890 match (a.as_f64(), b.as_f64()) {
891 (Some(av), Some(bv)) if av.is_finite() && bv.is_finite() => cmp(av, bv),
895 _ => false,
896 }
897}
898
899fn resolve_field(field: &str, ctx: &AbacEvalContext<'_>) -> serde_json::Value {
900 match field {
901 "principal.type" => serde_json::Value::String(ctx.principal_type.to_string()),
902 "principal.id" => serde_json::Value::String(ctx.principal_id.to_string()),
903 "risk.score" => ctx
907 .risk_score
908 .map(|r| {
909 if r.score.is_finite() {
910 serde_json::json!(r.score)
911 } else {
912 tracing::warn!(
913 "ABAC resolve_field: risk.score is non-finite ({}) — treating as max risk",
914 r.score
915 );
916 serde_json::json!(1.0)
917 }
918 })
919 .unwrap_or(serde_json::Value::Null),
920 "context.agent_id" => ctx
921 .eval_ctx
922 .agent_id
923 .as_ref()
924 .map(|s| serde_json::Value::String(s.clone()))
925 .unwrap_or(serde_json::Value::Null),
926 "context.tenant_id" => ctx
927 .eval_ctx
928 .tenant_id
929 .as_ref()
930 .map(|s| serde_json::Value::String(s.clone()))
931 .unwrap_or(serde_json::Value::Null),
932 "context.call_chain_depth" => {
933 serde_json::json!(ctx.eval_ctx.call_chain_depth())
934 }
935 _ => {
936 if let Some(claim_key) = field.strip_prefix("claims.") {
938 ctx.eval_ctx
939 .agent_identity
940 .as_ref()
941 .and_then(|id| id.claims.get(claim_key).cloned())
942 .unwrap_or(serde_json::Value::Null)
943 } else {
944 serde_json::Value::Null
945 }
946 }
947 }
948}
949
950fn action_patterns_overlap(a: &CompiledAction, b: &CompiledAction) -> bool {
952 if a.matchers.is_empty() || b.matchers.is_empty() {
954 return true;
955 }
956 for (at, af) in &a.matchers {
958 for (bt, bf) in &b.matchers {
959 if patterns_could_overlap(at, bt) && patterns_could_overlap(af, bf) {
960 return true;
961 }
962 }
963 }
964 false
965}
966
967fn patterns_could_overlap(a: &PatternMatcher, b: &PatternMatcher) -> bool {
969 match (a, b) {
970 (PatternMatcher::Any, _) | (_, PatternMatcher::Any) => true,
971 (PatternMatcher::Exact(x), PatternMatcher::Exact(y)) => x == y,
972 (PatternMatcher::Exact(e), PatternMatcher::Prefix(p))
973 | (PatternMatcher::Prefix(p), PatternMatcher::Exact(e)) => e.starts_with(p.as_str()),
974 (PatternMatcher::Exact(e), PatternMatcher::Suffix(s))
975 | (PatternMatcher::Suffix(s), PatternMatcher::Exact(e)) => e.ends_with(s.as_str()),
976 _ => true,
978 }
979}
980
981#[cfg(test)]
986mod tests {
987 use super::*;
988 use vellaveto_types::*;
989
990 fn make_action(tool: &str, function: &str) -> Action {
991 Action {
992 tool: tool.to_string(),
993 function: function.to_string(),
994 parameters: serde_json::json!({}),
995 target_paths: Vec::new(),
996 target_domains: Vec::new(),
997 resolved_ips: Vec::new(),
998 }
999 }
1000
1001 fn make_action_with_paths(tool: &str, function: &str, paths: Vec<&str>) -> Action {
1002 Action {
1003 tool: tool.to_string(),
1004 function: function.to_string(),
1005 parameters: serde_json::json!({}),
1006 target_paths: paths.into_iter().map(String::from).collect(),
1007 target_domains: Vec::new(),
1008 resolved_ips: Vec::new(),
1009 }
1010 }
1011
1012 fn make_action_with_domains(tool: &str, function: &str, domains: Vec<&str>) -> Action {
1013 Action {
1014 tool: tool.to_string(),
1015 function: function.to_string(),
1016 parameters: serde_json::json!({}),
1017 target_paths: Vec::new(),
1018 target_domains: domains.into_iter().map(String::from).collect(),
1019 resolved_ips: Vec::new(),
1020 }
1021 }
1022
1023 fn make_permit_policy(id: &str, tool_pattern: &str) -> AbacPolicy {
1024 AbacPolicy {
1025 id: id.to_string(),
1026 description: format!("Permit {id}"),
1027 effect: AbacEffect::Permit,
1028 priority: 0,
1029 principal: Default::default(),
1030 action: ActionConstraint {
1031 patterns: vec![tool_pattern.to_string()],
1032 },
1033 resource: Default::default(),
1034 conditions: vec![],
1035 }
1036 }
1037
1038 fn make_forbid_policy(id: &str, tool_pattern: &str) -> AbacPolicy {
1039 AbacPolicy {
1040 id: id.to_string(),
1041 description: format!("Forbid {id}"),
1042 effect: AbacEffect::Forbid,
1043 priority: 0,
1044 principal: Default::default(),
1045 action: ActionConstraint {
1046 patterns: vec![tool_pattern.to_string()],
1047 },
1048 resource: Default::default(),
1049 conditions: vec![],
1050 }
1051 }
1052
1053 fn make_ctx<'a>(
1054 eval_ctx: &'a EvaluationContext,
1055 principal_type: &'a str,
1056 principal_id: &'a str,
1057 ) -> AbacEvalContext<'a> {
1058 AbacEvalContext {
1059 eval_ctx,
1060 principal_type,
1061 principal_id,
1062 risk_score: None,
1063 }
1064 }
1065
1066 fn make_engine(policies: Vec<AbacPolicy>) -> AbacEngine {
1067 AbacEngine::new(&policies, &[]).unwrap()
1068 }
1069
1070 #[test]
1071 fn test_compile_valid_policies() {
1072 let engine = make_engine(vec![make_permit_policy("p1", "filesystem:read*")]);
1073 assert_eq!(engine.policy_count(), 1);
1074 }
1075
1076 #[test]
1077 fn test_compile_empty_policies() {
1078 let engine = make_engine(vec![]);
1079 assert_eq!(engine.policy_count(), 0);
1080 }
1081
1082 #[test]
1083 fn test_evaluate_permit_matches() {
1084 let engine = make_engine(vec![make_permit_policy("p1", "filesystem:read*")]);
1085 let eval_ctx = EvaluationContext::default();
1086 let ctx = make_ctx(&eval_ctx, "Agent", "test-agent");
1087 let action = make_action("filesystem", "read_file");
1088
1089 match engine.evaluate(&action, &ctx) {
1090 AbacDecision::Allow { policy_id } => assert_eq!(policy_id, "p1"),
1091 other => panic!("Expected Allow, got {other:?}"),
1092 }
1093 }
1094
1095 #[test]
1096 fn test_evaluate_forbid_overrides_permit() {
1097 let engine = make_engine(vec![
1098 make_permit_policy("permit-all", "*:*"),
1099 make_forbid_policy("forbid-bash", "bash:*"),
1100 ]);
1101 let eval_ctx = EvaluationContext::default();
1102 let ctx = make_ctx(&eval_ctx, "Agent", "test");
1103 let action = make_action("bash", "execute");
1104
1105 match engine.evaluate(&action, &ctx) {
1106 AbacDecision::Deny { policy_id, .. } => assert_eq!(policy_id, "forbid-bash"),
1107 other => panic!("Expected Deny, got {other:?}"),
1108 }
1109 }
1110
1111 #[test]
1112 fn test_evaluate_no_match_returns_nomatch() {
1113 let engine = make_engine(vec![make_permit_policy("p1", "filesystem:read*")]);
1114 let eval_ctx = EvaluationContext::default();
1115 let ctx = make_ctx(&eval_ctx, "Agent", "test");
1116 let action = make_action("network", "fetch");
1117
1118 assert_eq!(engine.evaluate(&action, &ctx), AbacDecision::NoMatch);
1119 }
1120
1121 #[test]
1122 fn test_evaluate_principal_type_match() {
1123 let policy = AbacPolicy {
1124 id: "p1".to_string(),
1125 description: "Only for Agents".to_string(),
1126 effect: AbacEffect::Permit,
1127 priority: 0,
1128 principal: PrincipalConstraint {
1129 principal_type: Some("Agent".to_string()),
1130 id_patterns: vec![],
1131 claims: HashMap::new(),
1132 },
1133 action: ActionConstraint {
1134 patterns: vec!["*:*".to_string()],
1135 },
1136 resource: Default::default(),
1137 conditions: vec![],
1138 };
1139 let engine = make_engine(vec![policy]);
1140 let eval_ctx = EvaluationContext::default();
1141
1142 let ctx = make_ctx(&eval_ctx, "Agent", "test");
1144 assert!(matches!(
1145 engine.evaluate(&make_action("any", "any"), &ctx),
1146 AbacDecision::Allow { .. }
1147 ));
1148
1149 let ctx = make_ctx(&eval_ctx, "Service", "test");
1151 assert_eq!(
1152 engine.evaluate(&make_action("any", "any"), &ctx),
1153 AbacDecision::NoMatch
1154 );
1155 }
1156
1157 #[test]
1158 fn test_evaluate_principal_id_glob() {
1159 let policy = AbacPolicy {
1160 id: "p1".to_string(),
1161 description: "code-* agents".to_string(),
1162 effect: AbacEffect::Permit,
1163 priority: 0,
1164 principal: PrincipalConstraint {
1165 principal_type: None,
1166 id_patterns: vec!["code-*".to_string()],
1167 claims: HashMap::new(),
1168 },
1169 action: Default::default(),
1170 resource: Default::default(),
1171 conditions: vec![],
1172 };
1173 let engine = make_engine(vec![policy]);
1174 let eval_ctx = EvaluationContext::default();
1175
1176 let ctx = make_ctx(&eval_ctx, "Agent", "code-assistant");
1177 assert!(matches!(
1178 engine.evaluate(&make_action("any", "any"), &ctx),
1179 AbacDecision::Allow { .. }
1180 ));
1181
1182 let ctx = make_ctx(&eval_ctx, "Agent", "data-pipeline");
1183 assert_eq!(
1184 engine.evaluate(&make_action("any", "any"), &ctx),
1185 AbacDecision::NoMatch
1186 );
1187 }
1188
1189 #[test]
1190 fn test_evaluate_principal_claims_match() {
1191 let mut claims = HashMap::new();
1192 claims.insert("team".to_string(), "security*".to_string());
1193
1194 let policy = AbacPolicy {
1195 id: "p1".to_string(),
1196 description: "security team".to_string(),
1197 effect: AbacEffect::Permit,
1198 priority: 0,
1199 principal: PrincipalConstraint {
1200 principal_type: None,
1201 id_patterns: vec![],
1202 claims,
1203 },
1204 action: Default::default(),
1205 resource: Default::default(),
1206 conditions: vec![],
1207 };
1208 let engine = make_engine(vec![policy]);
1209
1210 let mut identity_claims = HashMap::new();
1212 identity_claims.insert("team".to_string(), serde_json::json!("security-ops"));
1213 let eval_ctx = EvaluationContext {
1214 agent_identity: Some(AgentIdentity {
1215 claims: identity_claims,
1216 ..Default::default()
1217 }),
1218 ..Default::default()
1219 };
1220 let ctx = make_ctx(&eval_ctx, "Agent", "test");
1221 assert!(matches!(
1222 engine.evaluate(&make_action("any", "any"), &ctx),
1223 AbacDecision::Allow { .. }
1224 ));
1225
1226 let mut identity_claims = HashMap::new();
1228 identity_claims.insert("team".to_string(), serde_json::json!("engineering"));
1229 let eval_ctx = EvaluationContext {
1230 agent_identity: Some(AgentIdentity {
1231 claims: identity_claims,
1232 ..Default::default()
1233 }),
1234 ..Default::default()
1235 };
1236 let ctx = make_ctx(&eval_ctx, "Agent", "test");
1237 assert_eq!(
1238 engine.evaluate(&make_action("any", "any"), &ctx),
1239 AbacDecision::NoMatch
1240 );
1241 }
1242
1243 #[test]
1244 fn test_evaluate_action_tool_function_match() {
1245 let engine = make_engine(vec![make_permit_policy("p1", "filesystem:write_file")]);
1246 let eval_ctx = EvaluationContext::default();
1247 let ctx = make_ctx(&eval_ctx, "Agent", "test");
1248
1249 assert!(matches!(
1251 engine.evaluate(&make_action("filesystem", "write_file"), &ctx),
1252 AbacDecision::Allow { .. }
1253 ));
1254 assert_eq!(
1256 engine.evaluate(&make_action("filesystem", "read_file"), &ctx),
1257 AbacDecision::NoMatch
1258 );
1259 }
1260
1261 #[test]
1262 fn test_evaluate_action_wildcard() {
1263 let engine = make_engine(vec![make_permit_policy("p1", "*:*")]);
1264 let eval_ctx = EvaluationContext::default();
1265 let ctx = make_ctx(&eval_ctx, "Agent", "test");
1266
1267 assert!(matches!(
1268 engine.evaluate(&make_action("anything", "anything"), &ctx),
1269 AbacDecision::Allow { .. }
1270 ));
1271 }
1272
1273 #[test]
1274 fn test_evaluate_resource_path_match() {
1275 let policy = AbacPolicy {
1278 id: "p1".to_string(),
1279 description: "home dir only".to_string(),
1280 effect: AbacEffect::Permit,
1281 priority: 0,
1282 principal: Default::default(),
1283 action: Default::default(),
1284 resource: ResourceConstraint {
1285 path_patterns: vec!["/home/**".to_string()],
1286 domain_patterns: vec![],
1287 tags: vec![],
1288 },
1289 conditions: vec![],
1290 };
1291 let engine = make_engine(vec![policy]);
1292 let eval_ctx = EvaluationContext::default();
1293 let ctx = make_ctx(&eval_ctx, "Agent", "test");
1294
1295 let action = make_action_with_paths("fs", "read", vec!["/home/user/file.txt"]);
1297 assert!(matches!(
1298 engine.evaluate(&action, &ctx),
1299 AbacDecision::Allow { .. }
1300 ));
1301
1302 let action = make_action_with_paths("fs", "read", vec!["/etc/passwd"]);
1304 assert_eq!(engine.evaluate(&action, &ctx), AbacDecision::NoMatch);
1305
1306 let action = make_action("fs", "read");
1308 assert_eq!(engine.evaluate(&action, &ctx), AbacDecision::NoMatch);
1309 }
1310
1311 #[test]
1312 fn test_evaluate_resource_domain_match() {
1313 let policy = AbacPolicy {
1314 id: "p1".to_string(),
1315 description: "example.com only".to_string(),
1316 effect: AbacEffect::Permit,
1317 priority: 0,
1318 principal: Default::default(),
1319 action: Default::default(),
1320 resource: ResourceConstraint {
1321 path_patterns: vec![],
1322 domain_patterns: vec!["*example.com".to_string()],
1323 tags: vec![],
1324 },
1325 conditions: vec![],
1326 };
1327 let engine = make_engine(vec![policy]);
1328 let eval_ctx = EvaluationContext::default();
1329 let ctx = make_ctx(&eval_ctx, "Agent", "test");
1330
1331 let action = make_action_with_domains("net", "fetch", vec!["api.example.com"]);
1332 assert!(matches!(
1333 engine.evaluate(&action, &ctx),
1334 AbacDecision::Allow { .. }
1335 ));
1336 }
1337
1338 #[test]
1339 fn test_evaluate_resource_tags_match() {
1340 let policy = AbacPolicy {
1341 id: "p1".to_string(),
1342 description: "tagged resources".to_string(),
1343 effect: AbacEffect::Permit,
1344 priority: 0,
1345 principal: Default::default(),
1346 action: Default::default(),
1347 resource: ResourceConstraint {
1348 path_patterns: vec![],
1349 domain_patterns: vec![],
1350 tags: vec!["sensitive".to_string()],
1351 },
1352 conditions: vec![],
1353 };
1354 let engine = make_engine(vec![policy]);
1355 let eval_ctx = EvaluationContext::default();
1356 let ctx = make_ctx(&eval_ctx, "Agent", "test");
1357
1358 let mut action = make_action("fs", "read");
1359 action.parameters = serde_json::json!({"sensitive": true, "path": "/tmp"});
1360 assert!(matches!(
1361 engine.evaluate(&action, &ctx),
1362 AbacDecision::Allow { .. }
1363 ));
1364
1365 let mut action = make_action("fs", "read");
1367 action.parameters = serde_json::json!({"path": "/tmp"});
1368 assert_eq!(engine.evaluate(&action, &ctx), AbacDecision::NoMatch);
1369 }
1370
1371 #[test]
1372 fn test_evaluate_condition_eq() {
1373 let policy = AbacPolicy {
1374 id: "p1".to_string(),
1375 description: "tenant check".to_string(),
1376 effect: AbacEffect::Permit,
1377 priority: 0,
1378 principal: Default::default(),
1379 action: Default::default(),
1380 resource: Default::default(),
1381 conditions: vec![AbacCondition {
1382 field: "context.tenant_id".to_string(),
1383 op: AbacOp::Eq,
1384 value: serde_json::json!("acme"),
1385 }],
1386 };
1387 let engine = make_engine(vec![policy]);
1388
1389 let eval_ctx = EvaluationContext {
1390 tenant_id: Some("acme".to_string()),
1391 ..Default::default()
1392 };
1393 let ctx = make_ctx(&eval_ctx, "Agent", "test");
1394 assert!(matches!(
1395 engine.evaluate(&make_action("any", "any"), &ctx),
1396 AbacDecision::Allow { .. }
1397 ));
1398
1399 let eval_ctx = EvaluationContext {
1400 tenant_id: Some("other".to_string()),
1401 ..Default::default()
1402 };
1403 let ctx = make_ctx(&eval_ctx, "Agent", "test");
1404 assert_eq!(
1405 engine.evaluate(&make_action("any", "any"), &ctx),
1406 AbacDecision::NoMatch
1407 );
1408 }
1409
1410 #[test]
1411 fn test_evaluate_condition_in() {
1412 let policy = AbacPolicy {
1413 id: "p1".to_string(),
1414 description: "tenant in list".to_string(),
1415 effect: AbacEffect::Permit,
1416 priority: 0,
1417 principal: Default::default(),
1418 action: Default::default(),
1419 resource: Default::default(),
1420 conditions: vec![AbacCondition {
1421 field: "context.tenant_id".to_string(),
1422 op: AbacOp::In,
1423 value: serde_json::json!(["acme", "globex"]),
1424 }],
1425 };
1426 let engine = make_engine(vec![policy]);
1427
1428 let eval_ctx = EvaluationContext {
1429 tenant_id: Some("acme".to_string()),
1430 ..Default::default()
1431 };
1432 let ctx = make_ctx(&eval_ctx, "Agent", "test");
1433 assert!(matches!(
1434 engine.evaluate(&make_action("any", "any"), &ctx),
1435 AbacDecision::Allow { .. }
1436 ));
1437 }
1438
1439 #[test]
1440 fn test_evaluate_condition_starts_with() {
1441 let policy = AbacPolicy {
1442 id: "p1".to_string(),
1443 description: "agent prefix".to_string(),
1444 effect: AbacEffect::Permit,
1445 priority: 0,
1446 principal: Default::default(),
1447 action: Default::default(),
1448 resource: Default::default(),
1449 conditions: vec![AbacCondition {
1450 field: "context.agent_id".to_string(),
1451 op: AbacOp::StartsWith,
1452 value: serde_json::json!("prod-"),
1453 }],
1454 };
1455 let engine = make_engine(vec![policy]);
1456
1457 let eval_ctx = EvaluationContext {
1458 agent_id: Some("prod-agent-1".to_string()),
1459 ..Default::default()
1460 };
1461 let ctx = make_ctx(&eval_ctx, "Agent", "test");
1462 assert!(matches!(
1463 engine.evaluate(&make_action("any", "any"), &ctx),
1464 AbacDecision::Allow { .. }
1465 ));
1466 }
1467
1468 #[test]
1469 fn test_evaluate_condition_gt_lt() {
1470 let policy = AbacPolicy {
1471 id: "p1".to_string(),
1472 description: "low risk only".to_string(),
1473 effect: AbacEffect::Permit,
1474 priority: 0,
1475 principal: Default::default(),
1476 action: Default::default(),
1477 resource: Default::default(),
1478 conditions: vec![AbacCondition {
1479 field: "risk.score".to_string(),
1480 op: AbacOp::Lt,
1481 value: serde_json::json!(0.5),
1482 }],
1483 };
1484 let engine = make_engine(vec![policy]);
1485
1486 let risk = RiskScore {
1487 score: 0.3,
1488 factors: vec![],
1489 updated_at: "2026-02-14T00:00:00Z".to_string(),
1490 };
1491 let eval_ctx = EvaluationContext::default();
1492 let ctx = AbacEvalContext {
1493 eval_ctx: &eval_ctx,
1494 principal_type: "Agent",
1495 principal_id: "test",
1496 risk_score: Some(&risk),
1497 };
1498 assert!(matches!(
1499 engine.evaluate(&make_action("any", "any"), &ctx),
1500 AbacDecision::Allow { .. }
1501 ));
1502
1503 let risk = RiskScore {
1504 score: 0.8,
1505 factors: vec![],
1506 updated_at: "2026-02-14T00:00:00Z".to_string(),
1507 };
1508 let ctx = AbacEvalContext {
1509 eval_ctx: &eval_ctx,
1510 principal_type: "Agent",
1511 principal_id: "test",
1512 risk_score: Some(&risk),
1513 };
1514 assert_eq!(
1515 engine.evaluate(&make_action("any", "any"), &ctx),
1516 AbacDecision::NoMatch
1517 );
1518 }
1519
1520 #[test]
1521 fn test_evaluate_multiple_conditions_all_must_pass() {
1522 let policy = AbacPolicy {
1523 id: "p1".to_string(),
1524 description: "both conditions".to_string(),
1525 effect: AbacEffect::Permit,
1526 priority: 0,
1527 principal: Default::default(),
1528 action: Default::default(),
1529 resource: Default::default(),
1530 conditions: vec![
1531 AbacCondition {
1532 field: "context.tenant_id".to_string(),
1533 op: AbacOp::Eq,
1534 value: serde_json::json!("acme"),
1535 },
1536 AbacCondition {
1537 field: "context.agent_id".to_string(),
1538 op: AbacOp::Eq,
1539 value: serde_json::json!("agent-1"),
1540 },
1541 ],
1542 };
1543 let engine = make_engine(vec![policy]);
1544
1545 let eval_ctx = EvaluationContext {
1547 tenant_id: Some("acme".to_string()),
1548 agent_id: Some("agent-1".to_string()),
1549 ..Default::default()
1550 };
1551 let ctx = make_ctx(&eval_ctx, "Agent", "test");
1552 assert!(matches!(
1553 engine.evaluate(&make_action("any", "any"), &ctx),
1554 AbacDecision::Allow { .. }
1555 ));
1556
1557 let eval_ctx = EvaluationContext {
1559 tenant_id: Some("acme".to_string()),
1560 agent_id: Some("agent-2".to_string()),
1561 ..Default::default()
1562 };
1563 let ctx = make_ctx(&eval_ctx, "Agent", "test");
1564 assert_eq!(
1565 engine.evaluate(&make_action("any", "any"), &ctx),
1566 AbacDecision::NoMatch
1567 );
1568 }
1569
1570 #[test]
1571 fn test_evaluate_priority_ordering() {
1572 let engine = make_engine(vec![
1575 AbacPolicy {
1576 id: "high-permit".to_string(),
1577 description: "high priority permit".to_string(),
1578 effect: AbacEffect::Permit,
1579 priority: 100,
1580 principal: Default::default(),
1581 action: ActionConstraint {
1582 patterns: vec!["*:*".to_string()],
1583 },
1584 resource: Default::default(),
1585 conditions: vec![],
1586 },
1587 AbacPolicy {
1588 id: "low-forbid".to_string(),
1589 description: "low priority forbid".to_string(),
1590 effect: AbacEffect::Forbid,
1591 priority: 1,
1592 principal: Default::default(),
1593 action: ActionConstraint {
1594 patterns: vec!["*:*".to_string()],
1595 },
1596 resource: Default::default(),
1597 conditions: vec![],
1598 },
1599 ]);
1600 let eval_ctx = EvaluationContext::default();
1601 let ctx = make_ctx(&eval_ctx, "Agent", "test");
1602
1603 match engine.evaluate(&make_action("any", "any"), &ctx) {
1605 AbacDecision::Deny { policy_id, .. } => assert_eq!(policy_id, "low-forbid"),
1606 other => panic!("Expected Deny, got {other:?}"),
1607 }
1608 }
1609
1610 #[test]
1611 fn test_evaluate_fail_closed_missing_principal() {
1612 let policy = AbacPolicy {
1614 id: "p1".to_string(),
1615 description: "admins only".to_string(),
1616 effect: AbacEffect::Permit,
1617 priority: 0,
1618 principal: PrincipalConstraint {
1619 principal_type: Some("Admin".to_string()),
1620 id_patterns: vec![],
1621 claims: HashMap::new(),
1622 },
1623 action: Default::default(),
1624 resource: Default::default(),
1625 conditions: vec![],
1626 };
1627 let engine = make_engine(vec![policy]);
1628 let eval_ctx = EvaluationContext::default();
1629 let ctx = make_ctx(&eval_ctx, "Agent", "anonymous");
1630
1631 assert_eq!(
1633 engine.evaluate(&make_action("any", "any"), &ctx),
1634 AbacDecision::NoMatch
1635 );
1636 }
1637
1638 #[test]
1639 fn test_entity_store_lookup() {
1640 let entities = vec![AbacEntity {
1641 entity_type: "Agent".to_string(),
1642 id: "agent-1".to_string(),
1643 attributes: HashMap::new(),
1644 parents: vec![],
1645 }];
1646 let store = EntityStore::from_config(&entities);
1647 assert!(store.lookup("Agent", "agent-1").is_some());
1648 assert!(store.lookup("Agent", "nonexistent").is_none());
1649 }
1650
1651 #[test]
1652 fn test_entity_store_lookup_normalizes_unicode_keys() {
1653 let entities = vec![AbacEntity {
1654 entity_type: "Agent".to_string(),
1655 id: "admin".to_string(),
1656 attributes: HashMap::new(),
1657 parents: vec![],
1658 }];
1659 let store = EntityStore::from_config(&entities);
1660
1661 assert!(
1662 store.lookup("Agent", "аdmin").is_some(),
1663 "lookup should normalize fullwidth type and Cyrillic-homoglyph ID"
1664 );
1665 }
1666
1667 #[test]
1668 fn test_entity_store_memberships_normalize_parent_keys() {
1669 let entities = vec![
1670 AbacEntity {
1671 entity_type: "Group".to_string(),
1672 id: "admins".to_string(),
1673 attributes: HashMap::new(),
1674 parents: vec![],
1675 },
1676 AbacEntity {
1677 entity_type: "Agent".to_string(),
1678 id: "admin".to_string(),
1679 attributes: HashMap::new(),
1680 parents: vec!["Group::аdmins".to_string()],
1681 },
1682 ];
1683 let store = EntityStore::from_config(&entities);
1684
1685 let entity_key = format!(
1686 "{}::{}",
1687 crate::normalize::normalize_full("Agent"),
1688 crate::normalize::normalize_full("admin")
1689 );
1690 let group_key = format!(
1691 "{}::{}",
1692 crate::normalize::normalize_full("Group"),
1693 crate::normalize::normalize_full("admins")
1694 );
1695
1696 assert!(
1697 store.is_member_of(&entity_key, &group_key),
1698 "membership edges should normalize stored parent keys"
1699 );
1700 }
1701
1702 #[test]
1703 fn test_entity_store_group_membership() {
1704 let entities = vec![
1705 AbacEntity {
1706 entity_type: "Group".to_string(),
1707 id: "admins".to_string(),
1708 attributes: HashMap::new(),
1709 parents: vec![],
1710 },
1711 AbacEntity {
1712 entity_type: "Agent".to_string(),
1713 id: "agent-1".to_string(),
1714 attributes: HashMap::new(),
1715 parents: vec!["Group::admins".to_string()],
1716 },
1717 ];
1718 let store = EntityStore::from_config(&entities);
1719 assert!(store.is_member_of("Agent::agent-1", "Group::admins"));
1720 assert!(!store.is_member_of("Agent::agent-1", "Group::operators"));
1721 }
1722
1723 #[test]
1724 fn test_entity_store_transitive_membership_bounded() {
1725 let entities = vec![
1727 AbacEntity {
1728 entity_type: "G".to_string(),
1729 id: "d".to_string(),
1730 attributes: HashMap::new(),
1731 parents: vec![],
1732 },
1733 AbacEntity {
1734 entity_type: "G".to_string(),
1735 id: "c".to_string(),
1736 attributes: HashMap::new(),
1737 parents: vec!["G::d".to_string()],
1738 },
1739 AbacEntity {
1740 entity_type: "G".to_string(),
1741 id: "b".to_string(),
1742 attributes: HashMap::new(),
1743 parents: vec!["G::c".to_string()],
1744 },
1745 AbacEntity {
1746 entity_type: "A".to_string(),
1747 id: "a".to_string(),
1748 attributes: HashMap::new(),
1749 parents: vec!["G::b".to_string()],
1750 },
1751 ];
1752 let store = EntityStore::from_config(&entities);
1753 assert!(store.is_member_of("A::a", "G::d"));
1754 assert!(store.is_member_of("A::a", "G::b"));
1755 }
1756
1757 #[test]
1758 fn test_find_conflicts_none() {
1759 let engine = make_engine(vec![
1760 make_permit_policy("p1", "filesystem:*"),
1761 make_forbid_policy("f1", "bash:*"),
1762 ]);
1763 assert!(engine.find_conflicts().is_empty());
1764 }
1765
1766 #[test]
1767 fn test_find_conflicts_detected() {
1768 let engine = make_engine(vec![
1769 make_permit_policy("p1", "*:*"),
1770 make_forbid_policy("f1", "bash:*"),
1771 ]);
1772 let conflicts = engine.find_conflicts();
1773 assert_eq!(conflicts.len(), 1);
1774 assert_eq!(conflicts[0].permit_id, "p1");
1775 assert_eq!(conflicts[0].forbid_id, "f1");
1776 }
1777
1778 #[test]
1779 fn test_evaluate_with_risk_score_threshold() {
1780 let policy = AbacPolicy {
1781 id: "p1".to_string(),
1782 description: "low risk".to_string(),
1783 effect: AbacEffect::Permit,
1784 priority: 0,
1785 principal: Default::default(),
1786 action: Default::default(),
1787 resource: Default::default(),
1788 conditions: vec![AbacCondition {
1789 field: "risk.score".to_string(),
1790 op: AbacOp::Lte,
1791 value: serde_json::json!(0.5),
1792 }],
1793 };
1794 let engine = make_engine(vec![policy]);
1795
1796 let risk = RiskScore {
1797 score: 0.5,
1798 factors: vec![],
1799 updated_at: "2026-02-14T00:00:00Z".to_string(),
1800 };
1801 let eval_ctx = EvaluationContext::default();
1802 let ctx = AbacEvalContext {
1803 eval_ctx: &eval_ctx,
1804 principal_type: "Agent",
1805 principal_id: "test",
1806 risk_score: Some(&risk),
1807 };
1808 assert!(matches!(
1809 engine.evaluate(&make_action("any", "any"), &ctx),
1810 AbacDecision::Allow { .. }
1811 ));
1812 }
1813
1814 #[test]
1815 fn test_evaluate_condition_ne_not_in() {
1816 let policy = AbacPolicy {
1817 id: "p1".to_string(),
1818 description: "not in blocklist".to_string(),
1819 effect: AbacEffect::Permit,
1820 priority: 0,
1821 principal: Default::default(),
1822 action: Default::default(),
1823 resource: Default::default(),
1824 conditions: vec![
1825 AbacCondition {
1826 field: "context.tenant_id".to_string(),
1827 op: AbacOp::Ne,
1828 value: serde_json::json!("blocked"),
1829 },
1830 AbacCondition {
1831 field: "context.agent_id".to_string(),
1832 op: AbacOp::NotIn,
1833 value: serde_json::json!(["evil-agent", "bad-agent"]),
1834 },
1835 ],
1836 };
1837 let engine = make_engine(vec![policy]);
1838
1839 let eval_ctx = EvaluationContext {
1840 tenant_id: Some("acme".to_string()),
1841 agent_id: Some("good-agent".to_string()),
1842 ..Default::default()
1843 };
1844 let ctx = make_ctx(&eval_ctx, "Agent", "test");
1845 assert!(matches!(
1846 engine.evaluate(&make_action("any", "any"), &ctx),
1847 AbacDecision::Allow { .. }
1848 ));
1849
1850 let eval_ctx = EvaluationContext {
1851 tenant_id: Some("blocked".to_string()),
1852 agent_id: Some("good-agent".to_string()),
1853 ..Default::default()
1854 };
1855 let ctx = make_ctx(&eval_ctx, "Agent", "test");
1856 assert_eq!(
1857 engine.evaluate(&make_action("any", "any"), &ctx),
1858 AbacDecision::NoMatch
1859 );
1860 }
1861
1862 #[test]
1867 fn test_diamond_membership_no_exponential_blowup() {
1868 let entities = vec![
1872 AbacEntity {
1873 entity_type: "G".to_string(),
1874 id: "top".to_string(),
1875 attributes: HashMap::new(),
1876 parents: vec![],
1877 },
1878 AbacEntity {
1879 entity_type: "G".to_string(),
1880 id: "a".to_string(),
1881 attributes: HashMap::new(),
1882 parents: vec!["G::top".to_string()],
1883 },
1884 AbacEntity {
1885 entity_type: "G".to_string(),
1886 id: "b".to_string(),
1887 attributes: HashMap::new(),
1888 parents: vec!["G::top".to_string()],
1889 },
1890 AbacEntity {
1891 entity_type: "E".to_string(),
1892 id: "entity".to_string(),
1893 attributes: HashMap::new(),
1894 parents: vec!["G::a".to_string(), "G::b".to_string()],
1895 },
1896 ];
1897 let store = EntityStore::from_config(&entities);
1898 assert!(store.is_member_of("E::entity", "G::top"));
1899 assert!(store.is_member_of("E::entity", "G::a"));
1900 assert!(store.is_member_of("E::entity", "G::b"));
1901 assert!(!store.is_member_of("E::entity", "G::nonexistent"));
1902 }
1903
1904 #[test]
1905 fn test_wide_diamond_membership_completes_quickly() {
1906 let mut entities = vec![AbacEntity {
1909 entity_type: "G".to_string(),
1910 id: "top".to_string(),
1911 attributes: HashMap::new(),
1912 parents: vec![],
1913 }];
1914 let mut mid_parents = Vec::new();
1915 for i in 0..16 {
1916 let id = format!("mid{i}");
1917 entities.push(AbacEntity {
1918 entity_type: "G".to_string(),
1919 id: id.clone(),
1920 attributes: HashMap::new(),
1921 parents: vec!["G::top".to_string()],
1922 });
1923 mid_parents.push(format!("G::{id}"));
1924 }
1925 entities.push(AbacEntity {
1926 entity_type: "E".to_string(),
1927 id: "leaf".to_string(),
1928 attributes: HashMap::new(),
1929 parents: mid_parents,
1930 });
1931 let store = EntityStore::from_config(&entities);
1932 assert!(store.is_member_of("E::leaf", "G::top"));
1933 }
1934
1935 #[test]
1940 fn test_r46_001_path_traversal_normalized_in_resource_match() {
1941 let policy = AbacPolicy {
1946 id: "p1".to_string(),
1947 description: "home dir only".to_string(),
1948 effect: AbacEffect::Permit,
1949 priority: 0,
1950 principal: Default::default(),
1951 action: Default::default(),
1952 resource: ResourceConstraint {
1953 path_patterns: vec!["/home/**".to_string()],
1954 domain_patterns: vec![],
1955 tags: vec![],
1956 },
1957 conditions: vec![],
1958 };
1959 let engine = make_engine(vec![policy]);
1960 let eval_ctx = EvaluationContext::default();
1961 let ctx = make_ctx(&eval_ctx, "Agent", "test");
1962
1963 let action = make_action_with_paths("fs", "read", vec!["/home/../etc/passwd"]);
1965 assert_eq!(
1966 engine.evaluate(&action, &ctx),
1967 AbacDecision::NoMatch,
1968 "Path traversal should be normalized before ABAC resource matching"
1969 );
1970
1971 let action = make_action_with_paths("fs", "read", vec!["/home/user/file.txt"]);
1973 assert!(matches!(
1974 engine.evaluate(&action, &ctx),
1975 AbacDecision::Allow { .. }
1976 ));
1977 }
1978
1979 #[test]
1984 fn test_r46_002_domain_case_normalized_in_resource_match() {
1985 let policy = AbacPolicy {
1986 id: "p1".to_string(),
1987 description: "example.com only".to_string(),
1988 effect: AbacEffect::Permit,
1989 priority: 0,
1990 principal: Default::default(),
1991 action: Default::default(),
1992 resource: ResourceConstraint {
1993 path_patterns: vec![],
1994 domain_patterns: vec!["*example.com".to_string()],
1995 tags: vec![],
1996 },
1997 conditions: vec![],
1998 };
1999 let engine = make_engine(vec![policy]);
2000 let eval_ctx = EvaluationContext::default();
2001 let ctx = make_ctx(&eval_ctx, "Agent", "test");
2002
2003 let action = make_action_with_domains("net", "fetch", vec!["API.EXAMPLE.COM"]);
2005 assert!(matches!(
2006 engine.evaluate(&action, &ctx),
2007 AbacDecision::Allow { .. }
2008 ));
2009
2010 let action = make_action_with_domains("net", "fetch", vec!["api.example.com."]);
2012 assert!(matches!(
2013 engine.evaluate(&action, &ctx),
2014 AbacDecision::Allow { .. }
2015 ));
2016 }
2017
2018 #[test]
2023 fn test_r46_008_empty_field_condition_rejected_at_compile() {
2024 let policy = AbacPolicy {
2025 id: "p1".to_string(),
2026 description: "bad condition".to_string(),
2027 effect: AbacEffect::Permit,
2028 priority: 0,
2029 principal: Default::default(),
2030 action: Default::default(),
2031 resource: Default::default(),
2032 conditions: vec![AbacCondition {
2033 field: "".to_string(),
2034 op: AbacOp::Eq,
2035 value: serde_json::json!("test"),
2036 }],
2037 };
2038 let result = AbacEngine::new(&[policy], &[]);
2039 assert!(
2040 result.is_err(),
2041 "Empty condition field should be rejected at compile time"
2042 );
2043 }
2044
2045 #[test]
2046 fn test_cycle_in_membership_terminates() {
2047 let entities = vec![
2049 AbacEntity {
2050 entity_type: "G".to_string(),
2051 id: "a".to_string(),
2052 attributes: HashMap::new(),
2053 parents: vec!["G::b".to_string()],
2054 },
2055 AbacEntity {
2056 entity_type: "G".to_string(),
2057 id: "b".to_string(),
2058 attributes: HashMap::new(),
2059 parents: vec!["G::c".to_string()],
2060 },
2061 AbacEntity {
2062 entity_type: "G".to_string(),
2063 id: "c".to_string(),
2064 attributes: HashMap::new(),
2065 parents: vec!["G::a".to_string()],
2066 },
2067 ];
2068 let store = EntityStore::from_config(&entities);
2069 assert!(!store.is_member_of("G::a", "G::nonexistent"));
2071 assert!(store.is_member_of("G::a", "G::b"));
2073 }
2074
2075 #[test]
2080 fn test_p1_5_glob_double_star_path_matching() {
2081 let policy = AbacPolicy {
2083 id: "p1".to_string(),
2084 description: "txt files under /data".to_string(),
2085 effect: AbacEffect::Permit,
2086 priority: 0,
2087 principal: Default::default(),
2088 action: Default::default(),
2089 resource: ResourceConstraint {
2090 path_patterns: vec!["/data/**/*.txt".to_string()],
2091 domain_patterns: vec![],
2092 tags: vec![],
2093 },
2094 conditions: vec![],
2095 };
2096 let engine = make_engine(vec![policy]);
2097 let eval_ctx = EvaluationContext::default();
2098 let ctx = make_ctx(&eval_ctx, "Agent", "test");
2099
2100 let action = make_action_with_paths("fs", "read", vec!["/data/subdir/file.txt"]);
2102 assert!(matches!(
2103 engine.evaluate(&action, &ctx),
2104 AbacDecision::Allow { .. }
2105 ));
2106
2107 let action = make_action_with_paths("fs", "read", vec!["/data/a/b/c/file.txt"]);
2109 assert!(matches!(
2110 engine.evaluate(&action, &ctx),
2111 AbacDecision::Allow { .. }
2112 ));
2113
2114 let action = make_action_with_paths("fs", "read", vec!["/data/subdir/file.json"]);
2116 assert_eq!(engine.evaluate(&action, &ctx), AbacDecision::NoMatch);
2117
2118 let action = make_action_with_paths("fs", "read", vec!["/etc/file.txt"]);
2120 assert_eq!(engine.evaluate(&action, &ctx), AbacDecision::NoMatch);
2121 }
2122
2123 #[test]
2124 fn test_p1_5_glob_single_star_path_matching() {
2125 let policy = AbacPolicy {
2127 id: "p1".to_string(),
2128 description: "user config".to_string(),
2129 effect: AbacEffect::Permit,
2130 priority: 0,
2131 principal: Default::default(),
2132 action: Default::default(),
2133 resource: ResourceConstraint {
2134 path_patterns: vec!["/home/*/config".to_string()],
2135 domain_patterns: vec![],
2136 tags: vec![],
2137 },
2138 conditions: vec![],
2139 };
2140 let engine = make_engine(vec![policy]);
2141 let eval_ctx = EvaluationContext::default();
2142 let ctx = make_ctx(&eval_ctx, "Agent", "test");
2143
2144 let action = make_action_with_paths("fs", "read", vec!["/home/alice/config"]);
2145 assert!(matches!(
2146 engine.evaluate(&action, &ctx),
2147 AbacDecision::Allow { .. }
2148 ));
2149
2150 let action = make_action_with_paths("fs", "read", vec!["/home/alice/sub/config"]);
2152 assert_eq!(engine.evaluate(&action, &ctx), AbacDecision::NoMatch);
2153 }
2154
2155 #[test]
2156 fn test_p1_5_glob_question_mark_path_matching() {
2157 let policy = AbacPolicy {
2159 id: "p1".to_string(),
2160 description: "single char wildcard".to_string(),
2161 effect: AbacEffect::Permit,
2162 priority: 0,
2163 principal: Default::default(),
2164 action: Default::default(),
2165 resource: ResourceConstraint {
2166 path_patterns: vec!["/tmp/file?.log".to_string()],
2167 domain_patterns: vec![],
2168 tags: vec![],
2169 },
2170 conditions: vec![],
2171 };
2172 let engine = make_engine(vec![policy]);
2173 let eval_ctx = EvaluationContext::default();
2174 let ctx = make_ctx(&eval_ctx, "Agent", "test");
2175
2176 let action = make_action_with_paths("fs", "read", vec!["/tmp/file1.log"]);
2177 assert!(matches!(
2178 engine.evaluate(&action, &ctx),
2179 AbacDecision::Allow { .. }
2180 ));
2181
2182 let action = make_action_with_paths("fs", "read", vec!["/tmp/file12.log"]);
2184 assert_eq!(engine.evaluate(&action, &ctx), AbacDecision::NoMatch);
2185 }
2186
2187 #[test]
2188 fn test_p1_5_exact_path_still_works_without_glob() {
2189 let policy = AbacPolicy {
2191 id: "p1".to_string(),
2192 description: "exact path".to_string(),
2193 effect: AbacEffect::Permit,
2194 priority: 0,
2195 principal: Default::default(),
2196 action: Default::default(),
2197 resource: ResourceConstraint {
2198 path_patterns: vec!["/etc/config.yaml".to_string()],
2199 domain_patterns: vec![],
2200 tags: vec![],
2201 },
2202 conditions: vec![],
2203 };
2204 let engine = make_engine(vec![policy]);
2205 let eval_ctx = EvaluationContext::default();
2206 let ctx = make_ctx(&eval_ctx, "Agent", "test");
2207
2208 let action = make_action_with_paths("fs", "read", vec!["/etc/config.yaml"]);
2209 assert!(matches!(
2210 engine.evaluate(&action, &ctx),
2211 AbacDecision::Allow { .. }
2212 ));
2213
2214 let action = make_action_with_paths("fs", "read", vec!["/etc/other.yaml"]);
2215 assert_eq!(engine.evaluate(&action, &ctx), AbacDecision::NoMatch);
2216 }
2217
2218 #[test]
2219 fn test_p1_5_glob_bracket_pattern() {
2220 let policy = AbacPolicy {
2222 id: "p1".to_string(),
2223 description: "bracket pattern".to_string(),
2224 effect: AbacEffect::Permit,
2225 priority: 0,
2226 principal: Default::default(),
2227 action: Default::default(),
2228 resource: ResourceConstraint {
2229 path_patterns: vec!["/data/file[0-9].csv".to_string()],
2230 domain_patterns: vec![],
2231 tags: vec![],
2232 },
2233 conditions: vec![],
2234 };
2235 let engine = make_engine(vec![policy]);
2236 let eval_ctx = EvaluationContext::default();
2237 let ctx = make_ctx(&eval_ctx, "Agent", "test");
2238
2239 let action = make_action_with_paths("fs", "read", vec!["/data/file5.csv"]);
2240 assert!(matches!(
2241 engine.evaluate(&action, &ctx),
2242 AbacDecision::Allow { .. }
2243 ));
2244
2245 let action = make_action_with_paths("fs", "read", vec!["/data/fileA.csv"]);
2246 assert_eq!(engine.evaluate(&action, &ctx), AbacDecision::NoMatch);
2247 }
2248
2249 #[test]
2254 fn test_r215_004_principal_id_homoglyph_normalization() {
2255 let policy = AbacPolicy {
2258 id: "p1".to_string(),
2259 description: "admin agents".to_string(),
2260 effect: AbacEffect::Forbid,
2261 priority: 0,
2262 principal: PrincipalConstraint {
2263 principal_type: None,
2264 id_patterns: vec!["admin*".to_string()],
2265 claims: HashMap::new(),
2266 },
2267 action: ActionConstraint {
2268 patterns: vec!["*:*".to_string()],
2269 },
2270 resource: Default::default(),
2271 conditions: vec![],
2272 };
2273 let engine = make_engine(vec![policy]);
2274 let eval_ctx = EvaluationContext::default();
2275
2276 let ctx = make_ctx(&eval_ctx, "Agent", "\u{0430}dmin-user");
2278 match engine.evaluate(&make_action("any", "any"), &ctx) {
2279 AbacDecision::Deny { policy_id, .. } => assert_eq!(policy_id, "p1"),
2280 other => panic!("Expected Deny from homoglyph principal ID, got {other:?}"),
2281 }
2282 }
2283
2284 #[test]
2289 fn test_r215_005_principal_claims_homoglyph_normalization() {
2290 let mut claims = HashMap::new();
2292 claims.insert("team".to_string(), "security*".to_string());
2293
2294 let policy = AbacPolicy {
2295 id: "p1".to_string(),
2296 description: "security team".to_string(),
2297 effect: AbacEffect::Permit,
2298 priority: 0,
2299 principal: PrincipalConstraint {
2300 principal_type: None,
2301 id_patterns: vec![],
2302 claims,
2303 },
2304 action: Default::default(),
2305 resource: Default::default(),
2306 conditions: vec![],
2307 };
2308 let engine = make_engine(vec![policy]);
2309
2310 let mut identity_claims = HashMap::new();
2312 identity_claims.insert(
2313 "team".to_string(),
2314 serde_json::json!("\u{0455}\u{0435}curity-ops"),
2315 );
2316 let eval_ctx = EvaluationContext {
2317 agent_identity: Some(AgentIdentity {
2318 claims: identity_claims,
2319 ..Default::default()
2320 }),
2321 ..Default::default()
2322 };
2323 let ctx = make_ctx(&eval_ctx, "Agent", "test");
2324 assert!(
2325 matches!(
2326 engine.evaluate(&make_action("any", "any"), &ctx),
2327 AbacDecision::Allow { .. }
2328 ),
2329 "Homoglyph claim value should match after normalization"
2330 );
2331 }
2332
2333 #[test]
2338 fn test_r215_006_compare_numbers_nan_guard() {
2339 let policy = AbacPolicy {
2341 id: "permit-low-risk".to_string(),
2342 description: "low risk only".to_string(),
2343 effect: AbacEffect::Permit,
2344 priority: 0,
2345 principal: Default::default(),
2346 action: Default::default(),
2347 resource: Default::default(),
2348 conditions: vec![AbacCondition {
2349 field: "risk.score".to_string(),
2350 op: AbacOp::Lt,
2351 value: serde_json::json!(0.5),
2352 }],
2353 };
2354 let engine = make_engine(vec![policy]);
2355 let eval_ctx = EvaluationContext::default();
2356
2357 let risk = RiskScore {
2361 score: f64::NAN,
2362 factors: vec![],
2363 updated_at: "2026-02-14T00:00:00Z".to_string(),
2364 };
2365 let ctx = AbacEvalContext {
2366 eval_ctx: &eval_ctx,
2367 principal_type: "Agent",
2368 principal_id: "test",
2369 risk_score: Some(&risk),
2370 };
2371 assert_eq!(
2372 engine.evaluate(&make_action("any", "any"), &ctx),
2373 AbacDecision::NoMatch,
2374 "NaN risk score should not match Lt condition"
2375 );
2376 }
2377
2378 #[test]
2379 fn test_r215_006_compare_numbers_infinity_guard() {
2380 let policy = AbacPolicy {
2382 id: "permit-low-risk".to_string(),
2383 description: "low risk only".to_string(),
2384 effect: AbacEffect::Permit,
2385 priority: 0,
2386 principal: Default::default(),
2387 action: Default::default(),
2388 resource: Default::default(),
2389 conditions: vec![AbacCondition {
2390 field: "risk.score".to_string(),
2391 op: AbacOp::Lte,
2392 value: serde_json::json!(0.5),
2393 }],
2394 };
2395 let engine = make_engine(vec![policy]);
2396 let eval_ctx = EvaluationContext::default();
2397
2398 let risk = RiskScore {
2399 score: f64::INFINITY,
2400 factors: vec![],
2401 updated_at: "2026-02-14T00:00:00Z".to_string(),
2402 };
2403 let ctx = AbacEvalContext {
2404 eval_ctx: &eval_ctx,
2405 principal_type: "Agent",
2406 principal_id: "test",
2407 risk_score: Some(&risk),
2408 };
2409 assert_eq!(
2410 engine.evaluate(&make_action("any", "any"), &ctx),
2411 AbacDecision::NoMatch,
2412 "Infinity risk score should not match Lte condition"
2413 );
2414 }
2415
2416 #[test]
2421 fn test_r215_007_principal_type_homoglyph_normalization() {
2422 let policy = AbacPolicy {
2425 id: "p1".to_string(),
2426 description: "Agents only".to_string(),
2427 effect: AbacEffect::Permit,
2428 priority: 0,
2429 principal: PrincipalConstraint {
2430 principal_type: Some("Agent".to_string()),
2431 id_patterns: vec![],
2432 claims: HashMap::new(),
2433 },
2434 action: ActionConstraint {
2435 patterns: vec!["*:*".to_string()],
2436 },
2437 resource: Default::default(),
2438 conditions: vec![],
2439 };
2440 let engine = make_engine(vec![policy]);
2441 let eval_ctx = EvaluationContext::default();
2442
2443 let ctx = make_ctx(&eval_ctx, "\u{0410}gent", "test-agent");
2445 assert!(
2446 matches!(
2447 engine.evaluate(&make_action("any", "any"), &ctx),
2448 AbacDecision::Allow { .. }
2449 ),
2450 "Homoglyph principal_type should match after normalization"
2451 );
2452 }
2453
2454 #[test]
2460 fn test_r237_eng3_abac_eq_cyrillic_homoglyph_matches() {
2461 let policy = AbacPolicy {
2462 id: "p1".to_string(),
2463 description: "tenant eq check".to_string(),
2464 effect: AbacEffect::Permit,
2465 priority: 0,
2466 principal: Default::default(),
2467 action: Default::default(),
2468 resource: Default::default(),
2469 conditions: vec![AbacCondition {
2470 field: "context.tenant_id".to_string(),
2471 op: AbacOp::Eq,
2472 value: serde_json::json!("acme"),
2474 }],
2475 };
2476 let engine = make_engine(vec![policy]);
2477
2478 let eval_ctx = EvaluationContext {
2480 tenant_id: Some("\u{0430}cme".to_string()),
2481 ..Default::default()
2482 };
2483 let ctx = make_ctx(&eval_ctx, "Agent", "test");
2484 assert!(
2485 matches!(
2486 engine.evaluate(&make_action("any", "any"), &ctx),
2487 AbacDecision::Allow { .. }
2488 ),
2489 "Cyrillic homoglyph 'а' should match Latin 'a' after normalize_full()"
2490 );
2491 }
2492
2493 #[test]
2496 fn test_r237_eng3_abac_ne_cyrillic_homoglyph_treated_equal() {
2497 let policy = AbacPolicy {
2498 id: "p1".to_string(),
2499 description: "deny if not acme".to_string(),
2500 effect: AbacEffect::Forbid,
2501 priority: 0,
2502 principal: Default::default(),
2503 action: Default::default(),
2504 resource: Default::default(),
2505 conditions: vec![AbacCondition {
2506 field: "context.tenant_id".to_string(),
2507 op: AbacOp::Ne,
2508 value: serde_json::json!("acme"),
2509 }],
2510 };
2511 let engine = make_engine(vec![policy]);
2512
2513 let eval_ctx = EvaluationContext {
2516 tenant_id: Some("\u{0430}cme".to_string()),
2517 ..Default::default()
2518 };
2519 let ctx = make_ctx(&eval_ctx, "Agent", "test");
2520 assert_eq!(
2521 engine.evaluate(&make_action("any", "any"), &ctx),
2522 AbacDecision::NoMatch,
2523 "Homoglyph-equal values should not satisfy Ne condition"
2524 );
2525 }
2526
2527 #[test]
2529 fn test_r237_eng5_abac_in_case_insensitive_match() {
2530 let policy = AbacPolicy {
2531 id: "p1".to_string(),
2532 description: "tenant in list".to_string(),
2533 effect: AbacEffect::Permit,
2534 priority: 0,
2535 principal: Default::default(),
2536 action: Default::default(),
2537 resource: Default::default(),
2538 conditions: vec![AbacCondition {
2539 field: "context.tenant_id".to_string(),
2540 op: AbacOp::In,
2541 value: serde_json::json!(["Acme", "Globex"]),
2542 }],
2543 };
2544 let engine = make_engine(vec![policy]);
2545
2546 let eval_ctx = EvaluationContext {
2548 tenant_id: Some("acme".to_string()),
2549 ..Default::default()
2550 };
2551 let ctx = make_ctx(&eval_ctx, "Agent", "test");
2552 assert!(
2553 matches!(
2554 engine.evaluate(&make_action("any", "any"), &ctx),
2555 AbacDecision::Allow { .. }
2556 ),
2557 "Case-different 'acme' should match 'Acme' in list after normalize_full()"
2558 );
2559 }
2560
2561 #[test]
2564 fn test_r237_eng5_abac_notin_homoglyph_is_found() {
2565 let policy = AbacPolicy {
2566 id: "p1".to_string(),
2567 description: "forbid if not in blocked list".to_string(),
2568 effect: AbacEffect::Forbid,
2569 priority: 0,
2570 principal: Default::default(),
2571 action: Default::default(),
2572 resource: Default::default(),
2573 conditions: vec![AbacCondition {
2574 field: "context.tenant_id".to_string(),
2575 op: AbacOp::NotIn,
2576 value: serde_json::json!(["acme", "globex"]),
2577 }],
2578 };
2579 let engine = make_engine(vec![policy]);
2580
2581 let eval_ctx = EvaluationContext {
2584 tenant_id: Some("\u{0430}cme".to_string()),
2585 ..Default::default()
2586 };
2587 let ctx = make_ctx(&eval_ctx, "Agent", "test");
2588 assert_eq!(
2589 engine.evaluate(&make_action("any", "any"), &ctx),
2590 AbacDecision::NoMatch,
2591 "Homoglyph value should be found in list after normalization (NotIn false)"
2592 );
2593 }
2594
2595 #[test]
2599 fn test_r237_eng5_abac_contains_homoglyph_matches() {
2600 let policy = AbacPolicy {
2601 id: "p1".to_string(),
2602 description: "agent contains acme".to_string(),
2603 effect: AbacEffect::Permit,
2604 priority: 0,
2605 principal: Default::default(),
2606 action: Default::default(),
2607 resource: Default::default(),
2608 conditions: vec![AbacCondition {
2609 field: "context.agent_id".to_string(),
2610 op: AbacOp::Contains,
2611 value: serde_json::json!("acme"),
2613 }],
2614 };
2615 let engine = make_engine(vec![policy]);
2616
2617 let eval_ctx = EvaluationContext {
2619 agent_id: Some("my-\u{0430}\u{0441}m\u{0435}-agent".to_string()),
2620 ..Default::default()
2621 };
2622 let ctx = make_ctx(&eval_ctx, "Agent", "test");
2623 assert!(
2624 matches!(
2625 engine.evaluate(&make_action("any", "any"), &ctx),
2626 AbacDecision::Allow { .. }
2627 ),
2628 "Cyrillic homoglyphs in Contains haystack should match after normalize_full()"
2629 );
2630 }
2631
2632 #[test]
2634 fn test_r237_eng5_abac_startswith_case_normalized() {
2635 let policy = AbacPolicy {
2636 id: "p1".to_string(),
2637 description: "agent starts with prod-".to_string(),
2638 effect: AbacEffect::Permit,
2639 priority: 0,
2640 principal: Default::default(),
2641 action: Default::default(),
2642 resource: Default::default(),
2643 conditions: vec![AbacCondition {
2644 field: "context.agent_id".to_string(),
2645 op: AbacOp::StartsWith,
2646 value: serde_json::json!("prod-"),
2648 }],
2649 };
2650 let engine = make_engine(vec![policy]);
2651
2652 let eval_ctx = EvaluationContext {
2654 agent_id: Some("PROD-agent-1".to_string()),
2655 ..Default::default()
2656 };
2657 let ctx = make_ctx(&eval_ctx, "Agent", "test");
2658 assert!(
2659 matches!(
2660 engine.evaluate(&make_action("any", "any"), &ctx),
2661 AbacDecision::Allow { .. }
2662 ),
2663 "Case-different 'PROD-' should match 'prod-' after normalize_full()"
2664 );
2665 }
2666}