xtr-security-http 3.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. xtr_security_http/__init__.py +143 -0
  2. xtr_security_http/_runner.py +134 -0
  3. xtr_security_http/_state.py +57 -0
  4. xtr_security_http/access_map.py +52 -0
  5. xtr_security_http/access_map_interface.py +24 -0
  6. xtr_security_http/access_token/__init__.py +19 -0
  7. xtr_security_http/access_token/access_token_extractor_interface.py +31 -0
  8. xtr_security_http/access_token/access_token_handler_interface.py +34 -0
  9. xtr_security_http/access_token/chain_access_token_extractor.py +58 -0
  10. xtr_security_http/access_token/form_encoded_body_extractor.py +57 -0
  11. xtr_security_http/access_token/header_access_token_extractor.py +80 -0
  12. xtr_security_http/access_token/oidc/__init__.py +41 -0
  13. xtr_security_http/access_token/oidc/exception/__init__.py +7 -0
  14. xtr_security_http/access_token/oidc/exception/oidc_key_set_error.py +18 -0
  15. xtr_security_http/access_token/oidc/oidc_token_handler.py +246 -0
  16. xtr_security_http/access_token/query_access_token_extractor.py +51 -0
  17. xtr_security_http/authentication/__init__.py +24 -0
  18. xtr_security_http/authentication/_sensitive.py +60 -0
  19. xtr_security_http/authentication/authentication_failure_handler_interface.py +31 -0
  20. xtr_security_http/authentication/authentication_success_handler_interface.py +31 -0
  21. xtr_security_http/authentication/authenticator_manager.py +232 -0
  22. xtr_security_http/authentication/authenticator_manager_interface.py +40 -0
  23. xtr_security_http/authentication/expose_security_level.py +27 -0
  24. xtr_security_http/authenticator/__init__.py +9 -0
  25. xtr_security_http/authenticator/abstract_authenticator.py +66 -0
  26. xtr_security_http/authenticator/access_token_authenticator.py +199 -0
  27. xtr_security_http/authenticator/authenticator_interface.py +68 -0
  28. xtr_security_http/authenticator/oidc/__init__.py +7 -0
  29. xtr_security_http/authenticator/oidc/oidc_jwks.py +87 -0
  30. xtr_security_http/authenticator/passport/__init__.py +8 -0
  31. xtr_security_http/authenticator/passport/badge/__init__.py +16 -0
  32. xtr_security_http/authenticator/passport/badge/badge_interface.py +24 -0
  33. xtr_security_http/authenticator/passport/badge/password_upgrade_badge.py +59 -0
  34. xtr_security_http/authenticator/passport/badge/pre_authenticated_user_badge.py +27 -0
  35. xtr_security_http/authenticator/passport/badge/user_badge.py +163 -0
  36. xtr_security_http/authenticator/passport/credentials/__init__.py +9 -0
  37. xtr_security_http/authenticator/passport/credentials/credentials_interface.py +21 -0
  38. xtr_security_http/authenticator/passport/credentials/custom_credentials.py +64 -0
  39. xtr_security_http/authenticator/passport/credentials/password_credentials.py +51 -0
  40. xtr_security_http/authenticator/passport/passport.py +104 -0
  41. xtr_security_http/authenticator/passport/self_validating_passport.py +42 -0
  42. xtr_security_http/authenticator/token/__init__.py +7 -0
  43. xtr_security_http/authenticator/token/post_authentication_token.py +43 -0
  44. xtr_security_http/authorization/__init__.py +15 -0
  45. xtr_security_http/authorization/access_denied_handler_interface.py +27 -0
  46. xtr_security_http/authorization/insufficient_scope_access_denied_handler.py +66 -0
  47. xtr_security_http/authorization/oauth2_scope_voter.py +122 -0
  48. xtr_security_http/decorator/__init__.py +16 -0
  49. xtr_security_http/decorator/current_user.py +83 -0
  50. xtr_security_http/decorator/is_granted.py +199 -0
  51. xtr_security_http/decorator/is_granted_context.py +14 -0
  52. xtr_security_http/entry_point/__init__.py +7 -0
  53. xtr_security_http/entry_point/authentication_entry_point_interface.py +33 -0
  54. xtr_security_http/event/__init__.py +20 -0
  55. xtr_security_http/event/authentication_token_created_event.py +48 -0
  56. xtr_security_http/event/check_passport_event.py +48 -0
  57. xtr_security_http/event/login_failure_event.py +79 -0
  58. xtr_security_http/event/login_success_event.py +79 -0
  59. xtr_security_http/event_listener/__init__.py +21 -0
  60. xtr_security_http/event_listener/check_credentials_listener.py +162 -0
  61. xtr_security_http/event_listener/password_migrating_listener.py +79 -0
  62. xtr_security_http/event_listener/user_checker_listener.py +60 -0
  63. xtr_security_http/event_listener/user_provider_listener.py +53 -0
  64. xtr_security_http/exception/__init__.py +18 -0
  65. xtr_security_http/exception/firewall_not_booted_error.py +18 -0
  66. xtr_security_http/exception/invalid_access_token_error.py +20 -0
  67. xtr_security_http/exception/unknown_firewall_error.py +34 -0
  68. xtr_security_http/firewall/__init__.py +15 -0
  69. xtr_security_http/firewall/access_listener.py +66 -0
  70. xtr_security_http/firewall/exception_listener.py +151 -0
  71. xtr_security_http/firewall/firewall.py +142 -0
  72. xtr_security_http/firewall_context_interface.py +64 -0
  73. xtr_security_http/firewall_map.py +93 -0
  74. xtr_security_http/firewall_map_interface.py +48 -0
  75. xtr_security_http/firewall_scheme.py +114 -0
  76. xtr_security_http/firewall_scheme_registry.py +87 -0
  77. xtr_security_http/oidc/__init__.py +7 -0
  78. xtr_security_http/oidc/oidc_discovery.py +238 -0
  79. xtr_security_http/py.typed +0 -0
  80. xtr_security_http/request_matcher/__init__.py +28 -0
  81. xtr_security_http/request_matcher/_pattern.py +27 -0
  82. xtr_security_http/request_matcher/callable_request_matcher.py +37 -0
  83. xtr_security_http/request_matcher/chain_request_matcher.py +37 -0
  84. xtr_security_http/request_matcher/host_request_matcher.py +43 -0
  85. xtr_security_http/request_matcher/ip_request_matcher.py +37 -0
  86. xtr_security_http/request_matcher/method_request_matcher.py +32 -0
  87. xtr_security_http/request_matcher/path_request_matcher.py +41 -0
  88. xtr_security_http/request_matcher/request_matcher_interface.py +25 -0
  89. xtr_security_http/security_events.py +41 -0
  90. xtr_security_http-3.0.0.dist-info/METADATA +427 -0
  91. xtr_security_http-3.0.0.dist-info/RECORD +93 -0
  92. xtr_security_http-3.0.0.dist-info/WHEEL +4 -0
  93. xtr_security_http-3.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,53 @@
1
+ """The listener that gives a user badge its loader from the firewall's provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, final
6
+
7
+ from typing_extensions import override
8
+ from xtr_event_dispatcher import EventSubscriberInterface
9
+
10
+ from xtr_security_http.event.check_passport_event import CheckPassportEvent
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Mapping
14
+
15
+ from xtr_event_dispatcher import SubscribedEvents
16
+ from xtr_security_core.user.user_provider_interface import UserProviderInterface
17
+
18
+ __all__ = ["UserProviderListener"]
19
+
20
+ #: The priority the provider listener runs at — before every other passport check.
21
+ PRIORITY = 2048
22
+
23
+
24
+ @final
25
+ class UserProviderListener(EventSubscriberInterface):
26
+ """Sets a user badge's loader from the firewall's provider, when it has none.
27
+
28
+ Runs first on the passport check, so the listeners after it — the account
29
+ check, the credentials check — can load the user. An authenticator that
30
+ already gave the badge a loader (a bearer handler that resolved the user
31
+ from a token's claims) is left alone. The provider's ``load_user_by_identifier``
32
+ is set as the loader directly, so a badge with attributes reaches an
33
+ attributes-based provider's two-argument method and a plain provider's
34
+ one-argument one — the badge chooses by the method's own shape.
35
+ """
36
+
37
+ __slots__ = ("_provider",)
38
+
39
+ def __init__(self, provider: UserProviderInterface) -> None:
40
+ """Record the provider a badge's loader is built from."""
41
+ self._provider = provider
42
+
43
+ @classmethod
44
+ @override
45
+ def get_subscribed_events(cls) -> Mapping[str | type, SubscribedEvents]:
46
+ """Listen to the passport check first of all, at :data:`PRIORITY`."""
47
+ return {CheckPassportEvent: ("check_passport", PRIORITY)}
48
+
49
+ async def check_passport(self, event: CheckPassportEvent) -> None:
50
+ """Give the badge the provider's loader, unless it already has one."""
51
+ badge = event.get_passport().get_user_badge()
52
+ if badge.get_user_loader() is None:
53
+ badge.set_user_loader(self._provider.load_user_by_identifier)
@@ -0,0 +1,18 @@
1
+ """The errors the HTTP edge adds to the security core's.
2
+
3
+ Each derives from a core error — :class:`~xtr_security_core.exception.SecurityError`
4
+ or :class:`~xtr_security_core.exception.AuthenticationError` — so one
5
+ ``except SecurityError`` still catches everything the family raises.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .firewall_not_booted_error import FirewallNotBootedError
11
+ from .invalid_access_token_error import InvalidAccessTokenError
12
+ from .unknown_firewall_error import UnknownFirewallError
13
+
14
+ __all__ = [
15
+ "FirewallNotBootedError",
16
+ "InvalidAccessTokenError",
17
+ "UnknownFirewallError",
18
+ ]
@@ -0,0 +1,18 @@
1
+ """Something a booted kernel provides was read before boot, or outside a request."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from xtr_security_core.exception import SecurityError
6
+
7
+ __all__ = ["FirewallNotBootedError"]
8
+
9
+
10
+ class FirewallNotBootedError(SecurityError, RuntimeError):
11
+ """A firewall's booted state was read too early, or outside a request.
12
+
13
+ The OpenAPI scheme a firewall contributes is resolved from the booted
14
+ kernel serving the current request. Read it before the kernel has booted,
15
+ or with no request in scope, and there is nothing to resolve — this is
16
+ raised rather than a wrong, silently-chosen answer. Also a
17
+ :class:`RuntimeError`.
18
+ """
@@ -0,0 +1,20 @@
1
+ """A bearer access token was malformed, expired or wrongly signed."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import ClassVar
6
+
7
+ from xtr_security_core.exception import AuthenticationError
8
+
9
+ __all__ = ["InvalidAccessTokenError"]
10
+
11
+
12
+ class InvalidAccessTokenError(AuthenticationError):
13
+ """A bearer access token could not be trusted.
14
+
15
+ The token was present but malformed, expired, signed with the wrong key,
16
+ or otherwise not verifiable. A protected resource answers such a token
17
+ with a challenge naming ``invalid_token``.
18
+ """
19
+
20
+ MESSAGE_KEY: ClassVar[str] = "Invalid credentials."
@@ -0,0 +1,34 @@
1
+ """A firewall was asked for by a name that is not configured."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from xtr_security_core.exception import SecurityError
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Sequence
11
+
12
+ __all__ = ["UnknownFirewallError"]
13
+
14
+
15
+ class UnknownFirewallError(SecurityError, LookupError):
16
+ """No firewall is configured under the name asked for.
17
+
18
+ Also a :class:`LookupError`. Names the firewalls that are configured, so
19
+ the mistake — a typo, a firewall never declared — is plain from the error.
20
+
21
+ Attributes:
22
+ name: The name that matched no firewall.
23
+ configured: The names that are configured.
24
+ """
25
+
26
+ name: str
27
+ configured: tuple[str, ...]
28
+
29
+ def __init__(self, name: str, configured: Sequence[str] = ()) -> None:
30
+ """Record the unknown name and the names that are configured."""
31
+ self.name = name
32
+ self.configured = tuple(configured)
33
+ known = ", ".join(f'"{one}"' for one in self.configured) or "none"
34
+ super().__init__(f'The firewall "{name}" is not configured. Configured: {known}.')
@@ -0,0 +1,15 @@
1
+ """The firewall a request passes, its access listener and its exception listener.
2
+
3
+ The :class:`Firewall` dependency a route attaches, the
4
+ :class:`AccessListener` that decides a request against a firewall's rules, and
5
+ the :class:`ExceptionListener` that turns a security error into a response, kept
6
+ together in one folder.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .access_listener import AccessListener
12
+ from .exception_listener import ExceptionListener
13
+ from .firewall import Firewall
14
+
15
+ __all__ = ["AccessListener", "ExceptionListener", "Firewall"]
@@ -0,0 +1,66 @@
1
+ """The step that decides a request against a firewall's access-control rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, final
6
+
7
+ from xtr_security_core.authorization.access_decision import AccessDecision
8
+ from xtr_security_core.authorization.voter.authenticated_voter import AuthenticatedVoter
9
+ from xtr_security_core.exception import AccessDeniedError
10
+
11
+ if TYPE_CHECKING:
12
+ from starlette.requests import Request
13
+ from xtr_security_core.authentication.token.token_interface import TokenInterface
14
+ from xtr_security_core.authorization.access_decision_manager_interface import (
15
+ AccessDecisionManagerInterface,
16
+ )
17
+
18
+ from xtr_security_http.access_map_interface import AccessMapInterface
19
+
20
+ __all__ = ["AccessListener"]
21
+
22
+
23
+ @final
24
+ class AccessListener:
25
+ """Requires of a request whatever its firewall's first matching rule demands.
26
+
27
+ Run after authentication, inside the firewall. It finds the first access
28
+ rule the request matches and decides the token against that rule's one
29
+ attribute. A ``PUBLIC_ACCESS`` rule short-circuits — the resource is open,
30
+ so no decision is made. A request matching no rule is left alone, its
31
+ access decided by whatever the route itself requires.
32
+ """
33
+
34
+ __slots__ = ("_access_decision_manager", "_access_map")
35
+
36
+ def __init__(
37
+ self,
38
+ access_map: AccessMapInterface,
39
+ access_decision_manager: AccessDecisionManagerInterface,
40
+ ) -> None:
41
+ """Record the rules to match and the manager that decides them."""
42
+ self._access_map = access_map
43
+ self._access_decision_manager = access_decision_manager
44
+
45
+ async def check_access(self, request: Request, token: TokenInterface) -> None:
46
+ """Decide ``token`` against the first rule ``request`` matches.
47
+
48
+ Raises:
49
+ AccessDeniedError: When a matching rule's attribute is not granted,
50
+ carrying the decision so a handler can explain or challenge.
51
+ """
52
+ attribute = self._access_map.get_attribute(request)
53
+ if attribute is None or attribute == AuthenticatedVoter.PUBLIC_ACCESS:
54
+ return
55
+ decision = AccessDecision()
56
+ granted = await self._access_decision_manager.decide(
57
+ token,
58
+ [attribute],
59
+ access_decision=decision,
60
+ )
61
+ if not granted:
62
+ raise AccessDeniedError(
63
+ "Access to the requested resource is denied.",
64
+ attributes=(attribute,),
65
+ access_decision=decision,
66
+ )
@@ -0,0 +1,151 @@
1
+ """The listener that turns a security error into a response on the lifecycle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, cast, final
6
+
7
+ from starlette.responses import JSONResponse
8
+ from typing_extensions import override
9
+ from xtr_event_dispatcher import EventSubscriberInterface
10
+ from xtr_http_kernel import ExceptionEvent
11
+ from xtr_security_core.authentication.authentication_trust_resolver_interface import ( # noqa: TC002
12
+ AuthenticationTrustResolverInterface,
13
+ )
14
+ from xtr_security_core.exception import (
15
+ AccessDeniedError,
16
+ AuthenticationError,
17
+ InsufficientAuthenticationError,
18
+ )
19
+
20
+ from xtr_security_http._state import FIREWALL_CONTEXT_KEY, TOKEN_KEY, CarriedResponse
21
+ from xtr_security_http.authorization.oauth2_scope_voter import parse_oauth2_scope
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Mapping
25
+
26
+ from starlette.requests import Request
27
+ from starlette.responses import Response
28
+ from xtr_event_dispatcher import SubscribedEvents
29
+ from xtr_security_core.authentication.token.token_interface import TokenInterface
30
+
31
+ from xtr_security_http.firewall_context_interface import FirewallContextInterface
32
+
33
+ __all__ = ["ExceptionListener"]
34
+
35
+
36
+ @final
37
+ class ExceptionListener(EventSubscriberInterface):
38
+ """Turns a security error raised while handling a request into a response.
39
+
40
+ Sits on the lifecycle's exception event, alone deciding ``401`` versus
41
+ ``403``:
42
+
43
+ - a carried handler response is sent as it is;
44
+ - an :class:`~xtr_security_core.exception.AuthenticationError` becomes the
45
+ firewall's entry-point challenge, or a bare ``401`` bearer challenge when
46
+ the firewall has no entry point;
47
+ - an :class:`~xtr_security_core.exception.AccessDeniedError` from a caller who
48
+ is not fully authenticated becomes the entry-point challenge for
49
+ insufficient authentication; from a fully authenticated caller it becomes
50
+ a scope challenge when the denied attribute is a scope, the firewall's
51
+ access-denied handler when it has one, or a plain ``403`` otherwise.
52
+
53
+ Any other exception is left untouched, to whatever else handles it.
54
+
55
+ The firewall the request ran under, and the token it authenticated, are read
56
+ from ``request.state`` — stashed there by the firewall — so this singleton
57
+ listener answers a request without reaching into the request scope. The
58
+ trust resolver is a singleton it is built with.
59
+ """
60
+
61
+ __slots__ = ("_trust_resolver",)
62
+
63
+ def __init__(self, trust_resolver: AuthenticationTrustResolverInterface) -> None:
64
+ """Build the listener over the application's trust resolver."""
65
+ self._trust_resolver = trust_resolver
66
+
67
+ @classmethod
68
+ @override
69
+ def get_subscribed_events(cls) -> Mapping[str | type, SubscribedEvents]:
70
+ """Listen to the lifecycle's exception event."""
71
+ return {ExceptionEvent: "on_exception"}
72
+
73
+ async def on_exception(self, event: ExceptionEvent) -> None:
74
+ """Answer the request when the exception is one this listener owns."""
75
+ error = event.exception
76
+ if isinstance(error, CarriedResponse):
77
+ event.set_response(error.response)
78
+ return
79
+ if not isinstance(error, (AuthenticationError, AccessDeniedError)):
80
+ return
81
+ context = _context_of(event.request)
82
+ if isinstance(error, AuthenticationError):
83
+ event.set_response(await self._on_authentication_error(context, event.request, error))
84
+ return
85
+ event.set_response(await self._on_access_denied(context, event.request, error))
86
+
87
+ async def _on_authentication_error(
88
+ self,
89
+ context: FirewallContextInterface | None,
90
+ request: Request,
91
+ error: AuthenticationError,
92
+ ) -> Response:
93
+ """Answer an authentication error with the entry-point challenge, or a bare 401."""
94
+ if context is not None and context.entry_point is not None:
95
+ return await context.entry_point.start(request, error)
96
+ return _bare_challenge()
97
+
98
+ async def _on_access_denied(
99
+ self,
100
+ context: FirewallContextInterface | None,
101
+ request: Request,
102
+ error: AccessDeniedError,
103
+ ) -> Response:
104
+ """Answer a denial: a challenge when not fully authenticated, else a refusal."""
105
+ if not self._is_full_fledged(request):
106
+ if context is not None and context.entry_point is not None:
107
+ return await context.entry_point.start(request, InsufficientAuthenticationError())
108
+ return _bare_challenge()
109
+ if (
110
+ _is_scope_denial(error)
111
+ and context is not None
112
+ and context.scope_denied_handler is not None
113
+ ):
114
+ answered = await context.scope_denied_handler.handle(request, error)
115
+ if answered is not None:
116
+ return answered
117
+ if context is not None and context.access_denied_handler is not None:
118
+ answered = await context.access_denied_handler.handle(request, error)
119
+ if answered is not None:
120
+ return answered
121
+ return JSONResponse({"error": "access_denied"}, status_code=403)
122
+
123
+ def _is_full_fledged(self, request: Request) -> bool:
124
+ """Tell whether the request's token is fully authenticated."""
125
+ token = _token_of(request)
126
+ return self._trust_resolver.is_full_fledged(token)
127
+
128
+
129
+ def _context_of(request: Request) -> FirewallContextInterface | None:
130
+ """Return the firewall context the firewall stashed for ``request``, if any."""
131
+ context = getattr(request.state, FIREWALL_CONTEXT_KEY, None)
132
+ return cast("FirewallContextInterface | None", context)
133
+
134
+
135
+ def _token_of(request: Request) -> TokenInterface | None:
136
+ """Return the token the firewall stashed for ``request``, if any."""
137
+ return cast("TokenInterface | None", getattr(request.state, TOKEN_KEY, None))
138
+
139
+
140
+ def _bare_challenge() -> Response:
141
+ """Return a plain ``401`` carrying a bare bearer challenge."""
142
+ return JSONResponse(
143
+ {"error": "unauthorized"},
144
+ status_code=401,
145
+ headers={"WWW-Authenticate": "Bearer"},
146
+ )
147
+
148
+
149
+ def _is_scope_denial(error: AccessDeniedError) -> bool:
150
+ """Tell whether the denied attribute is an OAuth2 scope request."""
151
+ return any(parse_oauth2_scope(attribute) is not None for attribute in error.attributes)
@@ -0,0 +1,142 @@
1
+ """The firewall dependency: attach it to an app, a router or a route."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import inspect
7
+ from typing import TYPE_CHECKING, Final, ParamSpec, TypeVar, cast, overload
8
+
9
+ from fastapi import APIRouter
10
+ from fastapi.params import Security
11
+
12
+ from xtr_security_http.firewall_scheme import FirewallScheme
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Awaitable, Callable, Iterable
16
+
17
+ __all__ = ["Firewall"]
18
+
19
+ _P = ParamSpec("_P")
20
+ _R = TypeVar("_R")
21
+
22
+ _HIDDEN_PREFIX: Final = "_xtr_firewall_"
23
+
24
+
25
+ class Firewall(Security):
26
+ """A firewall a request passes before its endpoint runs.
27
+
28
+ Written wherever the framework takes a dependency, as a decorator, or on a
29
+ router before its routes:
30
+
31
+ ```python
32
+ app = FastAPI(dependencies=[Firewall()]) # chosen per request by the config
33
+ api = Firewall("api") # bound by name: exact OpenAPI scheme
34
+
35
+ router = APIRouter(prefix="/api", dependencies=[api])
36
+
37
+
38
+ @router.get("/books", dependencies=[api.scopes("books:read")])
39
+ async def books() -> list[Book]: ...
40
+
41
+
42
+ @router.delete("/books/{isbn}")
43
+ @Firewall("api") # below the route decorator
44
+ async def delete_book(isbn: str) -> None: ...
45
+ ```
46
+
47
+ Bound by name, it resolves that firewall directly and shows its exact
48
+ scheme in OpenAPI. Unbound, it is matched per request to the first firewall
49
+ whose request matcher claims it. Authentication runs once per request
50
+ however many firewall dependencies a route carries; the accumulated
51
+ ``scopes`` are checked against the token with the OAuth2 scope voter.
52
+
53
+ Attributes:
54
+ firewall_name: The firewall bound by name, or ``None`` when matched per
55
+ request.
56
+ """
57
+
58
+ firewall_name: str | None # pyright: ignore[reportUninitializedInstanceVariable]
59
+
60
+ def __init__(
61
+ self,
62
+ name: str | None = None,
63
+ *,
64
+ scopes: Iterable[str] = (),
65
+ _scheme: FirewallScheme | None = None,
66
+ ) -> None:
67
+ """Attach the firewall ``name`` (or the matched one), requiring ``scopes``.
68
+
69
+ Args:
70
+ name: The firewall to bind by name, or ``None`` to match per request.
71
+ scopes: The scopes a request must carry, added to the OpenAPI
72
+ operation and checked by the scope voter.
73
+ _scheme: The shared scheme a scoped variant reuses; not for callers.
74
+ """
75
+ scheme = _scheme if _scheme is not None else FirewallScheme(name)
76
+ super().__init__(dependency=scheme, scopes=list(scopes), use_cache=True)
77
+ object.__setattr__(self, "firewall_name", name)
78
+
79
+ def scoped(self, *scopes: str) -> Firewall:
80
+ """Return a variant requiring ``scopes``, sharing this firewall's scheme.
81
+
82
+ The returned firewall points at the same scheme object, so the two are
83
+ one security scheme in OpenAPI with the scopes added to the operation
84
+ that uses the variant. Named ``scoped`` — not ``scopes`` — because the
85
+ framework reads ``scopes`` as the accumulated list on the marker itself.
86
+ """
87
+ scheme = cast("FirewallScheme", self.dependency)
88
+ return Firewall(self.firewall_name, scopes=scopes, _scheme=scheme)
89
+
90
+ @overload
91
+ def __call__(self, target: APIRouter, /) -> APIRouter: ...
92
+
93
+ @overload
94
+ def __call__(self, target: Callable[_P, _R], /) -> Callable[_P, _R]: ...
95
+
96
+ def __call__(self, target: APIRouter | Callable[_P, _R], /) -> APIRouter | Callable[_P, _R]:
97
+ """Attach this firewall to every route of ``target``, or to the endpoint ``target``.
98
+
99
+ A router must have no route yet — the framework copies a router's
100
+ dependencies into each route as it is added. An endpoint must be
101
+ decorated below its route decorator, which reads the endpoint's
102
+ signature then.
103
+ """
104
+ if isinstance(target, APIRouter):
105
+ target.dependencies.append(self)
106
+ return target
107
+ return self._decorate(target)
108
+
109
+ def _decorate(self, endpoint: Callable[_P, _R]) -> Callable[_P, _R]:
110
+ """Return ``endpoint`` taking this firewall as a hidden dependency."""
111
+ signature = inspect.signature(endpoint)
112
+ taken = set(signature.parameters)
113
+ index = 0
114
+ while f"{_HIDDEN_PREFIX}{index}" in taken:
115
+ index += 1
116
+ name = f"{_HIDDEN_PREFIX}{index}"
117
+
118
+ parameters = list(signature.parameters.values())
119
+ keywords = [p for p in parameters if p.kind is inspect.Parameter.VAR_KEYWORD]
120
+ others = [p for p in parameters if p.kind is not inspect.Parameter.VAR_KEYWORD]
121
+ hidden = inspect.Parameter(name, inspect.Parameter.KEYWORD_ONLY, default=self)
122
+ extended = signature.replace(parameters=[*others, hidden, *keywords])
123
+
124
+ if inspect.iscoroutinefunction(endpoint):
125
+ call = cast("Callable[_P, Awaitable[object]]", endpoint)
126
+
127
+ @functools.wraps(endpoint)
128
+ async def asynchronous(*args: _P.args, **kwargs: _P.kwargs) -> object:
129
+ _ = kwargs.pop(name, None)
130
+ return await call(*args, **kwargs)
131
+
132
+ wrapper = cast("Callable[_P, _R]", asynchronous)
133
+ else:
134
+
135
+ @functools.wraps(endpoint)
136
+ def synchronous(*args: _P.args, **kwargs: _P.kwargs) -> _R:
137
+ _ = kwargs.pop(name, None)
138
+ return endpoint(*args, **kwargs)
139
+
140
+ wrapper = synchronous
141
+ wrapper.__signature__ = extended # pyright: ignore[reportAttributeAccessIssue] # ty: ignore[unresolved-attribute]
142
+ return wrapper
@@ -0,0 +1,64 @@
1
+ """The per-firewall contract the firewall runner and exception listener read.
2
+
3
+ The HTTP edge runs a firewall without knowing how its parts were built: the
4
+ runner authenticates through the manager and decides through the access
5
+ listener, and the exception listener answers a failure through the entry point
6
+ and handlers. Both read those parts off this contract, so the concrete context
7
+ that gathers them — built by the bundle — lives outside this package while the
8
+ runtime here depends only on the shape.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
14
+
15
+ if TYPE_CHECKING:
16
+ from fastapi.security.base import SecurityBase
17
+ from xtr_event_dispatcher_contracts import EventDispatcherInterface
18
+
19
+ from xtr_security_http.authentication.authenticator_manager_interface import (
20
+ AuthenticatorManagerInterface,
21
+ )
22
+ from xtr_security_http.authorization.access_denied_handler_interface import (
23
+ AccessDeniedHandlerInterface,
24
+ )
25
+ from xtr_security_http.entry_point.authentication_entry_point_interface import (
26
+ AuthenticationEntryPointInterface,
27
+ )
28
+ from xtr_security_http.firewall.access_listener import AccessListener
29
+
30
+ __all__ = ["FirewallContextInterface"]
31
+
32
+
33
+ @runtime_checkable
34
+ class FirewallContextInterface(Protocol):
35
+ """The runtime pieces of one firewall, resolved once and shared for its lifetime.
36
+
37
+ Groups the manager that authenticates a request, the access listener that
38
+ decides it, the entry point and handlers that answer it, the firewall's own
39
+ event dispatcher, and the OpenAPI scheme it contributes.
40
+
41
+ Attributes:
42
+ name: The firewall's name — the key it is looked up by, and the memo
43
+ key that keeps its authentication to one pass per request.
44
+ authenticator_manager: Runs the firewall's authenticators.
45
+ access_listener: Decides a request against the firewall's rules.
46
+ dispatcher: The firewall's own event dispatcher.
47
+ scheme: The FastAPI security object the firewall shows in OpenAPI.
48
+ security: Whether the firewall authenticates at all; ``False`` lets
49
+ every request through untouched.
50
+ entry_point: Answers an unauthenticated request with a challenge.
51
+ access_denied_handler: Answers a fully-authenticated caller's denial.
52
+ scope_denied_handler: Answers a denied OAuth2 scope with its own
53
+ challenge, ahead of the plain access-denied handler.
54
+ """
55
+
56
+ name: str
57
+ authenticator_manager: AuthenticatorManagerInterface
58
+ access_listener: AccessListener
59
+ dispatcher: EventDispatcherInterface
60
+ scheme: SecurityBase
61
+ security: bool
62
+ entry_point: AuthenticationEntryPointInterface | None
63
+ access_denied_handler: AccessDeniedHandlerInterface | None
64
+ scope_denied_handler: AccessDeniedHandlerInterface | None
@@ -0,0 +1,93 @@
1
+ """The firewalls of an application: found by name, or by matching a request."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, final
6
+
7
+ from typing_extensions import override
8
+ from xtr_security_core.exception import InvalidArgumentError
9
+
10
+ from .exception.unknown_firewall_error import UnknownFirewallError
11
+ from .firewall_map_interface import FirewallMapInterface
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Sequence
15
+
16
+ from starlette.requests import Request
17
+
18
+ from .firewall_context_interface import FirewallContextInterface
19
+ from .request_matcher.request_matcher_interface import RequestMatcherInterface
20
+
21
+ __all__ = ["FirewallMap"]
22
+
23
+
24
+ @final
25
+ class FirewallMap(FirewallMapInterface):
26
+ """Holds an application's firewalls, found by name or by matching a request.
27
+
28
+ A firewall bound by name — ``Firewall("api")`` — is looked up directly. An
29
+ unbound firewall — ``Firewall()`` — is matched to the first firewall whose
30
+ request matcher claims the request, in the order they were registered, so a
31
+ narrow firewall must be registered before a catch-all it would shadow.
32
+
33
+ Each firewall is paired with a
34
+ :class:`~xtr_security_http.request_matcher.request_matcher_interface.RequestMatcherInterface`
35
+ — a path, host, method or callable matcher, or a chain of them, from the
36
+ ``request_matcher`` package — so the map matches a request without a notion
37
+ of matching of its own. A chain of no matchers claims every request, the
38
+ catch-all a firewall registered last relies on.
39
+
40
+ The contexts it holds are read through
41
+ :class:`~xtr_security_http.firewall_context_interface.FirewallContextInterface`,
42
+ so the map works over whatever concrete context the bundle gathers.
43
+ """
44
+
45
+ __slots__ = ("_by_name", "_matchers")
46
+
47
+ def __init__(
48
+ self,
49
+ firewalls: Sequence[tuple[RequestMatcherInterface, FirewallContextInterface]] = (),
50
+ ) -> None:
51
+ """Record the firewalls, keyed by name and kept in matching order.
52
+
53
+ Raises:
54
+ InvalidArgumentError: When two firewalls share a name.
55
+ """
56
+ self._matchers: tuple[tuple[RequestMatcherInterface, FirewallContextInterface], ...] = (
57
+ tuple(firewalls)
58
+ )
59
+ self._by_name: dict[str, FirewallContextInterface] = {}
60
+ for _, context in self._matchers:
61
+ if context.name in self._by_name:
62
+ raise InvalidArgumentError(f'Two firewalls share the name "{context.name}".')
63
+ self._by_name[context.name] = context
64
+
65
+ @override
66
+ def has(self, name: str) -> bool:
67
+ """Tell whether a firewall is registered under ``name``."""
68
+ return name in self._by_name
69
+
70
+ @override
71
+ def get(self, name: str) -> FirewallContextInterface:
72
+ """Return the firewall named ``name``.
73
+
74
+ Raises:
75
+ UnknownFirewallError: When no firewall carries that name.
76
+ """
77
+ try:
78
+ return self._by_name[name]
79
+ except KeyError as error:
80
+ raise UnknownFirewallError(name, tuple(self._by_name)) from error
81
+
82
+ @override
83
+ def match(self, request: Request) -> FirewallContextInterface | None:
84
+ """Return the first firewall whose matcher claims ``request``, or ``None``."""
85
+ for matcher, context in self._matchers:
86
+ if matcher.matches(request):
87
+ return context
88
+ return None
89
+
90
+ @override
91
+ def names(self) -> tuple[str, ...]:
92
+ """Return the names of every firewall registered."""
93
+ return tuple(self._by_name)