Coverage for src/lexigram/auth/web/guards.py: 42%

183 statements  

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

1"""GuardProtocol services for authorization in Lexigram Framework""" 

2 

3from __future__ import annotations 

4 

5from abc import ABC, abstractmethod 

6from functools import wraps 

7from typing import TYPE_CHECKING, Any, cast 

8 

9from lexigram.auth.types import GuardContext 

10from lexigram.contracts.web import ResponseFactoryProtocol 

11from lexigram.contracts.web.guard import GuardProtocol 

12from lexigram.logging import get_logger 

13from lexigram.primitives.context import Context, get_request_context 

14 

15if TYPE_CHECKING: 

16 from collections.abc import Callable 

17 

18 from lexigram.contracts.web import ResponseProtocol 

19 

20logger = get_logger(__name__) 

21 

22 

23def _get_request_resolver(request: Any) -> Any | None: 

24 from lexigram.di.resolution.context import get_resolver 

25 

26 resolver = get_resolver(request) 

27 if resolver is not None: 

28 return resolver 

29 

30 scope = getattr(request, "scope", None) 

31 if isinstance(scope, dict): 

32 return scope.get("lexigram_resolver") 

33 

34 return None 

35 

36 

37async def _get_request_context_user_id(request: Any) -> str | None: 

38 resolver = _get_request_resolver(request) 

39 if resolver is None: 

40 return None 

41 

42 resolve_optional = getattr(resolver, "resolve_optional", None) 

43 if callable(resolve_optional): 

44 context = await resolve_optional(Context) 

45 else: 

46 context = await resolver.resolve(Context) 

47 if context is None: 

48 return None 

49 

50 current = get_request_context(context.registry) 

51 return current.user_id if current is not None else None 

52 

53 

54class _GuardBase(ABC): 

55 """Private abstract base for auth guards; provides default handle_rejection.""" 

56 

57 @abstractmethod 

58 async def can_activate(self, context: GuardContext) -> bool: 

59 """Check if the guard allows the request to proceed""" 

60 

61 async def handle_rejection(self, context: GuardContext) -> ResponseProtocol: 

62 """Handle guard rejection by returning appropriate response.""" 

63 try: 

64 resolver = _get_request_resolver(context.request) 

65 if resolver is None: 

66 raise ValueError("No resolver found in context") 

67 response_factory = await resolver.resolve(ResponseFactoryProtocol) 

68 except ValueError as exc: 

69 raise RuntimeError( 

70 "ResponseFactoryProtocol not available — ensure the DI container is configured", 

71 ) from exc 

72 

73 return cast( 

74 "ResponseProtocol", 

75 response_factory.json( 

76 {"error": "forbidden", "message": "Access denied"}, 

77 status_code=403, 

78 ), 

79 ) 

80 

81 

82class AuthGuard(_GuardBase): 

83 """GuardProtocol that requires authentication""" 

84 

85 async def can_activate(self, context: GuardContext) -> bool: 

86 """Check if user is authenticated""" 

87 return context.user is not None or context.request_context_user_id is not None 

88 

89 async def handle_rejection(self, context: GuardContext) -> ResponseProtocol: 

90 """Return 401 for unauthenticated requests.""" 

91 try: 

92 resolver = _get_request_resolver(context.request) 

93 if resolver is None: 

94 raise ValueError("No resolver found in context") 

95 response_factory = await resolver.resolve(ResponseFactoryProtocol) 

96 except ValueError as exc: 

97 raise RuntimeError( 

98 "ResponseFactoryProtocol not available — ensure the DI container is configured", 

99 ) from exc 

100 

101 return cast( 

102 "ResponseProtocol", 

103 response_factory.json( 

104 {"error": "unauthorized", "message": "Authentication required"}, 

105 status_code=401, 

106 ), 

107 ) 

108 

109 

110class RoleGuard(_GuardBase): 

111 """GuardProtocol that requires specific roles""" 

112 

113 def __init__(self, *roles: str) -> None: 

114 self.required_roles = list(roles) 

115 

116 async def can_activate(self, context: GuardContext) -> bool: 

117 """Check if user has required roles""" 

118 if not context.user: 

119 return False 

120 return any(context.user.has_role(role) for role in self.required_roles) 

121 

122 

123class PermissionGuard(_GuardBase): 

124 """GuardProtocol that requires specific permissions""" 

125 

126 def __init__(self, *permissions: str) -> None: 

127 self.required_permissions = list(permissions) 

128 

129 async def can_activate(self, context: GuardContext) -> bool: 

130 """Check if user has required permissions""" 

131 if not context.user: 

132 return False 

133 

134 # Import here to avoid circular imports 

135 

136 try: 

137 resolver = _get_request_resolver(context.request) 

138 

139 from lexigram.contracts.auth import AuthProviderProtocol 

140 

141 if resolver is None: 

142 return False 

143 

144 auth_provider: Any = cast( 

145 "Any", 

146 await resolver.resolve(AuthProviderProtocol), 

147 ) 

148 

149 return bool( 

150 auth_provider.has_any_permission( 

151 cast("Any", context.user), 

152 self.required_permissions, 

153 ), 

154 ) 

155 except (RuntimeError, ValueError, TypeError): 

156 logger.warning("Failed to check permissions via container") 

157 return False 

158 

159 

160class CompositeGuard(_GuardBase): 

161 """GuardProtocol that combines multiple guards with AND logic""" 

162 

163 def __init__(self, *guards: GuardProtocol) -> None: 

164 self.guards = guards 

165 

166 async def can_activate(self, context: GuardContext) -> bool: 

167 """Check if all guards pass""" 

168 for guard in self.guards: 

169 if not await guard.can_activate(context): # type: ignore[arg-type] 

170 return False 

171 return True 

172 

173 

174class AdminGuard(RoleGuard): 

175 """GuardProtocol that requires admin role""" 

176 

177 def __init__(self) -> None: 

178 super().__init__("admin") 

179 

180 

181class UserGuard(AuthGuard): 

182 """GuardProtocol that requires any authenticated user""" 

183 

184 

185def use_guards( 

186 *guards: GuardProtocol, 

187) -> Callable[[Callable[..., Any]], Callable[..., Any]]: 

188 """Apply guards to a route handler (auth-scoped internal implementation). 

189 

190 .. note:: 

191 This is the auth-package-scoped version of ``use_guards``, intended for 

192 internal use within ``lexigram-auth``. For general-purpose use outside 

193 the auth subsystem, prefer ``lexigram.security.guards.use_guards`` which 

194 integrates with ``GuardChain``. 

195 """ 

196 

197 def decorator(func: Callable) -> Callable: 

198 @wraps(func) 

199 async def wrapper(*args: Any, **kwargs: Any) -> Any: 

200 # Extract request from args (Starlette pattern) 

201 request = None 

202 for arg in args: 

203 if hasattr(arg, "state") and hasattr(arg, "headers"): 

204 request = arg 

205 break 

206 

207 if not request and "request" in kwargs: 

208 request = kwargs["request"] 

209 

210 if not request: 

211 # If no request found, assume guard passes (for testing) 

212 return await func(*args, **kwargs) 

213 

214 # Always skip guards for OPTIONS requests (CORS preflight) 

215 if hasattr(request, "method") and request.method == "OPTIONS": 

216 return await func(*args, **kwargs) 

217 

218 # Get user from request state 

219 user = getattr(request.state, "user", None) 

220 request_context_user_id = await _get_request_context_user_id(request) 

221 

222 # Create guard context 

223 context = GuardContext( 

224 user=user, 

225 request=request, 

226 request_context_user_id=request_context_user_id, 

227 ) 

228 

229 # Check all guards 

230 for guard in guards: 

231 if not await guard.can_activate(context): # type: ignore[arg-type] 

232 return await guard.handle_rejection(context) # type: ignore[arg-type] 

233 

234 # All guards passed, proceed 

235 return await func(*args, **kwargs) 

236 

237 # Store guards on function for introspection 

238 wrapper.__guards__ = guards # type: ignore[attr-defined] 

239 return wrapper 

240 

241 return decorator 

242 

243 

244class GuardFactory: 

245 """Factory for creating guards via dependency injection. 

246 

247 This class handles the async resolution of guards from the DI container 

248 and provides synchronous access for use in decorators. 

249 """ 

250 

251 _instances: dict[str, GuardProtocol] = {} 

252 

253 @classmethod 

254 async def get_guard( 

255 cls, 

256 guard_type: type[GuardProtocol], 

257 resolver: Any | None = None, 

258 ) -> GuardProtocol: 

259 """Get a guard instance, resolving from DI container if needed. 

260 

261 Args: 

262 guard_type: The type of guard to get. 

263 resolver: Optional resolver to use. 

264 

265 Returns: 

266 The guard instance. 

267 """ 

268 from lexigram.di.resolution.context import get_resolver 

269 

270 res = get_resolver(resolver) 

271 if res: 

272 guard = await res.resolve_optional(guard_type) 

273 if guard is not None: 

274 return guard 

275 logger.debug( 

276 "guard_resolution_failed", 

277 guard=guard_type.__name__, 

278 error="not registered", 

279 ) 

280 

281 key = guard_type.__name__ 

282 if key not in cls._instances: 

283 cls._instances[key] = guard_type() 

284 return cls._instances[key] 

285 

286 

287def require_auth() -> Callable[[Callable[..., Any]], Callable[..., Any]]: 

288 """Decorator requiring authentication. 

289 

290 Uses GuardFactory to get the guard instance properly. 

291 """ 

292 

293 def decorator(func: Callable) -> Callable: 

294 @wraps(func) 

295 async def wrapper(*args: Any, **kwargs: Any) -> Any: 

296 request = None 

297 for arg in args: 

298 if hasattr(arg, "state") and hasattr(arg, "headers"): 

299 request = arg 

300 break 

301 

302 if not request and "request" in kwargs: 

303 request = kwargs["request"] 

304 

305 if not request: 

306 return await func(*args, **kwargs) 

307 

308 if hasattr(request, "method") and request.method == "OPTIONS": 

309 return await func(*args, **kwargs) 

310 

311 user = getattr(request.state, "user", None) 

312 context = GuardContext(request, user) 

313 

314 guard = await GuardFactory.get_guard(AuthGuard, request) # type: ignore[arg-type] 

315 if not await guard.can_activate(context): # type: ignore[arg-type] 

316 return await guard.handle_rejection(context) # type: ignore[arg-type] 

317 

318 return await func(*args, **kwargs) 

319 

320 wrapper.__guard_type__ = AuthGuard # type: ignore[attr-defined] 

321 return wrapper 

322 

323 return decorator 

324 

325 

326def require_admin() -> Callable[[Callable[..., Any]], Callable[..., Any]]: 

327 """Decorator requiring admin role. 

328 

329 Uses GuardFactory to get the guard instance properly. 

330 """ 

331 

332 def decorator(func: Callable) -> Callable: 

333 @wraps(func) 

334 async def wrapper(*args: Any, **kwargs: Any) -> Any: 

335 request = None 

336 for arg in args: 

337 if hasattr(arg, "state") and hasattr(arg, "headers"): 

338 request = arg 

339 break 

340 

341 if not request and "request" in kwargs: 

342 request = kwargs["request"] 

343 

344 if not request: 

345 return await func(*args, **kwargs) 

346 

347 if hasattr(request, "method") and request.method == "OPTIONS": 

348 return await func(*args, **kwargs) 

349 

350 user = getattr(request.state, "user", None) 

351 context = GuardContext(request, user) 

352 

353 guard = await GuardFactory.get_guard(AdminGuard, request) # type: ignore[arg-type] 

354 if not await guard.can_activate(context): # type: ignore[arg-type] 

355 return await guard.handle_rejection(context) # type: ignore[arg-type] 

356 

357 return await func(*args, **kwargs) 

358 

359 wrapper.__guard_type__ = AdminGuard # type: ignore[attr-defined] 

360 return wrapper 

361 

362 return decorator 

363 

364 

365def require_role(*roles: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]: 

366 """Decorator requiring specific roles""" 

367 return use_guards(RoleGuard(*roles)) # type: ignore[arg-type] 

368 

369 

370def require_permission( 

371 *permissions: str, 

372) -> Callable[[Callable[..., Any]], Callable[..., Any]]: 

373 """Decorator requiring specific permissions""" 

374 return use_guards(PermissionGuard(*permissions)) # type: ignore[arg-type] 

375 

376 

377__all__ = [ 

378 "AdminGuard", 

379 "AuthGuard", 

380 "CompositeGuard", 

381 "GuardContext", 

382 # GuardProtocol classes 

383 "GuardProtocol", 

384 "PermissionGuard", 

385 "RoleGuard", 

386 "UserGuard", 

387 "require_admin", 

388 "require_auth", 

389 "require_permission", 

390 "require_role", 

391 # Decorators (snake_case only) 

392 "use_guards", 

393]