Coverage for src/lexigram/auth/policies/engine.py: 76%

154 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-26 00:58 +0800

1"""Policy Engine for ABAC evaluation.""" 

2 

3from __future__ import annotations 

4 

5import re 

6import threading 

7from typing import TYPE_CHECKING, Literal, Protocol 

8 

9from lexigram.auth.policies.evaluator import ConditionEvaluator 

10from lexigram.auth.policies.types import ( 

11 AuthorizationDecision, 

12 AuthorizationRequest, 

13 DecisionOutcome, 

14 Policy, 

15 PolicyEffect, 

16) 

17from lexigram.logging import get_logger 

18 

19if TYPE_CHECKING: 

20 from lexigram.contracts.auth.policy import PolicyStoreProtocol 

21 

22logger = get_logger(__name__) 

23 

24 

25# ============================================================================= 

26# Pattern Matcher Registry 

27# ============================================================================= 

28 

29 

30class PatternMatcher(Protocol): 

31 """Protocol for pattern matchers.""" 

32 

33 def can_match(self, pattern: str) -> bool: 

34 """Check if this matcher can handle the given pattern.""" 

35 ... 

36 

37 def matches(self, pattern: str, target: str) -> bool: 

38 """Check if the target matches the pattern.""" 

39 ... 

40 

41 

42class ExactPatternMatcher: 

43 """Matches exact string patterns without wildcards.""" 

44 

45 def can_match(self, pattern: str) -> bool: 

46 return "*" not in pattern 

47 

48 def matches(self, pattern: str, target: str) -> bool: 

49 return pattern == target 

50 

51 

52class WildcardPatternMatcher: 

53 """Matches wildcard patterns using regex.""" 

54 

55 def can_match(self, pattern: str) -> bool: 

56 return "*" in pattern 

57 

58 def matches(self, pattern: str, target: str) -> bool: 

59 regex = "^" + pattern.replace("*", ".*") + "$" 

60 return bool(re.match(regex, target)) 

61 

62 

63class GlobPatternMatcher: 

64 """Matches glob-style patterns (e.g., 'user.*' matches 'user.read').""" 

65 

66 def can_match(self, pattern: str) -> bool: 

67 return "." in pattern and "*" in pattern 

68 

69 def matches(self, pattern: str, target: str) -> bool: 

70 # Convert glob pattern to regex 

71 regex_pattern = pattern.replace(".", r"\.").replace("*", ".*") 

72 regex = f"^{regex_pattern}$" 

73 return bool(re.match(regex, target)) 

74 

75 

76class PatternMatcherRegistry: 

77 """Registry for pattern matchers. 

78 

79 Provides extensible pattern matching for policy evaluation. 

80 """ 

81 

82 def __init__(self) -> None: 

83 self._lock = threading.Lock() 

84 self._matchers: list[PatternMatcher] = [] 

85 

86 @classmethod 

87 def with_defaults(cls) -> PatternMatcherRegistry: 

88 """Create a registry pre-loaded with the standard pattern matchers.""" 

89 instance = cls() 

90 instance._register_default_matchers() 

91 return instance 

92 

93 def _register_default_matchers(self) -> None: 

94 """Register the default pattern matchers.""" 

95 self._matchers = [ 

96 ExactPatternMatcher(), 

97 WildcardPatternMatcher(), 

98 ] 

99 

100 def register_matcher(self, matcher: PatternMatcher) -> None: 

101 """Register a custom pattern matcher.""" 

102 with self._lock: 

103 self._matchers.insert(0, matcher) 

104 

105 def matches(self, pattern: str, target: str) -> bool: 

106 """Check if the target matches the pattern using registered matchers.""" 

107 with self._lock: 

108 matchers = list(self._matchers) 

109 for matcher in matchers: 

110 if matcher.can_match(pattern): 

111 return matcher.matches(pattern, target) 

112 return False 

113 

114 

115class PolicyEngine: 

116 """Evaluates authorization requests against a collection of policies. 

117 

118 Three evaluation strategies are supported, selected at construction time via 

119 the ``strategy`` parameter: 

120 

121 * ``"deny_first"`` *(default)* — the first **DENY** match short-circuits 

122 evaluation and the request is immediately rejected. Any subsequent 

123 ALLOW policies are never reached. This is the most secure default and 

124 the one mandated by most enterprise security frameworks. 

125 

126 * ``"allow_first"`` — the first **ALLOW** match short-circuits evaluation 

127 and the request is immediately granted. Useful for additive permission 

128 models where policies are non-conflicting. 

129 

130 * ``"unanimous"`` — every matching policy must be an ALLOW; a single DENY 

131 (or INDETERMINATE, i.e. no matching ALLOW) causes the request to be 

132 denied. Suitable for high-security contexts where all gates must pass. 

133 """ 

134 

135 def __init__( 

136 self, 

137 policies: list[Policy] | None = None, 

138 *, 

139 store: PolicyStoreProtocol | None = None, 

140 strategy: Literal["deny_first", "allow_first", "unanimous"] = "deny_first", 

141 ) -> None: 

142 """Initialise the policy engine with a static list and/or a store. 

143 

144 Args: 

145 policies: Optional list of in-memory policies. Sorted by 

146 priority (descending) so that the highest-priority policy 

147 wins on conflict. 

148 store: Optional :class:`~lexigram.contracts.auth.policy.PolicyStoreProtocol` 

149 used to load/persist policies asynchronously. When provided, 

150 call :meth:`load_from_store` during application boot to merge 

151 the stored policies with any static ones. 

152 strategy: Evaluation strategy controlling short-circuit behaviour. 

153 

154 * ``"deny_first"`` — first DENY wins (most secure, default). 

155 * ``"allow_first"`` — first ALLOW wins (additive model). 

156 * ``"unanimous"`` — all matching policies must be ALLOW. 

157 """ 

158 self.policies: list[Policy] = sorted( 

159 policies or [], 

160 key=lambda p: p.priority, 

161 reverse=True, 

162 ) 

163 self._store: PolicyStoreProtocol | None = store 

164 self._strategy: Literal["deny_first", "allow_first", "unanimous"] = strategy 

165 self.evaluator = ConditionEvaluator() 

166 self._pattern_matchers = PatternMatcherRegistry.with_defaults() 

167 # Resource-pattern index for O(1) candidate lookup in evaluate(). 

168 # Keys are exact resource patterns; the special key "" covers catch-all 

169 # policies (empty resources list). Wildcard-containing patterns are 

170 # stored separately in _wildcard_resource_policies so they are always 

171 # checked via pattern matching. 

172 self._resource_index: dict[str, list[Policy]] = {} 

173 self._wildcard_resource_policies: list[Policy] = [] 

174 self._rebuild_resource_index() 

175 

176 def __repr__(self) -> str: 

177 """Return developer-friendly string representation.""" 

178 return f"PolicyEngine(policies={len(self.policies)})" 

179 

180 def _rebuild_resource_index(self) -> None: 

181 """Rebuild the resource-pattern index from ``self.policies``. 

182 

183 Exact resource patterns are indexed for O(1) lookup. Wildcard 

184 patterns (containing ``*``) are gathered in 

185 ``_wildcard_resource_policies`` so they are always evaluated via 

186 the full pattern-match path. Catch-all policies (empty 

187 ``resources`` list) are stored under the ``""`` key. 

188 """ 

189 index: dict[str, list[Policy]] = {} 

190 wildcard: list[Policy] = [] 

191 for policy in self.policies: 

192 if not policy.resources: 

193 index.setdefault("", []).append(policy) 

194 else: 

195 has_wildcard = False 

196 for pattern in policy.resources: 

197 if "*" in pattern: 

198 has_wildcard = True 

199 else: 

200 index.setdefault(pattern, []).append(policy) 

201 if has_wildcard: 

202 wildcard.append(policy) 

203 self._resource_index = index 

204 self._wildcard_resource_policies = wildcard 

205 

206 async def load_from_store(self) -> None: 

207 """Load policies from the configured :class:`PolicyStoreProtocol`. 

208 

209 Fetches all policies from the store and merges them with any 

210 statically registered policies, re-sorting the combined list by 

211 priority. A no-op if no store was provided at construction time. 

212 

213 Raises: 

214 RuntimeError: If no store was provided when this method is called. 

215 """ 

216 if self._store is None: 

217 return 

218 stored = await self._store.load_policies() 

219 merged = {**{p.name: p for p in self.policies}, **{p.name: p for p in stored}} 

220 self.policies = sorted(merged.values(), key=lambda p: p.priority, reverse=True) 

221 self._rebuild_resource_index() 

222 logger.info("policy_engine.loaded_from_store", count=len(stored)) 

223 

224 async def save_policy(self, policy: Policy) -> None: 

225 """Persist a policy to the store and add it to the in-memory list. 

226 

227 Args: 

228 policy: The policy to persist. 

229 

230 Raises: 

231 RuntimeError: If no store was configured. 

232 """ 

233 if self._store is None: 

234 msg = "Cannot persist policy: PolicyEngine has no store configured" 

235 raise RuntimeError(msg) 

236 await self._store.save_policy(policy) 

237 # Merge into in-memory list (replace any existing policy with same name) 

238 self.policies = sorted( 

239 [p for p in self.policies if p.name != policy.name] + [policy], 

240 key=lambda p: p.priority, 

241 reverse=True, 

242 ) 

243 self._rebuild_resource_index() 

244 logger.info("policy_engine.policy_saved", policy=policy.name) 

245 

246 def evaluate(self, request: AuthorizationRequest) -> AuthorizationDecision: 

247 """Evaluate an authorization request against loaded policies. 

248 

249 Short-circuit behaviour is governed by ``self._strategy``: 

250 

251 * ``deny_first`` — first DENY match returns immediately. 

252 * ``allow_first`` — first ALLOW match returns immediately. 

253 * ``unanimous`` — every matching policy must be ALLOW; a single DENY 

254 or the absence of any ALLOW yields DENY/INDETERMINATE respectively. 

255 """ 

256 # Build candidate set using the resource index to avoid a full scan. 

257 # Priority order is preserved via sorted self.policies insertion order. 

258 seen: set[str] = set() 

259 candidates: list[Policy] = [] 

260 

261 # 1. Catch-all policies (empty resources list) 

262 for p in self._resource_index.get("", []): 

263 if p.policy_id not in seen: 

264 seen.add(p.policy_id) 

265 candidates.append(p) 

266 

267 # 2. Exact resource-pattern match 

268 for p in self._resource_index.get(request.resource, []): 

269 if p.policy_id not in seen: 

270 seen.add(p.policy_id) 

271 candidates.append(p) 

272 

273 # 3. Wildcard resource patterns (must still be pattern-matched below) 

274 for p in self._wildcard_resource_policies: 

275 if p.policy_id not in seen: 

276 seen.add(p.policy_id) 

277 candidates.append(p) 

278 

279 # Re-sort the smaller candidate set by priority (descending) 

280 candidates.sort(key=lambda p: p.priority, reverse=True) 

281 

282 matched_policies: list[str] = [] 

283 allow_found = False 

284 

285 for policy in candidates: 

286 if not self._matches(policy, request): 

287 continue 

288 

289 matched_policies.append(policy.policy_id) 

290 

291 if self._strategy == "deny_first": 

292 # First DENY wins — most secure default. 

293 if policy.effect == PolicyEffect.DENY: 

294 logger.info("Access DENIED by policy (deny_first): %s", policy.name) 

295 return AuthorizationDecision( 

296 decision=DecisionOutcome.DENY, 

297 reason=f"Denied by policy: {policy.name}", 

298 applied_policies=matched_policies, 

299 ) 

300 if policy.effect == PolicyEffect.ALLOW: 

301 allow_found = True 

302 

303 elif self._strategy == "allow_first": 

304 # First ALLOW wins — additive model, stops on first grant. 

305 if policy.effect == PolicyEffect.ALLOW: 

306 logger.info( 

307 "Access ALLOWED by policy (allow_first): %s", policy.name 

308 ) 

309 return AuthorizationDecision( 

310 decision=DecisionOutcome.ALLOW, 

311 reason=f"Allowed by policy: {policy.name}", 

312 applied_policies=matched_policies, 

313 ) 

314 if policy.effect == PolicyEffect.DENY: 

315 allow_found = False # keep scanning but track deny seen 

316 

317 else: # unanimous 

318 # Every matching policy must be ALLOW; a single DENY short-circuits. 

319 if policy.effect == PolicyEffect.DENY: 

320 logger.info("Access DENIED by policy (unanimous): %s", policy.name) 

321 return AuthorizationDecision( 

322 decision=DecisionOutcome.DENY, 

323 reason=f"Denied by policy: {policy.name}", 

324 applied_policies=matched_policies, 

325 ) 

326 if policy.effect == PolicyEffect.ALLOW: 

327 allow_found = True 

328 

329 if allow_found: 

330 return AuthorizationDecision( 

331 decision=DecisionOutcome.ALLOW, 

332 applied_policies=matched_policies, 

333 ) 

334 

335 return AuthorizationDecision( 

336 decision=DecisionOutcome.INDETERMINATE, 

337 reason="No matching policies found", 

338 applied_policies=matched_policies, 

339 ) 

340 

341 def _matches(self, policy: Policy, request: AuthorizationRequest) -> bool: 

342 """Check if a policy applies to the given request.""" 

343 # 1. Match Action 

344 if not self._pattern_match(policy.actions, request.action): 

345 return False 

346 

347 # 2. Match Resource 

348 if not self._pattern_match(policy.resources, request.resource): 

349 return False 

350 

351 # 3. Match Principal 

352 if not self._pattern_match(policy.principals, request.principal): 

353 return False 

354 

355 # 4. Evaluate Conditions 

356 for cond in policy.conditions: 

357 if not self.evaluator.evaluate(cond, request.context): 

358 return False 

359 

360 return True 

361 

362 def _pattern_match(self, patterns: list[str], target: str) -> bool: 

363 """Check if target matches any of the patterns using the registry.""" 

364 if not patterns: 

365 return True # Empty means matches all 

366 

367 for pattern in patterns: 

368 if self._pattern_matchers.matches(pattern, target): 

369 return True 

370 

371 return False 

372 

373 

374__all__ = [ 

375 "ExactPatternMatcher", 

376 "GlobPatternMatcher", 

377 "PatternMatcher", 

378 "PatternMatcherRegistry", 

379 "PolicyEngine", 

380 "WildcardPatternMatcher", 

381 "logger", 

382]