Coverage for src/lexigram/auth/decorators.py: 0%
34 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 12:26 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 12:26 +0800
1from __future__ import annotations
3from collections.abc import Callable
4import functools
5from typing import Any, TypeVar
7from lexigram.auth.exceptions import AuthenticationError, AuthorizationError
9F = TypeVar("F", bound=Callable[..., Any])
12def _extract_request(*args: Any, **kwargs: Any) -> Any:
13 """Extract the request-like object from positional or keyword args."""
14 for arg in args:
15 if hasattr(arg, "identity") or hasattr(arg, "state"):
16 return arg
17 return kwargs.get("request")
20def require_auth(fn: F) -> F:
21 """Require a valid authenticated identity on the request context.
23 The decorator reads ``request.identity`` from the first positional argument
24 (assumed to be the request/context object). Raises ``AuthenticationError``
25 when no identity is present.
27 Args:
28 fn: The async handler function to decorate.
30 Returns:
31 Decorated async handler that validates identity before invocation.
33 Raises:
34 AuthenticationError: When no identity is present on the request.
36 Example::
38 @require_auth
39 async def get_profile(self, request: Request) -> Response:
40 return Response(data=request.identity.claims)
41 """
43 @functools.wraps(fn)
44 async def wrapper(*args: Any, **kwargs: Any) -> Any:
45 request = _extract_request(*args, **kwargs)
46 if request is None or not getattr(request, "identity", None):
47 raise AuthenticationError("Authentication required")
48 return await fn(*args, **kwargs)
50 return wrapper # type: ignore[return-value]
53def require_roles(*roles: str) -> Callable[[F], F]:
54 """Require that the authenticated identity holds at least one of the given roles.
56 Args:
57 *roles: One or more role names. Access is granted if the identity
58 has **any** of the listed roles.
60 Returns:
61 Decorator that enforces role-based access control.
63 Raises:
64 AuthenticationError: When no identity is present on the request.
65 AuthorizationError: When the identity lacks all required roles.
67 Example::
69 @require_roles("admin", "moderator")
70 async def delete_post(self, request: Request, post_id: str) -> Response: ...
71 """
73 def decorator(fn: F) -> F:
74 @functools.wraps(fn)
75 async def wrapper(*args: Any, **kwargs: Any) -> Any:
76 request = _extract_request(*args, **kwargs)
77 identity = (
78 getattr(request, "identity", None) if request is not None else None
79 )
80 if identity is None:
81 raise AuthenticationError("Authentication required")
82 identity_roles: set[str] = set(getattr(identity, "roles", []))
83 if not identity_roles.intersection(roles):
84 raise AuthorizationError(
85 f"Required roles: {roles!r}. Identity has: {sorted(identity_roles)!r}"
86 )
87 return await fn(*args, **kwargs)
89 return wrapper # type: ignore[return-value]
91 return decorator
94__all__ = ["require_auth", "require_roles"]