Coverage for src/lexigram/auth/authz/_check_mixin.py: 22%

208 statements  

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

1"""Authorization check mixin for AuthorizationService.""" 

2 

3from __future__ import annotations 

4 

5from typing import TYPE_CHECKING, Any 

6 

7from lexigram.contracts.audit import AuditEntry, AuditLoggerProtocol 

8from lexigram.logging import get_logger 

9from lexigram.primitives import clock as ambient_clock 

10from lexigram.result import Ok, Result 

11 

12if TYPE_CHECKING: 

13 from lexigram.auth.authz._parsers import ValueParserRegistry 

14 from lexigram.auth.exceptions import AuthorizationError 

15 

16logger = get_logger(__name__) 

17 

18 

19class _AuthCheckMixin: 

20 """Mixin providing authorization check methods for AuthorizationService.""" 

21 

22 # Type annotations for attributes provided by AuthorizationService.__init__. 

23 # Declared here so type checkers understand the mixin's assumptions. 

24 _roles: dict[str, Any] 

25 _policy_engine: Any | None 

26 _permission_cache: dict[str, tuple[float, set[str]]] 

27 _permission_cache_ttl: float 

28 _max_cache_entries: int 

29 _audit_logger: AuditLoggerProtocol | None 

30 _role_flatten_cache: dict[frozenset[str], tuple[float, set[str]]] 

31 _value_parsers: ValueParserRegistry 

32 

33 def _parse_list(self, val: Any) -> list[str]: 

34 """Parse a value into a list of strings using the registry.""" 

35 return self._value_parsers.parse(val) 

36 

37 async def check_access( 

38 self, 

39 user: Any, 

40 allowed_roles: set[str], 

41 resource: str | None = None, 

42 action: str | None = None, 

43 ) -> bool: 

44 """Core access check combining roles, inheritance, and permission patterns.""" 

45 if "*" in allowed_roles: 

46 return True 

47 

48 if not user: 

49 return False 

50 

51 # Gather user roles 

52 user_roles = set() 

53 

54 if hasattr(user, "roles") and user.roles: 

55 user_roles.update(self._parse_list(user.roles)) 

56 if hasattr(user, "role") and user.role: 

57 user_roles.add(str(user.role)) 

58 # Check superuser/admin bypass 

59 if ( 

60 getattr(user, "is_superuser", False) 

61 or "admin" in user_roles 

62 or "superuser" in user_roles 

63 ): 

64 return True 

65 

66 # 1. Direct role check 

67 if bool(user_roles & allowed_roles): 

68 return True 

69 

70 # 2. Inheritance check 

71 effective_roles = self._flatten_roles(user_roles) 

72 if bool(effective_roles & allowed_roles): 

73 return True 

74 

75 # 3. Permission Pattern check (if resource/action provided) 

76 if resource and action: 

77 # 1. Gather context for ABAC evaluation 

78 context = { 

79 "user": user, 

80 "resource": resource, 

81 "action": action, 

82 "resource_id": resource.rsplit(":", maxsplit=1)[-1] 

83 if ":" in resource 

84 else None, 

85 "request": getattr(user, "_request_metadata", {}), 

86 "delegations": getattr(user, "delegations", []), 

87 } 

88 

89 # 2. ABAC Policy evaluation 

90 if hasattr(self, "_policy_engine") and self._policy_engine: 

91 from lexigram.auth.policies.types import ( 

92 AuthorizationRequest, 

93 DecisionOutcome, 

94 ) 

95 

96 # Principal identity e.g., "user:123" 

97 principal = f"user:{getattr(user, 'user_id', 'unknown')}" 

98 

99 req = AuthorizationRequest( 

100 principal=principal, 

101 action=action, 

102 resource=resource, 

103 context=context, 

104 ) 

105 

106 abac_decision = self._policy_engine.evaluate(req) 

107 if abac_decision.decision == DecisionOutcome.DENY: 

108 logger.info("ABAC: Access DENIED by policy for %s", principal) 

109 return False 

110 if abac_decision.decision == DecisionOutcome.ALLOW: 

111 logger.info("ABAC: Access ALLOWED by policy for %s", principal) 

112 return True 

113 

114 # 3. Permission Pattern check (Fallback if no ABAC policy matched) 

115 user_id = getattr(user, "user_id", None) or getattr(user, "id", None) 

116 now = ambient_clock.monotonic() 

117 

118 user_perms = None 

119 if user_id: 

120 cache_entry = self._permission_cache.get(str(user_id)) 

121 if cache_entry: 

122 expiry, cached_perms = cache_entry 

123 if expiry > now: 

124 user_perms = cached_perms 

125 

126 if user_perms is None: 

127 user_perms = self._get_user_permissions(effective_roles) 

128 # Mix in direct user permissions 

129 if hasattr(user, "permissions") and user.permissions: 

130 user_perms.update(self._parse_list(user.permissions)) 

131 

132 if user_id: 

133 # Bounded cache: evict the oldest entry (by insertion order) 

134 # when the cache is at capacity. Python 3.7+ dicts preserve 

135 # insertion order, so ``next(iter(...))`` is always the entry 

136 # that was added earliest. The effective eviction policy is 

137 # FIFO (first-in, first-out), not true recency-based LRU. 

138 # ``max_cache_entries`` (default 10 000) caps memory usage. 

139 if len(self._permission_cache) >= self._max_cache_entries: 

140 oldest_key = next(iter(self._permission_cache)) 

141 del self._permission_cache[oldest_key] 

142 self._permission_cache[str(user_id)] = ( 

143 now + self._permission_cache_ttl, 

144 user_perms, 

145 ) 

146 

147 required_perm = f"{resource}.{action}" 

148 if self._has_permission(user_perms, required_perm): 

149 return True 

150 

151 # 4. Permission Delegation check 

152 delegations = context.get("delegations") 

153 # Also check user.delegations if injected there 

154 if not delegations and hasattr(user, "delegations"): 

155 delegations = user.delegations 

156 

157 if delegations: 

158 for delegation in delegations: 

159 # Match Action and Resource 

160 if ( 

161 action in delegation.permissions 

162 or "*" in delegation.permissions 

163 ) and self._has_permission(set(delegation.resources), resource): 

164 logger.info( 

165 "Access ALLOWED via delegation %s (from %s)", 

166 delegation.delegation_id, 

167 delegation.delegator_id, 

168 ) 

169 return True 

170 

171 return False 

172 

173 def has_any_role(self, user: Any, roles: list[str]) -> bool: 

174 """Check if user has any of the given roles.""" 

175 if not user: 

176 return False 

177 

178 user_roles = set() 

179 user_roles_attr = ( 

180 user.get("roles") 

181 if isinstance(user, dict) 

182 else getattr(user, "roles", None) 

183 ) 

184 if user_roles_attr: 

185 user_roles.update(self._parse_list(user_roles_attr)) 

186 user_role_attr = ( 

187 user.get("role") if isinstance(user, dict) else getattr(user, "role", None) 

188 ) 

189 if user_role_attr: 

190 user_roles.add(str(user_role_attr)) 

191 

192 # Check superuser/admin bypass 

193 is_super = ( 

194 user.get("is_superuser", False) 

195 if isinstance(user, dict) 

196 else getattr(user, "is_superuser", False) 

197 ) 

198 if is_super or "admin" in user_roles or "superuser" in user_roles: 

199 return True 

200 

201 effective_roles = self._flatten_roles(user_roles) 

202 return bool(effective_roles.intersection(roles)) 

203 

204 def has_any_permission(self, user: Any, permissions: list[str]) -> bool: 

205 """Return True if user has at least one of the given permissions.""" 

206 if not user: 

207 return False 

208 

209 user_roles = set() 

210 user_roles_attr = ( 

211 user.get("roles") 

212 if isinstance(user, dict) 

213 else getattr(user, "roles", None) 

214 ) 

215 if user_roles_attr: 

216 user_roles.update(self._parse_list(user_roles_attr)) 

217 user_role_attr = ( 

218 user.get("role") if isinstance(user, dict) else getattr(user, "role", None) 

219 ) 

220 if user_role_attr: 

221 user_roles.add(str(user_role_attr)) 

222 

223 # Check superuser/admin bypass 

224 is_super = ( 

225 user.get("is_superuser", False) 

226 if isinstance(user, dict) 

227 else getattr(user, "is_superuser", False) 

228 ) 

229 if is_super or "admin" in user_roles or "superuser" in user_roles: 

230 return True 

231 

232 effective_roles = self._flatten_roles(user_roles) 

233 user_perms = self._get_user_permissions(effective_roles) 

234 

235 user_perms_attr = ( 

236 user.get("permissions") 

237 if isinstance(user, dict) 

238 else getattr(user, "permissions", None) 

239 ) 

240 if user_perms_attr: 

241 user_perms.update(self._parse_list(user_perms_attr)) 

242 

243 for req_perm in permissions: 

244 if self._has_permission(user_perms, req_perm): 

245 return True 

246 

247 return False 

248 

249 async def can(self, user: Any, action: str, resource: str) -> bool: 

250 """Convenience alias: return True if user can perform action on resource. 

251 

252 Delegates to :meth:`authorize` and unwraps the result, returning 

253 ``False`` on ``Err`` (i.e. if the authorization check itself fails). 

254 """ 

255 result = await self.authorize(user, action, resource) 

256 return result.unwrap_or(False) 

257 

258 async def can_view(self, user: Any, resource: str, record: Any = None) -> bool: 

259 """Grant view when the user holds ``<resource>.view`` (record ignored for now).""" 

260 return self.has_any_permission(user, [f"{resource}.view"]) 

261 

262 async def can_create(self, user: Any, resource: str) -> bool: 

263 """Grant create when the user holds ``<resource>.create``.""" 

264 return self.has_any_permission(user, [f"{resource}.create"]) 

265 

266 async def can_update(self, user: Any, resource: str, record: Any = None) -> bool: 

267 """Grant update when the user holds ``<resource>.update`` (record ignored for now).""" 

268 return self.has_any_permission(user, [f"{resource}.update"]) 

269 

270 async def can_delete(self, user: Any, resource: str, record: Any = None) -> bool: 

271 """Grant delete when the user holds ``<resource>.delete`` (record ignored for now).""" 

272 return self.has_any_permission(user, [f"{resource}.delete"]) 

273 

274 async def can_execute_action( 

275 self, user: Any, resource: str, action: str, record: Any | None = None 

276 ) -> bool: 

277 """Grant action execution when the user holds ``<resource>.<action>``.""" 

278 return self.has_any_permission(user, [f"{resource}.{action}"]) 

279 

280 async def authorize( 

281 self, user: Any, action: str, resource: Any 

282 ) -> Result[bool, AuthorizationError]: 

283 """Check whether *user* is allowed to perform *action* on *resource*. 

284 

285 This is the primary authorization method. Satisfies the 

286 :class:`~lexigram.contracts.auth.guard.AuthorizerProtocol` protocol (note: 

287 the contracts protocol declares ``-> bool``; this implementation 

288 returns ``Result[bool, AuthorizationError]`` to make failures 

289 explicit. Use :meth:`can` when a plain ``bool`` is required). 

290 

291 ``Ok(True)`` — access granted. 

292 ``Ok(False)`` — access denied. 

293 ``Err(AuthorizationError)`` — the authorization check itself failed 

294 (e.g. policy engine error); callers should treat this as denied. 

295 

296 Args: 

297 user: Authenticated user object. 

298 action: Action identifier (e.g. ``"read"``). 

299 resource: Target resource; coerced to :class:`str` for matching. 

300 

301 Returns: 

302 ``Result[bool, AuthorizationError]`` 

303 """ 

304 granted = await self.check_access(user, set(), str(resource), action) 

305 

306 if self._audit_logger is not None: 

307 actor_id = str( 

308 getattr(user, "user_id", None) or getattr(user, "id", None) or "unknown" 

309 ) 

310 await self._audit_logger.log( 

311 AuditEntry( 

312 action="authz.decision", 

313 actor_id=actor_id, 

314 resource_type=type(resource).__name__, 

315 resource_id=str(resource), 

316 outcome="granted" if granted else "denied", 

317 metadata={"authz_action": action}, 

318 ) 

319 ) 

320 

321 return Ok(granted) 

322 

323 # --- Internal Helpers --- 

324 

325 def _get_effective_roles(self, user_roles: set[str]) -> set[str]: 

326 """Resolve all roles including inherited ones.""" 

327 effective = set(user_roles) 

328 to_process = list(user_roles) 

329 processed = set() 

330 

331 while to_process: 

332 role_name = to_process.pop() 

333 if role_name in processed: 

334 continue 

335 processed.add(role_name) 

336 

337 role_def = self._roles.get(role_name) 

338 if role_def: 

339 inherits = getattr(role_def, "inherits", []) 

340 if not inherits and isinstance(role_def, dict): 

341 inherits = role_def.get("inherits", []) 

342 

343 for parent in inherits: 

344 effective.add(parent) 

345 to_process.append(parent) 

346 return effective 

347 

348 def _get_user_permissions(self, effective_roles: set[str]) -> set[str]: 

349 """Flatten all permissions from effective roles.""" 

350 permissions = set() 

351 for role_name in effective_roles: 

352 role_def = self._roles.get(role_name) 

353 if role_def is not None: 

354 perms = [] 

355 if hasattr(role_def, "permissions"): 

356 perms = role_def.permissions 

357 elif isinstance(role_def, dict): 

358 perms = role_def.get("permissions", []) 

359 

360 if perms: 

361 permissions.update(perms) 

362 return permissions 

363 

364 def _has_permission(self, user_permissions: set[str], required: str) -> bool: 

365 """Check if required permission matches any user permission patterns. 

366 

367 Supports bidirectional wildcard matching: 

368 1. User has wildcard: user='admin.*', required='admin.users' -> True 

369 2. Requirement has wildcard: user='community.list', required='community.*' -> True 

370 """ 

371 if "*" in user_permissions: 

372 return True 

373 

374 if required in user_permissions: 

375 return True 

376 

377 # Helper to check if a pattern matches a string 

378 def matches(pattern: str, string: str) -> bool: 

379 if "*" not in pattern: 

380 return pattern == string 

381 parts = pattern.split("*") 

382 # For simplicity, we only support a single wildcard for now (standard for the framework) 

383 prefix, suffix = parts[0], parts[1] if len(parts) > 1 else "" 

384 return string.startswith(prefix) and string.endswith(suffix) 

385 

386 # Check if any user permission pattern matches the required string 

387 for user_perm in user_permissions: 

388 if "*" in user_perm and matches(user_perm, required): 

389 return True 

390 

391 # Check if the required string is a pattern that matches any user permission 

392 if "*" in required: 

393 for user_perm in user_permissions: 

394 if matches(required, user_perm): 

395 return True 

396 

397 return False 

398 

399 def _flatten_roles(self, role_names: set[str]) -> set[str]: 

400 """Recursively collect all parent roles for the given set of role names. 

401 

402 This helper walks the role inheritance graph stored in ``self._roles`` and 

403 returns a set containing the original roles plus any roles they inherit 

404 from, directly or indirectly. 

405 

406 Results are memoized for performance. 

407 """ 

408 # OPT-AUTH-1: Use memoization cache 

409 cache_key = frozenset(role_names) 

410 now = ambient_clock.monotonic() 

411 

412 if cache_key in self._role_flatten_cache: 

413 expiry, cached_roles = self._role_flatten_cache[cache_key] 

414 if expiry > now: 

415 return cached_roles 

416 

417 result: set[str] = set(role_names) 

418 to_process: list[str] = list(role_names) 

419 while to_process: 

420 name = to_process.pop() 

421 role_def = self._roles.get(name) 

422 if not role_def: 

423 continue 

424 # ``inherits`` may be an attribute or a dict key depending on the role type 

425 inherits = ( 

426 getattr(role_def, "inherits", None) 

427 if hasattr(role_def, "inherits") 

428 else None 

429 ) 

430 if inherits is None: 

431 inherits = ( 

432 role_def.get("inherits", []) if isinstance(role_def, dict) else [] 

433 ) 

434 for parent in inherits: 

435 if parent not in result: 

436 result.add(parent) 

437 to_process.append(parent) 

438 

439 self._role_flatten_cache[cache_key] = (now + self._permission_cache_ttl, result) 

440 return result 

441 

442 

443__all__ = ["_AuthCheckMixin"]