Coverage for src/lexigram/web/security/guards.py: 32%
93 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
1"""
2GuardProtocol system for request authorization.
4Guards are executed before route handlers to validate authorization.
5"""
7from __future__ import annotations
9from abc import ABC
10from dataclasses import dataclass
11from functools import wraps
12import inspect
13from typing import Any
15from starlette.requests import Request
16from starlette.responses import JSONResponse
18from lexigram.contracts import AuthorizerProtocol
19from lexigram.contracts.web.guard import GuardProtocol
20from lexigram.result import Err, Ok, Result
21from lexigram.web.protocols import (
22 ExecutionContextProtocol as _BaseExecutionContext,
23)
26@dataclass(frozen=True)
27class GuardRejection:
28 """Represents a guard denial with error details."""
30 error: str = "Unauthorized"
31 message: str = "Access denied"
32 status_code: int = 403
35def _extract_request(context: Any) -> Any:
36 """Return the raw request from either an ExecutionContextProtocol or a plain request."""
37 if isinstance(context, _BaseExecutionContext):
38 return context.request
39 return context
42class AuthGuard(ABC):
43 """
44 GuardProtocol that checks if user is authenticated.
46 Looks for user in request.state.user (set by auth middleware).
47 """
49 async def can_activate(self, context: Any) -> bool:
50 """Check if user is authenticated."""
51 request = _extract_request(context)
52 user = getattr(request.state, "user", None)
53 return user is not None
56class RoleGuard(ABC):
57 """
58 GuardProtocol that checks if user has required role(s).
60 The authorizer must be injected at guard instantiation time (constructor injection).
62 Usage:
63 authorizer = await container.resolve(AuthorizerProtocol)
64 @use_guards(RoleGuard(*required_roles, authorizer=authorizer))
65 async def admin_only(self):
66 ...
67 """
69 def __init__(self, *required_roles: str, authorizer: AuthorizerProtocol) -> None:
70 self.required_roles = set(required_roles)
71 self._authorizer = authorizer
73 async def can_activate(self, context: Any) -> bool:
74 """Check if user has required role using AuthorizerProtocol."""
75 request = _extract_request(context)
76 user = getattr(request.state, "user", None)
77 if not user:
78 return False
80 # Check access with authorizer (handles superuser bypass and inheritance)
81 return await self._authorizer.check_access(user, self.required_roles)
84class PermissionGuard(ABC):
85 """
86 GuardProtocol that checks if user has required permission(s).
88 The authorizer must be injected at guard instantiation time (constructor injection).
90 Usage:
91 authorizer = await container.resolve(AuthorizerProtocol)
92 @use_guards(PermissionGuard("users:write", authorizer=authorizer))
93 async def create_user(self):
94 ...
95 """
97 def __init__(
98 self, *required_permissions: str, authorizer: AuthorizerProtocol
99 ) -> None:
100 self.required_permissions = set(required_permissions)
101 self._authorizer = authorizer
103 async def can_activate(self, context: Any) -> bool:
104 """Check if user has required permissions using AuthorizerProtocol."""
105 request = _extract_request(context)
106 user = getattr(request.state, "user", None)
107 if not user:
108 return False
110 # Check each permission against the authorizer
111 for perm in self.required_permissions:
112 # We treat permission string as "resource.action" if it contains a dot,
113 # otherwise just pass as action (or resource, depending on how you view it).
114 parts = perm.split(".", 1)
115 resource = parts[0]
116 action = parts[1] if len(parts) > 1 else perm
118 if await self._authorizer.can(user, action, resource):
119 return True
121 return bool(not self.required_permissions)
124def use_guards(*guards: type[Any] | Any) -> Any:
125 """
126 Decorator to attach guards to a route handler or controller.
128 Guards are executed in order before the route handler.
129 If any guard returns False, the request is rejected.
131 **Guards requiring dependencies (like RoleGuard, PermissionGuard) must
132 be instantiated with those dependencies passed to the constructor.**
134 Usage:
135 @use_guards(AuthGuard)
136 async def protected_route(self):
137 ...
139 # With injected dependencies
140 authorizer = await container.resolve(AuthorizerProtocol)
141 @use_guards(AuthGuard, RoleGuard("admin", authorizer=authorizer))
142 async def admin_route(self):
143 ...
144 """
146 def decorator(target: Any) -> Any:
147 # Store guards metadata
148 guard_instances = []
149 for guard in guards:
150 if isinstance(guard, type):
151 guard_instances.append(guard())
152 else:
153 guard_instances.append(guard)
155 target.__guards__ = guard_instances
157 # Wrap the function to execute guards
158 if inspect.iscoroutinefunction(target):
160 @wraps(target)
161 async def wrapper(*args: Any, **kwargs: Any) -> Any:
162 # Find request in kwargs first, then scan positional args
163 request = kwargs.get("request")
164 if request is None:
165 for arg in args:
166 if isinstance(arg, Request):
167 request = arg
168 break
169 if request is None:
170 raise RuntimeError(
171 "GuardProtocol requires request context. Ensure the handler "
172 "declares 'request: Request' as a parameter.",
173 )
175 result = await execute_guards(guard_instances, request)
176 if result.is_err():
177 rejection = result.unwrap_err()
178 return JSONResponse(
179 {"error": rejection.error, "message": rejection.message},
180 status_code=rejection.status_code,
181 )
183 return await target(*args, **kwargs)
185 return wrapper
186 return target
188 return decorator
191async def execute_guards(
192 guards: list[Any],
193 request: Request,
194) -> Result[None, GuardRejection]:
195 """
196 Execute a list of guards.
198 Args:
199 guards: List of guard instances
200 request: The incoming request
202 Returns:
203 Ok(None) if all guards pass, Err(GuardRejection) if any guard fails
204 """
205 for guard in guards:
206 can_proceed = await guard.can_activate(request)
207 if not can_proceed:
208 return Err(GuardRejection())
210 return Ok(None)
213def require_admin(target: Any = None, *, authorizer: Any = None) -> Any:
214 """
215 Convenience decorator for admin-only routes.
217 The ``authorizer`` dependency must be injected.
219 Can be used either as:
220 @require_admin(authorizer=auth)
221 async def handler(...):
222 ...
224 or as:
225 @require_admin
226 async def handler(...):
227 ...
229 Args:
230 target: When used without parentheses, the decorated function.
231 authorizer: **Required** AuthorizerProtocol instance.
232 """
233 if authorizer is None:
234 raise ValueError(
235 "authorizer parameter is required. Inject it from the container at startup."
236 )
238 decorator = use_guards(RoleGuard("admin", authorizer=authorizer))
239 # If used without parentheses
240 if target is not None:
241 return decorator(target)
242 return decorator
245__all__ = [
246 "AuthGuard",
247 "GuardProtocol",
248 "GuardRejection",
249 "PermissionGuard",
250 "RoleGuard",
251 "execute_guards",
252 "require_admin",
253 "use_guards",
254]