Coverage for src/lexigram/web/middleware/role_guard.py: 40%
52 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"""Role guard middleware — declarative path-to-role enforcement.
3Guards HTTP paths against configured role requirements using the
4authenticated identity set by the authentication middleware
5(``scope["state"]["user_id"]``). Roles are resolved **per request** from the
6identity store (never from JWT claims), so demotions take effect immediately.
8Rules are plain values: an exact path, or a path ending in ``/**`` which
9matches every path under that prefix. The first matching rule wins.
11Registration order matters: this middleware must sit **inside** the
12authentication middleware so an authenticated identity exists when it runs.
13In :class:`~lexigram.web.di.provider.WebProvider` the auth middleware is
14registered by :class:`~lexigram.web.integrations.auth.AuthIntegration`; the
15role guard is registered right after it in the same step.
16"""
18from __future__ import annotations
20from dataclasses import dataclass
21from typing import Protocol, runtime_checkable
23from starlette.responses import JSONResponse
24from starlette.types import ASGIApp, Receive, Scope, Send
26from lexigram.logging import get_logger
28logger = get_logger(__name__)
31@dataclass(frozen=True)
32class RoleGuardRule:
33 """One role guard rule.
35 Attributes:
36 path: Exact path to guard. A trailing ``/**`` matches every path
37 under that prefix (e.g. ``/api/admin/**`` also matches
38 ``/api/admin`` itself).
39 roles: Role identifiers allowed to pass.
40 """
42 path: str
43 roles: list[str]
46@runtime_checkable
47class RoleResolverProtocol(Protocol):
48 """Resolve the role set of the authenticated user per request.
50 Implementations must query the identity store — never read JWT claims —
51 so demotions take effect immediately.
52 """
54 async def resolve(self, user_id: str) -> list[str] | None:
55 """Return the roles granted to *user_id*.
57 Args:
58 user_id: Verified identity of the authenticated caller.
60 Returns:
61 The roles granted to the user, or ``None`` when the user is
62 unknown or deactivated.
63 """
64 ...
67class RoleGuardMiddleware:
68 """ASGI middleware enforcing role rules against authenticated identities.
70 Args:
71 app: Inner ASGI application.
72 rules: Rules applied in declaration order; first match wins.
73 resolver: Per-request role resolver.
74 """
76 def __init__(
77 self,
78 app: ASGIApp,
79 rules: list[RoleGuardRule],
80 resolver: RoleResolverProtocol,
81 ) -> None:
82 """Initialise the middleware with its rule set and resolver.
84 Args:
85 app: Inner ASGI application.
86 rules: Rules applied in declaration order; first match wins.
87 resolver: Per-request role resolver.
88 """
89 self.app = app
90 self._rules = rules
91 self._resolver = resolver
93 def _match(self, path: str) -> RoleGuardRule | None:
94 """Return the first rule matching *path*.
96 Args:
97 path: Request path to evaluate.
99 Returns:
100 The first matching rule, or ``None`` when no rule applies.
101 """
102 for rule in self._rules:
103 if rule.path.endswith("/**"):
104 prefix = rule.path[:-3].rstrip("/")
105 if path == prefix or path.startswith(f"{prefix}/"):
106 return rule
107 elif path == rule.path:
108 return rule
109 return None
111 async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
112 """Enforce role rules for the request.
114 Args:
115 scope: The ASGI connection scope.
116 receive: The ASGI receive callable.
117 send: The ASGI send callable.
118 """
119 if scope["type"] != "http":
120 await self.app(scope, receive, send)
121 return
123 rule = self._match(scope.get("path", ""))
124 if rule is None:
125 await self.app(scope, receive, send)
126 return
128 user_id = scope.setdefault("state", {}).get("user_id")
129 if user_id is None:
130 logger.warning("role_guard_unauthorized", path=scope.get("path"))
131 await self._reject(scope, receive, send, 401, "Unauthorized")
132 return
134 roles = await self._resolver.resolve(str(user_id))
135 if roles is None or not set(roles).intersection(rule.roles):
136 logger.warning(
137 "role_guard_forbidden",
138 path=scope.get("path"),
139 user_id=str(user_id),
140 rule_roles=list(rule.roles),
141 )
142 await self._reject(scope, receive, send, 403, "Forbidden")
143 return
145 await self.app(scope, receive, send)
147 @staticmethod
148 async def _reject(
149 scope: Scope,
150 receive: Receive,
151 send: Send,
152 status_code: int,
153 error: str,
154 ) -> None:
155 """Respond with a JSON error body.
157 Args:
158 scope: The ASGI connection scope.
159 receive: The ASGI receive callable.
160 send: The ASGI send callable.
161 status_code: HTTP status of the rejection.
162 error: Error message for the ``{"error": ...}`` body.
163 """
164 response = JSONResponse({"error": error}, status_code=status_code)
165 await response(scope, receive, send)
168__all__ = [
169 "RoleGuardMiddleware",
170 "RoleGuardRule",
171 "RoleResolverProtocol",
172]