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.
- xtr_security_http/__init__.py +143 -0
- xtr_security_http/_runner.py +134 -0
- xtr_security_http/_state.py +57 -0
- xtr_security_http/access_map.py +52 -0
- xtr_security_http/access_map_interface.py +24 -0
- xtr_security_http/access_token/__init__.py +19 -0
- xtr_security_http/access_token/access_token_extractor_interface.py +31 -0
- xtr_security_http/access_token/access_token_handler_interface.py +34 -0
- xtr_security_http/access_token/chain_access_token_extractor.py +58 -0
- xtr_security_http/access_token/form_encoded_body_extractor.py +57 -0
- xtr_security_http/access_token/header_access_token_extractor.py +80 -0
- xtr_security_http/access_token/oidc/__init__.py +41 -0
- xtr_security_http/access_token/oidc/exception/__init__.py +7 -0
- xtr_security_http/access_token/oidc/exception/oidc_key_set_error.py +18 -0
- xtr_security_http/access_token/oidc/oidc_token_handler.py +246 -0
- xtr_security_http/access_token/query_access_token_extractor.py +51 -0
- xtr_security_http/authentication/__init__.py +24 -0
- xtr_security_http/authentication/_sensitive.py +60 -0
- xtr_security_http/authentication/authentication_failure_handler_interface.py +31 -0
- xtr_security_http/authentication/authentication_success_handler_interface.py +31 -0
- xtr_security_http/authentication/authenticator_manager.py +232 -0
- xtr_security_http/authentication/authenticator_manager_interface.py +40 -0
- xtr_security_http/authentication/expose_security_level.py +27 -0
- xtr_security_http/authenticator/__init__.py +9 -0
- xtr_security_http/authenticator/abstract_authenticator.py +66 -0
- xtr_security_http/authenticator/access_token_authenticator.py +199 -0
- xtr_security_http/authenticator/authenticator_interface.py +68 -0
- xtr_security_http/authenticator/oidc/__init__.py +7 -0
- xtr_security_http/authenticator/oidc/oidc_jwks.py +87 -0
- xtr_security_http/authenticator/passport/__init__.py +8 -0
- xtr_security_http/authenticator/passport/badge/__init__.py +16 -0
- xtr_security_http/authenticator/passport/badge/badge_interface.py +24 -0
- xtr_security_http/authenticator/passport/badge/password_upgrade_badge.py +59 -0
- xtr_security_http/authenticator/passport/badge/pre_authenticated_user_badge.py +27 -0
- xtr_security_http/authenticator/passport/badge/user_badge.py +163 -0
- xtr_security_http/authenticator/passport/credentials/__init__.py +9 -0
- xtr_security_http/authenticator/passport/credentials/credentials_interface.py +21 -0
- xtr_security_http/authenticator/passport/credentials/custom_credentials.py +64 -0
- xtr_security_http/authenticator/passport/credentials/password_credentials.py +51 -0
- xtr_security_http/authenticator/passport/passport.py +104 -0
- xtr_security_http/authenticator/passport/self_validating_passport.py +42 -0
- xtr_security_http/authenticator/token/__init__.py +7 -0
- xtr_security_http/authenticator/token/post_authentication_token.py +43 -0
- xtr_security_http/authorization/__init__.py +15 -0
- xtr_security_http/authorization/access_denied_handler_interface.py +27 -0
- xtr_security_http/authorization/insufficient_scope_access_denied_handler.py +66 -0
- xtr_security_http/authorization/oauth2_scope_voter.py +122 -0
- xtr_security_http/decorator/__init__.py +16 -0
- xtr_security_http/decorator/current_user.py +83 -0
- xtr_security_http/decorator/is_granted.py +199 -0
- xtr_security_http/decorator/is_granted_context.py +14 -0
- xtr_security_http/entry_point/__init__.py +7 -0
- xtr_security_http/entry_point/authentication_entry_point_interface.py +33 -0
- xtr_security_http/event/__init__.py +20 -0
- xtr_security_http/event/authentication_token_created_event.py +48 -0
- xtr_security_http/event/check_passport_event.py +48 -0
- xtr_security_http/event/login_failure_event.py +79 -0
- xtr_security_http/event/login_success_event.py +79 -0
- xtr_security_http/event_listener/__init__.py +21 -0
- xtr_security_http/event_listener/check_credentials_listener.py +162 -0
- xtr_security_http/event_listener/password_migrating_listener.py +79 -0
- xtr_security_http/event_listener/user_checker_listener.py +60 -0
- xtr_security_http/event_listener/user_provider_listener.py +53 -0
- xtr_security_http/exception/__init__.py +18 -0
- xtr_security_http/exception/firewall_not_booted_error.py +18 -0
- xtr_security_http/exception/invalid_access_token_error.py +20 -0
- xtr_security_http/exception/unknown_firewall_error.py +34 -0
- xtr_security_http/firewall/__init__.py +15 -0
- xtr_security_http/firewall/access_listener.py +66 -0
- xtr_security_http/firewall/exception_listener.py +151 -0
- xtr_security_http/firewall/firewall.py +142 -0
- xtr_security_http/firewall_context_interface.py +64 -0
- xtr_security_http/firewall_map.py +93 -0
- xtr_security_http/firewall_map_interface.py +48 -0
- xtr_security_http/firewall_scheme.py +114 -0
- xtr_security_http/firewall_scheme_registry.py +87 -0
- xtr_security_http/oidc/__init__.py +7 -0
- xtr_security_http/oidc/oidc_discovery.py +238 -0
- xtr_security_http/py.typed +0 -0
- xtr_security_http/request_matcher/__init__.py +28 -0
- xtr_security_http/request_matcher/_pattern.py +27 -0
- xtr_security_http/request_matcher/callable_request_matcher.py +37 -0
- xtr_security_http/request_matcher/chain_request_matcher.py +37 -0
- xtr_security_http/request_matcher/host_request_matcher.py +43 -0
- xtr_security_http/request_matcher/ip_request_matcher.py +37 -0
- xtr_security_http/request_matcher/method_request_matcher.py +32 -0
- xtr_security_http/request_matcher/path_request_matcher.py +41 -0
- xtr_security_http/request_matcher/request_matcher_interface.py +25 -0
- xtr_security_http/security_events.py +41 -0
- xtr_security_http-3.0.0.dist-info/METADATA +427 -0
- xtr_security_http-3.0.0.dist-info/RECORD +93 -0
- xtr_security_http-3.0.0.dist-info/WHEEL +4 -0
- 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)
|