Skip to main content

vellaveto_engine/
abac.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4//
5// Copyright 2026 Paolo Vella
6// SPDX-License-Identifier: MPL-2.0
7
8//! ABAC (Attribute-Based Access Control) engine — Cedar-style policy evaluation.
9//!
10//! Compiles ABAC policies at load time into a fast in-memory representation
11//! and evaluates them with forbid-overrides semantics.
12
13use 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/// A compiled path matcher that uses `globset::Glob` for patterns containing
21/// wildcards (`*`, `**`, `?`, `[`) and falls back to `PatternMatcher` for simple
22/// exact/prefix/suffix patterns.
23///
24/// SECURITY (FIND-P1-5): ABAC resource path matching must have parity with
25/// the main engine's path rules, which use `globset::Glob`. Without this,
26/// patterns like `/home/**/*.txt` would not match correctly.
27#[derive(Debug, Clone)]
28enum CompiledPathMatcher {
29    /// Simple pattern (no glob metacharacters) — use PatternMatcher.
30    Simple(PatternMatcher),
31    /// Glob pattern — pre-compiled for fast matching.
32    Glob(globset::GlobMatcher),
33}
34
35impl CompiledPathMatcher {
36    /// Compile a path pattern. If the pattern contains glob metacharacters
37    /// (`*`, `**`, `?`, `[`), compile as a glob. Otherwise, use PatternMatcher.
38    ///
39    /// Uses `literal_separator(true)` so that `*` does not match path
40    /// separators (`/`), matching standard filesystem glob behavior. Only
41    /// `**` crosses directory boundaries.
42    ///
43    /// SECURITY: If a glob pattern fails to compile, returns `None` (fail-closed).
44    /// The caller must treat `None` as a deny.
45    fn compile(pattern: &str) -> Option<Self> {
46        let has_glob_meta = pattern.contains('*') || pattern.contains('?') || pattern.contains('[');
47
48        if !has_glob_meta {
49            // No glob metacharacters — simple exact match
50            return Some(CompiledPathMatcher::Simple(PatternMatcher::compile(
51                pattern,
52            )));
53        }
54
55        // Try to compile as a glob with literal_separator so that `*` does
56        // not cross `/` boundaries (only `**` does).
57        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 // Fail-closed: caller treats as always-deny
69            }
70        }
71    }
72
73    /// Check if a path matches this compiled pattern.
74    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
82/// Maximum transitive group membership depth to prevent cycles.
83const MAX_MEMBERSHIP_DEPTH: usize = 16;
84
85// ═══════════════════════════════════════════════════════════════════════════════
86// ABAC EVALUATION RESULT
87// ═══════════════════════════════════════════════════════════════════════════════
88
89/// Result of ABAC policy evaluation.
90#[derive(Debug, Clone, PartialEq)]
91#[non_exhaustive]
92#[must_use = "ABAC decisions must not be discarded"]
93pub enum AbacDecision {
94    /// An ABAC permit policy matched — allow the action.
95    Allow { policy_id: String },
96    /// An ABAC forbid policy matched — deny the action.
97    Deny { policy_id: String, reason: String },
98    /// No ABAC policy matched — fall through to existing verdict.
99    NoMatch,
100}
101
102/// Conflict between permit and forbid policies on overlapping patterns.
103#[derive(Debug, Clone)]
104pub struct AbacConflict {
105    pub permit_id: String,
106    pub forbid_id: String,
107    pub overlap_description: String,
108}
109
110// ═══════════════════════════════════════════════════════════════════════════════
111// EVAL CONTEXT
112// ═══════════════════════════════════════════════════════════════════════════════
113
114/// Context for ABAC evaluation — combines EvaluationContext with resolved principal.
115pub 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
122// ═══════════════════════════════════════════════════════════════════════════════
123// COMPILED TYPES
124// ═══════════════════════════════════════════════════════════════════════════════
125
126/// Compiled principal constraint with pre-built matchers.
127struct CompiledPrincipal {
128    principal_type: Option<String>,
129    id_matchers: Vec<PatternMatcher>,
130    claims: Vec<(String, PatternMatcher)>,
131}
132
133/// Compiled action constraint with pre-built tool:function matchers.
134struct CompiledAction {
135    /// Each pattern is "tool:function" split and compiled separately.
136    matchers: Vec<(PatternMatcher, PatternMatcher)>,
137}
138
139/// Compiled resource constraint with pre-built path/domain matchers.
140///
141/// SECURITY (FIND-P1-5): Path matchers use `CompiledPathMatcher` which
142/// delegates to `globset::Glob` for patterns containing wildcards. If any
143/// path pattern fails to compile, the entire resource becomes a "fail-closed
144/// deny" (no action can match it) via the `path_compile_failed` flag.
145struct CompiledResource {
146    path_matchers: Vec<CompiledPathMatcher>,
147    domain_matchers: Vec<PatternMatcher>,
148    tags: Vec<String>,
149    /// Set to true if any path pattern failed to compile as a glob.
150    /// When true, `matches_resource` returns false (fail-closed).
151    path_compile_failed: bool,
152}
153
154/// Compiled ABAC condition — ready for evaluation.
155struct CompiledCondition {
156    field: String,
157    op: AbacOp,
158    value: serde_json::Value,
159}
160
161/// A fully compiled ABAC policy ready for evaluation.
162struct 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
172// ═══════════════════════════════════════════════════════════════════════════════
173// ENTITY STORE
174// ═══════════════════════════════════════════════════════════════════════════════
175
176/// In-memory entity store for ABAC principal/resource attributes.
177///
178/// # Security
179///
180/// This store is the authority for group membership lookups used by ABAC
181/// principal matching. Membership queries (`is_member_of`) are bounded
182/// by [`MAX_MEMBERSHIP_DEPTH`] and use a visited set to prevent cycles
183/// and exponential blowup in diamond-shaped group hierarchies.
184pub struct EntityStore {
185    /// Entities keyed by "Type::id".
186    entities: HashMap<String, AbacEntity>,
187    /// Group membership: entity_key → parent entity_keys.
188    memberships: HashMap<String, Vec<String>>,
189}
190
191impl EntityStore {
192    /// Build an entity store from config entities.
193    ///
194    /// SECURITY (R242-ENG-2): Keys are normalized via `normalize_full()` to match
195    /// the normalized lookup keys constructed in `matches_principal()`. Without this,
196    /// entities with non-ASCII type/id would never match normalized lookup keys.
197    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    /// Look up an entity by type and ID.
231    ///
232    /// SECURITY (R242-ENG-2): Normalize lookup key to match storage normalization.
233    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    /// Check if an entity is a (transitive) member of a group.
243    /// Bounded to MAX_MEMBERSHIP_DEPTH to prevent infinite loops.
244    /// Uses a visited set to prevent exponential blowup through diamond-shaped
245    /// membership graphs (FIND-R44-001).
246    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        // FIND-R44-001: Skip entities already visited through a different path
269        // to prevent exponential blowup in diamond-shaped graphs.
270        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
299// ═══════════════════════════════════════════════════════════════════════════════
300// ABAC ENGINE
301// ═══════════════════════════════════════════════════════════════════════════════
302
303/// The ABAC policy evaluation engine.
304///
305/// Compiles policies at construction time and evaluates them with
306/// forbid-overrides semantics (any matching forbid wins over all permits).
307pub struct AbacEngine {
308    compiled: Vec<CompiledAbacPolicy>,
309    entity_store: EntityStore,
310}
311
312/// Maximum number of ABAC entities to prevent memory exhaustion during engine construction.
313///
314/// SECURITY (FIND-R111-003): Unbounded entity lists from operator-supplied configuration
315/// could cause OOM when building the EntityStore.
316const MAX_ABAC_ENTITIES: usize = 10_000;
317
318impl AbacEngine {
319    /// Create an ABAC engine from policies and entities.
320    ///
321    /// Compiles all policies and builds the entity store. Returns an error
322    /// if any policy pattern is invalid or entity bounds are exceeded.
323    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            // SECURITY: Validate policy bounds before compiling to reject
327            // oversized conditions, patterns, or other bounded fields early.
328            policy
329                .validate()
330                .map_err(|e| format!("ABAC policy '{}' validation failed: {e}", policy.id))?;
331            compiled.push(compile_policy(policy)?);
332        }
333        // Sort by priority descending (higher priority first)
334        compiled.sort_by(|a, b| b.priority.cmp(&a.priority));
335
336        // SECURITY (FIND-R111-003): Validate entity count and each entity's bounds
337        // before building the EntityStore. Without this, attacker-controlled entity
338        // lists bypass AbacEntity::validate() bounds and can cause OOM.
339        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    // VERIFIED [S7]: Forbid-overrides — a single Forbid beats any number of Permits (AbacForbidOverrides.tla S7)
363    /// Evaluate an action against all ABAC policies.
364    ///
365    /// Uses forbid-overrides semantics:
366    /// 1. Collect matching policies (principal + action + resource + conditions)
367    /// 2. If any matching policy is Forbid → Deny
368    /// 3. If any matching policy is Permit (and no Forbid) → Allow
369    /// 4. If nothing matches → NoMatch (caller decides)
370    #[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                    // SECURITY (FIND-R58-ENG-008): Early exit on first Forbid match.
391                    // With forbid-overrides semantics, no subsequent match can change
392                    // the outcome. Continuing wastes CPU on the critical evaluation path.
393                    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    /// Get a reference to the entity store.
416    pub fn entity_store(&self) -> &EntityStore {
417        &self.entity_store
418    }
419
420    /// Detect conflicts where permit and forbid policies overlap.
421    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    /// Return the number of compiled policies.
452    pub fn policy_count(&self) -> usize {
453        self.compiled.len()
454    }
455}
456
457// ═══════════════════════════════════════════════════════════════════════════════
458// COMPILATION
459// ═══════════════════════════════════════════════════════════════════════════════
460
461/// Known condition fields that can be resolved at evaluation time.
462const 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    // SECURITY (FIND-R46-008): Validate condition fields at compile time.
477    // Reject conditions with empty field names, and warn about unknown fields
478    // (fields not in the known set and not starting with "claims." prefix).
479    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        // SECURITY (P3-ENG-001): Reject control characters and Unicode format characters
488        // in condition field names. A field like "context.agent_id\x00suffix" would not
489        // match any known key (silently resolving to Null), which can bypass Forbid
490        // conditions that compare against expected values.
491        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        // SECURITY: For numeric comparison operators, validate that the condition
509        // value is a finite number. NaN/Infinity in the condition value causes all
510        // comparisons to return false, silently bypassing Forbid policies.
511        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                _ => {} // finite number, OK
526            }
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                // No colon — treat as tool-only match (any function)
571                (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                // SECURITY (FIND-P1-5): Glob compilation failed — fail-closed.
587                // We still collect remaining matchers for diagnostics, but mark
588                // the resource as failed so matches_resource always returns false.
589                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
606// ═══════════════════════════════════════════════════════════════════════════════
607// MATCHING
608// ═══════════════════════════════════════════════════════════════════════════════
609
610fn matches_principal(
611    principal: &CompiledPrincipal,
612    ctx: &AbacEvalContext<'_>,
613    entity_store: &EntityStore,
614) -> bool {
615    // SECURITY (FIND-R215-007, R228-ENG-2): Normalize both required_type and ctx.principal_type
616    // through normalize_full() (NFKC + lowercase + homoglyphs) to prevent circled/fullwidth
617    // characters from bypassing principal type matching in ABAC policies.
618    // Type check
619    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            // SECURITY (R240-ENG-4): Normalize entity/group keys to match compile-time
624            // normalization. Raw (un-normalized) keys would create a lookup mismatch with
625            // the entity store, which stores keys under normalized forms.
626            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    // SECURITY (FIND-R215-004, R228-ENG-2): Normalize principal_id through normalize_full()
643    // before matching against compiled id_matchers. Patterns are normalized at compile
644    // time; runtime input must also be normalized to prevent Cyrillic/fullwidth
645    // characters from bypassing Forbid policies.
646    // ID pattern check
647    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    // Claims check
655    for (claim_key, pattern) in &principal.claims {
656        // SECURITY (FIND-R49-010): Absent claim must not match — returning false
657        // when the key is missing prevents treating missing claims as empty strings,
658        // which would incorrectly match patterns like "" or wildcard.
659        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        // SECURITY (FIND-R215-005, R228-ENG-2): Normalize claim values through normalize_full()
669        // before matching against compiled patterns. Without this, Cyrillic/fullwidth
670        // characters in JWT claim values bypass ABAC principal claim checks.
671        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    // Empty patterns = match any action
682    if action_constraint.matchers.is_empty() {
683        return true;
684    }
685    // SECURITY (FIND-R206-002, R228-ENG-2): Normalize tool/function names through
686    // normalize_full() (NFKC + lowercase + homoglyphs) before ABAC matching. Patterns
687    // are normalized at compile time; input must also be normalized to prevent
688    // circled/Cyrillic/Greek/fullwidth characters from bypassing Forbid policies.
689    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    // SECURITY (FIND-P1-5): If any path pattern failed to compile as a glob,
698    // fail-closed — no action can match this resource constraint.
699    if resource.path_compile_failed {
700        return false;
701    }
702
703    // Path check: if patterns specified, at least one path must match
704    // SECURITY (FIND-R46-001): Apply path normalization before matching to prevent
705    // traversal bypasses (e.g., "/home/../etc/passwd" matching "/home/*").
706    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            // SECURITY (FIND-R49-001): Use bounded path normalization.
712            // SECURITY (FIND-R149-006): On normalization failure, skip the path
713            // (return false for this path). This is fail-closed because:
714            // - Permit policies won't match → no false Allow
715            // - Forbid policies won't match → but unmatched actions still need a
716            //   Permit to be allowed, so the result is NoMatch → Deny at caller
717            // Previous behavior was `unwrap_or_else(|_| "/".to_string())` which
718            // was unpredictable: "/" could match broad Permit patterns like "/**"
719            // (false Allow) or miss specific Forbid patterns (false pass).
720            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    // Domain check: if patterns specified, at least one domain must match
745    // SECURITY (FIND-R46-002): Apply domain normalization (lowercase, trim trailing dots)
746    // before matching to prevent bypass via case or trailing dot variations.
747    // SECURITY (FIND-P2-001): Apply full IDNA normalization via normalize_domain_for_match()
748    // to prevent bypass via internationalized domain name variations (e.g., punycode vs
749    // Unicode, homoglyphs). Domains that fail IDNA normalization are rejected (fail-closed).
750    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                    // SECURITY (FIND-P2-001): Fail-closed — domain cannot be IDNA-normalized.
759                    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    // Tags check: all tags must be present (checked against action parameters)
777    // Tags are metadata labels — for now we check if they appear as parameter keys
778    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    // SECURITY (FIND-R46-008): Log a warning when conditions array is empty.
795    // Empty conditions = no restrictions is correct for ABAC (vacuous truth),
796    // but it may indicate a misconfiguration. Compile-time validation in
797    // compile_policy() ensures condition fields are well-formed.
798    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        // SECURITY (R237-ENG-3/5): Normalize string operands through normalize_full()
808        // for consistency with principal/action matching. Prevents Unicode homoglyph
809        // and case-variant bypasses in ABAC conditions.
810        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                // SECURITY (FIND-R46-009): Fail-closed when NotIn policy value is
852                // not an array. A non-array value indicates a misconfigured policy.
853                // Previously returned `true` (pass), allowing the action through.
854                // Now returns `false` (condition fails → policy doesn't match),
855                // which is fail-closed because unmatched policies don't permit.
856                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        // SECURITY (FIND-R215-006): Guard against NaN/Infinity values that could
892        // cause unpredictable comparison results (NaN < x is always false, etc.).
893        // Non-finite values in either operand cause the comparison to fail-closed.
894        (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        // SECURITY (FIND-R48-002): Non-finite risk.score (NaN/Inf) must fail-closed.
904        // json!(NaN) produces Null, which would cause Forbid conditions to not match.
905        // Treat non-finite scores as maximum risk (1.0) to ensure Forbid policies fire.
906        "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            // Try to resolve from agent identity claims: "claims.<key>"
937            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
950/// Check if two action constraints could match the same action.
951fn action_patterns_overlap(a: &CompiledAction, b: &CompiledAction) -> bool {
952    // Empty patterns match everything, so they always overlap
953    if a.matchers.is_empty() || b.matchers.is_empty() {
954        return true;
955    }
956    // Check pairwise — conservative: if any pair could overlap, report it
957    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
967/// Conservative check: could two pattern matchers match the same string?
968fn 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        // For prefix/suffix/prefix-prefix/suffix-suffix, conservatively assume overlap
977        _ => true,
978    }
979}
980
981// ═══════════════════════════════════════════════════════════════════════════════
982// TESTS
983// ═══════════════════════════════════════════════════════════════════════════════
984
985#[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        // Agent matches
1143        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        // Service doesn't match
1150        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        // Matching claim
1211        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        // Non-matching claim
1227        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        // Exact match
1250        assert!(matches!(
1251            engine.evaluate(&make_action("filesystem", "write_file"), &ctx),
1252            AbacDecision::Allow { .. }
1253        ));
1254        // Different function
1255        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        // FIND-P1-5: Updated from `/home/*` to `/home/**` because with
1276        // proper globset matching, `*` no longer crosses path separators.
1277        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        // Matching path
1296        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        // Non-matching path
1303        let action = make_action_with_paths("fs", "read", vec!["/etc/passwd"]);
1304        assert_eq!(engine.evaluate(&action, &ctx), AbacDecision::NoMatch);
1305
1306        // No paths at all → fails resource match
1307        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        // Missing tag
1366        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        // Both match
1546        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        // Only one matches
1558        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        // Higher priority permit should be reported even if lower priority forbid exists
1573        // But forbid-overrides means forbid always wins regardless of priority
1574        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        // Forbid wins even with lower priority
1604        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        // Policy requires specific principal type, context has different type
1613        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        // No match → fail-closed at the caller
1632        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        // Chain: a → b → c → d (transitive)
1726        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    // ════════════════════════════════════════════════════════
1863    // FIND-R44-001: Diamond-shaped membership graph
1864    // ════════════════════════════════════════════════════════
1865
1866    #[test]
1867    fn test_diamond_membership_no_exponential_blowup() {
1868        // Diamond graph: entity → A, entity → B, A → top, B → top
1869        // Without visited-set, checking membership in "top" would visit
1870        // the "top" node twice. With wider diamonds this becomes exponential.
1871        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        // Create a wide diamond: entity → [g0..g15] → top
1907        // Without visited-set this could be slow; with it, each node visited once.
1908        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    // ════════════════════════════════════════════════════════
1936    // FIND-R46-001: Path normalization in ABAC resource matching
1937    // ════════════════════════════════════════════════════════
1938
1939    #[test]
1940    fn test_r46_001_path_traversal_normalized_in_resource_match() {
1941        // A path like "/home/../etc/passwd" should be normalized to "/etc/passwd"
1942        // and should NOT match a policy for "/home/**".
1943        // FIND-P1-5: Updated from `/home/*` to `/home/**` because with
1944        // proper globset matching, `*` no longer crosses path separators.
1945        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        // Traversal path should normalize to /etc/passwd, not match /home/**
1964        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        // Normal home path should still match
1972        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    // ════════════════════════════════════════════════════════
1980    // FIND-R46-002: Domain normalization in ABAC resource matching
1981    // ════════════════════════════════════════════════════════
1982
1983    #[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        // Mixed case should be normalized to lowercase
2004        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        // Trailing dot should be trimmed
2011        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    // ════════════════════════════════════════════════════════
2019    // FIND-R46-008: Empty conditions compile-time validation
2020    // ════════════════════════════════════════════════════════
2021
2022    #[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        // Cycle: a → b → c → a (should terminate via depth or visited-set)
2048        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        // Should terminate without stack overflow
2070        assert!(!store.is_member_of("G::a", "G::nonexistent"));
2071        // Self-cycle should still find transitive membership
2072        assert!(store.is_member_of("G::a", "G::b"));
2073    }
2074
2075    // ════════════════════════════════════════════════════════
2076    // FIND-P1-5: ABAC resource path matching uses globset
2077    // ════════════════════════════════════════════════════════
2078
2079    #[test]
2080    fn test_p1_5_glob_double_star_path_matching() {
2081        // `**/*.txt` should match nested paths — PatternMatcher cannot do this
2082        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        // Nested txt file should match
2101        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        // Deeply nested txt file should match
2108        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        // Non-txt file should not match
2115        let action = make_action_with_paths("fs", "read", vec!["/data/subdir/file.json"]);
2116        assert_eq!(engine.evaluate(&action, &ctx), AbacDecision::NoMatch);
2117
2118        // Path outside /data should not match
2119        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        // `/home/*/config` should match single-level wildcard
2126        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        // Nested path should NOT match single-star (unlike **)
2151        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        // `/tmp/file?.log` should match single character wildcard
2158        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        // Two characters should not match single ?
2183        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        // Exact paths (no wildcards) should still work via PatternMatcher
2190        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        // `[` triggers glob compilation — test bracket character class
2221        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    // ════════════════════════════════════════════════════════
2250    // FIND-R215-004: ABAC principal ID homoglyph normalization
2251    // ════════════════════════════════════════════════════════
2252
2253    #[test]
2254    fn test_r215_004_principal_id_homoglyph_normalization() {
2255        // Principal ID pattern "admin*" should match a Cyrillic-homoglyph variant
2256        // because normalize_homoglyphs maps Cyrillic 'а' (U+0430) to Latin 'a'.
2257        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        // Cyrillic 'а' (U+0430) visually identical to Latin 'a'
2277        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    // ════════════════════════════════════════════════════════
2285    // FIND-R215-005: ABAC principal claims homoglyph normalization
2286    // ════════════════════════════════════════════════════════
2287
2288    #[test]
2289    fn test_r215_005_principal_claims_homoglyph_normalization() {
2290        // Claim pattern "security*" should match Cyrillic-homoglyph variant
2291        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        // Cyrillic 'ѕ' (U+0455) looks like Latin 's', 'е' (U+0435) looks like 'e'
2311        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    // ════════════════════════════════════════════════════════
2334    // FIND-R215-006: compare_numbers NaN guard
2335    // ════════════════════════════════════════════════════════
2336
2337    #[test]
2338    fn test_r215_006_compare_numbers_nan_guard() {
2339        // NaN in risk.score should cause Permit condition to not match (fail-closed)
2340        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        // NaN risk score — resolve_field treats non-finite as 1.0 (max risk),
2358        // and compare_numbers guards against non-finite operands. Either way,
2359        // the permit condition should NOT match.
2360        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        // Infinity should also fail-closed
2381        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    // ════════════════════════════════════════════════════════
2417    // FIND-R215-007: ABAC principal_type homoglyph normalization
2418    // ════════════════════════════════════════════════════════
2419
2420    #[test]
2421    fn test_r215_007_principal_type_homoglyph_normalization() {
2422        // Required type "Agent" should match Cyrillic-homoglyph "Аgent"
2423        // (Cyrillic 'А' U+0410 looks like Latin 'A')
2424        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        // Cyrillic 'А' (U+0410) visually identical to Latin 'A'
2444        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    // ── R237-ENG-3/5: ABAC conditions normalize_full() for string ops ──
2455
2456    /// R237-ENG-3: Eq condition normalizes both sides through normalize_full(),
2457    /// so Cyrillic 'а' (U+0430) in the claim value matches Latin 'a' in the
2458    /// condition value.
2459    #[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                // Policy condition expects Latin "acme"
2473                value: serde_json::json!("acme"),
2474            }],
2475        };
2476        let engine = make_engine(vec![policy]);
2477
2478        // Claim uses Cyrillic 'а' (U+0430) in place of Latin 'a'
2479        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    /// R237-ENG-3: Ne condition also normalizes, so homoglyphs are treated as equal
2494    /// and Ne returns false (NoMatch — the values are equal after normalization).
2495    #[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        // Cyrillic 'а' should normalize to Latin 'a', making values equal,
2514        // so Ne is false and the Forbid policy does not match.
2515        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    /// R237-ENG-5: In condition normalizes both the field value and each list element.
2528    #[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        // Claim uses all-lowercase "acme" — normalize_full lowercases both sides
2547        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    /// R237-ENG-5: NotIn condition normalizes — homoglyph value IS in the list
2562    /// after normalization, so NotIn is false (NoMatch for the Forbid policy).
2563    #[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        // Cyrillic 'а' normalizes to Latin 'a', so "аcme" IS in the list
2582        // → NotIn is false → Forbid does not match
2583        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    /// R237-ENG-5: Contains condition normalizes — Cyrillic substring matches.
2596    /// Uses Cyrillic 'а' (U+0430→'a'), 'с' (U+0441→'c'), 'е' (U+0435→'e')
2597    /// so the haystack "my-\u{0430}\u{0441}m\u{0435}-agent" normalizes to "my-acme-agent".
2598    #[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                // Policy expects Latin "acme"
2612                value: serde_json::json!("acme"),
2613            }],
2614        };
2615        let engine = make_engine(vec![policy]);
2616
2617        // Claim uses Cyrillic homoglyphs: а(U+0430→a), с(U+0441→c), е(U+0435→e)
2618        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    /// R237-ENG-5: StartsWith condition normalizes — case difference matches.
2633    #[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                // Policy expects lowercase "prod-"
2647                value: serde_json::json!("prod-"),
2648            }],
2649        };
2650        let engine = make_engine(vec![policy]);
2651
2652        // Claim uses mixed case "PROD-agent-1"
2653        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}