Coverage for src/lexigram/auth/policies/evaluator.py: 81%
104 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 00:58 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 00:58 +0800
1"""Condition evaluators for ABAC Policy Engine."""
3from __future__ import annotations
5import re
6import threading
7from typing import TYPE_CHECKING, Any, Protocol, cast
9if TYPE_CHECKING:
10 from collections.abc import Callable
12 from lexigram.auth.policies.types import Condition
15class OperatorHandlerProtocol(Protocol):
16 """Protocol for operator handlers."""
18 def compare(self, actual: Any, expected: Any) -> bool:
19 """Compare actual value against expected."""
20 ...
23class EqualsOperator:
24 def compare(self, actual: Any, expected: Any) -> bool:
25 return cast("bool", actual == expected)
28class NotEqualsOperator:
29 def compare(self, actual: Any, expected: Any) -> bool:
30 return cast("bool", actual != expected)
33class ContainsOperator:
34 def compare(self, actual: Any, expected: Any) -> bool:
35 return expected in actual
38class NotContainsOperator:
39 def compare(self, actual: Any, expected: Any) -> bool:
40 return expected not in actual
43class InOperator:
44 def compare(self, actual: Any, expected: Any) -> bool:
45 return actual in expected
48class NotInOperator:
49 def compare(self, actual: Any, expected: Any) -> bool:
50 return actual not in expected
53class MatchesOperator:
54 def compare(self, actual: Any, expected: Any) -> bool:
55 return bool(re.match(str(expected), str(actual)))
58class GreaterThanOperator:
59 def compare(self, actual: Any, expected: Any) -> bool:
60 try:
61 return cast("bool", actual > expected)
62 except TypeError:
63 return False
66class LessThanOperator:
67 def compare(self, actual: Any, expected: Any) -> bool:
68 try:
69 return cast("bool", actual < expected)
70 except TypeError:
71 return False
74class GreaterThanOrEqualsOperator:
75 def compare(self, actual: Any, expected: Any) -> bool:
76 try:
77 return cast("bool", actual >= expected)
78 except TypeError:
79 return False
82class LessThanOrEqualsOperator:
83 def compare(self, actual: Any, expected: Any) -> bool:
84 try:
85 return cast("bool", actual <= expected)
86 except TypeError:
87 return False
90class OperatorRegistry:
91 """Registry for condition operators."""
93 def __init__(self) -> None:
94 self._lock = threading.Lock()
95 self._handlers: dict[str, OperatorHandlerProtocol] = {}
97 @classmethod
98 def with_defaults(cls) -> OperatorRegistry:
99 """Create a registry pre-loaded with the standard operator handlers."""
100 instance = cls()
101 instance._register_default_handlers()
102 return instance
104 def _register_default_handlers(self) -> None:
105 self._handlers = {
106 "equals": EqualsOperator(),
107 "not_equals": NotEqualsOperator(),
108 "contains": ContainsOperator(),
109 "not_contains": NotContainsOperator(),
110 "in": InOperator(),
111 "not_in": NotInOperator(),
112 "matches": MatchesOperator(),
113 "greater_than": GreaterThanOperator(),
114 "less_than": LessThanOperator(),
115 "greater_than_or_equals": GreaterThanOrEqualsOperator(),
116 "less_than_or_equals": LessThanOrEqualsOperator(),
117 }
119 def register_handler(self, operator: str, handler: OperatorHandlerProtocol) -> None:
120 """Register a custom operator handler."""
121 with self._lock:
122 self._handlers[operator] = handler
124 def compare(self, actual: Any, operator: str, expected: Any) -> bool:
125 """Compare using the registered handler for the operator."""
126 with self._lock:
127 handler = self._handlers.get(operator)
128 if handler:
129 return handler.compare(actual, expected)
130 return False
133class ConditionEvaluator:
134 """Evaluates individual policy conditions against a request context."""
136 def __init__(self) -> None:
137 self._operator_registry = OperatorRegistry.with_defaults()
138 # Cache compiled accessor functions keyed by attribute path string.
139 # Each accessor is a Callable[[Any], Any] that traverses the path
140 # without re-splitting on every evaluation call.
141 self._path_cache: dict[str, Callable[[Any], Any]] = {}
143 @staticmethod
144 def _compile_path(path: str) -> Callable[[Any], Any]:
145 """Compile an attribute path string into a reusable accessor function.
147 Args:
148 path: Dot-separated attribute path, e.g. ``"user.department"``.
150 Returns:
151 A callable that accepts a context dict or object and traverses
152 the path, returning ``None`` if any segment is absent.
153 """
154 parts = path.split(".")
156 def accessor(obj: Any) -> Any:
157 current = obj
158 for part in parts:
159 if isinstance(current, dict):
160 if part not in current:
161 return None
162 current = current[part]
163 elif hasattr(current, part):
164 current = getattr(current, part)
165 else:
166 return None
167 return current
169 return accessor
171 def evaluate(self, condition: Condition, context: dict[str, Any]) -> bool:
172 """Evaluate a single condition against the context."""
173 actual_val = self._resolve_attribute(condition.attribute, context)
175 # Variable substitution in expected value (e.g., "expected": "${user.id}")
176 expected_val = condition.value
177 if (
178 isinstance(expected_val, str)
179 and expected_val.startswith("${")
180 and expected_val.endswith("}")
181 ):
182 var_path = expected_val[2:-1]
183 expected_val = self._resolve_attribute(var_path, context)
185 return self._operator_registry.compare(
186 actual_val,
187 condition.operator,
188 expected_val,
189 )
191 def _resolve_attribute(self, path: str, context: dict[str, Any]) -> Any:
192 """Resolve a nested attribute from the context using a cached accessor."""
193 accessor = self._path_cache.get(path)
194 if accessor is None:
195 accessor = self._compile_path(path)
196 self._path_cache[path] = accessor
197 return accessor(context)
200__all__ = [
201 "ConditionEvaluator",
202 "ContainsOperator",
203 "EqualsOperator",
204 "GreaterThanOperator",
205 "GreaterThanOrEqualsOperator",
206 "InOperator",
207 "LessThanOperator",
208 "LessThanOrEqualsOperator",
209 "MatchesOperator",
210 "NotContainsOperator",
211 "NotEqualsOperator",
212 "NotInOperator",
213 "OperatorHandlerProtocol",
214 "OperatorRegistry",
215]