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,42 @@
|
|
|
1
|
+
"""A passport whose user was already authenticated, carrying no credentials."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, final
|
|
6
|
+
|
|
7
|
+
from xtr_security_http.authenticator.passport.badge.pre_authenticated_user_badge import (
|
|
8
|
+
PreAuthenticatedUserBadge,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
from .passport import Passport
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Iterable, Mapping
|
|
15
|
+
|
|
16
|
+
from .badge.badge_interface import BadgeInterface
|
|
17
|
+
from .badge.user_badge import UserBadge
|
|
18
|
+
|
|
19
|
+
__all__ = ["SelfValidatingPassport"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@final
|
|
23
|
+
class SelfValidatingPassport(Passport):
|
|
24
|
+
"""A passport for a user proven before it was built.
|
|
25
|
+
|
|
26
|
+
A bearer token was verified by its handler, so there are no credentials to
|
|
27
|
+
check — only the user it names. Every self-validating passport carries a
|
|
28
|
+
:class:`~xtr_security_http.authenticator.passport.badge.pre_authenticated_user_badge.PreAuthenticatedUserBadge`,
|
|
29
|
+
which is resolved from the start, so the passport check has nothing to
|
|
30
|
+
verify and settles on the user the token proved.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
user_badge: UserBadge,
|
|
36
|
+
badges: Iterable[BadgeInterface] = (),
|
|
37
|
+
attributes: Mapping[str, object] | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Record the user badge, add the pre-authenticated badge, keep the rest."""
|
|
40
|
+
super().__init__(user_badge, badges, attributes)
|
|
41
|
+
if not self.has_badge(PreAuthenticatedUserBadge):
|
|
42
|
+
_ = self.add_badge(PreAuthenticatedUserBadge())
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""The token authentication settles on once a user is established."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from xtr_security_core.authentication.token.abstract_token import AbstractToken
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
|
|
12
|
+
from xtr_security_core.user.user_interface import UserInterface
|
|
13
|
+
|
|
14
|
+
__all__ = ["PostAuthenticationToken"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PostAuthenticationToken(AbstractToken):
|
|
18
|
+
"""A user, the firewall that authenticated them, and the roles decided.
|
|
19
|
+
|
|
20
|
+
The ordinary outcome of a successful authentication. It lives in the core,
|
|
21
|
+
not only at the HTTP edge, because a worker or a command authenticates the
|
|
22
|
+
same way — it sets one of these on the token storage without a request ever
|
|
23
|
+
being involved.
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
firewall_name: The firewall (or unit of work) that authenticated.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
_firewall_name: str
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
user: UserInterface,
|
|
34
|
+
firewall_name: str,
|
|
35
|
+
roles: Sequence[str],
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Record the user, the firewall that authenticated them, and their roles."""
|
|
38
|
+
super().__init__(user=user, roles=roles)
|
|
39
|
+
self._firewall_name = firewall_name
|
|
40
|
+
|
|
41
|
+
def get_firewall_name(self) -> str:
|
|
42
|
+
"""Return the firewall (or unit of work) that authenticated the user."""
|
|
43
|
+
return self._firewall_name
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Authorization at the HTTP edge: scope voting and turning a denial into a response."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .access_denied_handler_interface import AccessDeniedHandlerInterface
|
|
6
|
+
from .insufficient_scope_access_denied_handler import InsufficientScopeAccessDeniedHandler
|
|
7
|
+
from .oauth2_scope_voter import OAuth2ScopeVoter, oauth2_scope, parse_oauth2_scope
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"AccessDeniedHandlerInterface",
|
|
11
|
+
"InsufficientScopeAccessDeniedHandler",
|
|
12
|
+
"OAuth2ScopeVoter",
|
|
13
|
+
"oauth2_scope",
|
|
14
|
+
"parse_oauth2_scope",
|
|
15
|
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""What turns an access denial into a response of its own."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from starlette.requests import Request
|
|
9
|
+
from starlette.responses import Response
|
|
10
|
+
from xtr_security_core.exception import AccessDeniedError
|
|
11
|
+
|
|
12
|
+
__all__ = ["AccessDeniedHandlerInterface"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@runtime_checkable
|
|
16
|
+
class AccessDeniedHandlerInterface(Protocol):
|
|
17
|
+
"""Shapes the response to a caller who is known but not allowed.
|
|
18
|
+
|
|
19
|
+
A firewall hands a denial here when the caller is fully authenticated —
|
|
20
|
+
the answer is a refusal, not a challenge. A handler may return a response
|
|
21
|
+
of its own, or ``None`` to leave the firewall to answer with a plain
|
|
22
|
+
``403``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
async def handle(self, request: Request, error: AccessDeniedError) -> Response | None:
|
|
26
|
+
"""Return the response that refuses ``request``, or ``None`` for a plain refusal."""
|
|
27
|
+
...
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""The access-denied handler that answers a scope shortfall the RFC 6750 way."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, final
|
|
6
|
+
|
|
7
|
+
from starlette.responses import JSONResponse
|
|
8
|
+
from typing_extensions import override
|
|
9
|
+
|
|
10
|
+
from .access_denied_handler_interface import AccessDeniedHandlerInterface
|
|
11
|
+
from .oauth2_scope_voter import parse_oauth2_scope
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from starlette.requests import Request
|
|
15
|
+
from starlette.responses import Response
|
|
16
|
+
from xtr_security_core.exception import AccessDeniedError
|
|
17
|
+
|
|
18
|
+
__all__ = ["InsufficientScopeAccessDeniedHandler"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@final
|
|
22
|
+
class InsufficientScopeAccessDeniedHandler(AccessDeniedHandlerInterface):
|
|
23
|
+
"""Answers a denied scope check with ``403`` and a bearer scope challenge.
|
|
24
|
+
|
|
25
|
+
When a bearer token is valid but lacks a scope a resource requires, RFC
|
|
26
|
+
6750 says the answer is ``403`` with a ``WWW-Authenticate: Bearer`` header
|
|
27
|
+
naming ``error="insufficient_scope"`` and the ``scope`` the resource needs.
|
|
28
|
+
This handler reads the scopes out of the denied ``OAUTH2_SCOPE(...)``
|
|
29
|
+
attribute and writes that challenge, with the realm when one is configured.
|
|
30
|
+
|
|
31
|
+
Attributes:
|
|
32
|
+
realm: The protection realm named in the challenge, when set.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
__slots__ = ("_realm",)
|
|
36
|
+
|
|
37
|
+
def __init__(self, realm: str | None = None) -> None:
|
|
38
|
+
"""Record the realm named in the challenge, if any."""
|
|
39
|
+
self._realm = realm
|
|
40
|
+
|
|
41
|
+
@override
|
|
42
|
+
async def handle(self, request: Request, error: AccessDeniedError) -> Response | None:
|
|
43
|
+
"""Return a ``403`` carrying the RFC 6750 insufficient-scope challenge."""
|
|
44
|
+
del request
|
|
45
|
+
scope = self._required_scope(error)
|
|
46
|
+
parts = ["Bearer"]
|
|
47
|
+
if self._realm is not None:
|
|
48
|
+
parts.append(f'realm="{self._realm}"')
|
|
49
|
+
parts.append('error="insufficient_scope"')
|
|
50
|
+
parts.append('error_description="The request requires higher privileges than provided."')
|
|
51
|
+
if scope:
|
|
52
|
+
parts.append(f'scope="{scope}"')
|
|
53
|
+
challenge = parts[0] + " " + ", ".join(parts[1:])
|
|
54
|
+
return JSONResponse(
|
|
55
|
+
{"error": "insufficient_scope"},
|
|
56
|
+
status_code=403,
|
|
57
|
+
headers={"WWW-Authenticate": challenge},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def _required_scope(self, error: AccessDeniedError) -> str:
|
|
61
|
+
"""Read the space-joined scopes out of the denied scope attribute."""
|
|
62
|
+
for attribute in error.attributes:
|
|
63
|
+
scopes = parse_oauth2_scope(attribute)
|
|
64
|
+
if scopes is not None:
|
|
65
|
+
return " ".join(scopes)
|
|
66
|
+
return ""
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""A voter that grants by the scopes a bearer token carries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar, Final, cast, final
|
|
6
|
+
|
|
7
|
+
from typing_extensions import override
|
|
8
|
+
from xtr_security_core.authorization.voter.access import Access
|
|
9
|
+
from xtr_security_core.authorization.voter.cacheable_voter_interface import CacheableVoterInterface
|
|
10
|
+
from xtr_security_core.exception import InvalidArgumentError
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Iterable, Sequence
|
|
14
|
+
|
|
15
|
+
from xtr_security_core.authentication.token.token_interface import TokenInterface
|
|
16
|
+
from xtr_security_core.authorization.voter.vote import Vote
|
|
17
|
+
|
|
18
|
+
__all__ = ["OAuth2ScopeVoter", "oauth2_scope", "parse_oauth2_scope"]
|
|
19
|
+
|
|
20
|
+
_PREFIX: Final = "OAUTH2_SCOPE("
|
|
21
|
+
_SUFFIX: Final = ")"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def oauth2_scope(*scopes: str) -> str:
|
|
25
|
+
"""Build the attribute string that asks for ``scopes``.
|
|
26
|
+
|
|
27
|
+
``oauth2_scope("books:read", "books:write")`` returns
|
|
28
|
+
``"OAUTH2_SCOPE(books:read books:write)"`` — the attribute an access rule or
|
|
29
|
+
an ``IsGranted`` hands to
|
|
30
|
+
:class:`OAuth2ScopeVoter`.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
InvalidArgumentError: When called with no scopes, which would build an
|
|
34
|
+
attribute that grants any scoped token.
|
|
35
|
+
"""
|
|
36
|
+
if not scopes:
|
|
37
|
+
raise InvalidArgumentError("Asking for OAuth2 scopes requires at least one scope.")
|
|
38
|
+
return f"{_PREFIX}{' '.join(scopes)}{_SUFFIX}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@final
|
|
42
|
+
class OAuth2ScopeVoter(CacheableVoterInterface):
|
|
43
|
+
"""Grants ``OAUTH2_SCOPE(...)`` when the token carries every scope asked for.
|
|
44
|
+
|
|
45
|
+
A bearer token keeps the scopes it was issued with in the ``oauth2_scope``
|
|
46
|
+
attribute — a sequence, or a single space-separated string. This voter reads
|
|
47
|
+
an ``OAUTH2_SCOPE(a b)`` attribute and grants only when every scope in it is
|
|
48
|
+
among the token's. A token that carries no scopes at all is not judged: the
|
|
49
|
+
voter abstains, leaving the question to the others.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
SCOPE_ATTRIBUTE: ClassVar[str] = "oauth2_scope"
|
|
53
|
+
|
|
54
|
+
@override
|
|
55
|
+
async def vote(
|
|
56
|
+
self,
|
|
57
|
+
token: TokenInterface,
|
|
58
|
+
subject: object,
|
|
59
|
+
attributes: Sequence[object],
|
|
60
|
+
vote: Vote | None = None,
|
|
61
|
+
) -> Access:
|
|
62
|
+
"""Grant when the token holds every asked-for scope; abstain without scopes."""
|
|
63
|
+
del subject
|
|
64
|
+
result = Access.ABSTAIN
|
|
65
|
+
for attribute in attributes:
|
|
66
|
+
scopes = parse_oauth2_scope(attribute)
|
|
67
|
+
if scopes is None:
|
|
68
|
+
continue
|
|
69
|
+
if not token.has_attribute(self.SCOPE_ATTRIBUTE):
|
|
70
|
+
continue
|
|
71
|
+
result = Access.DENIED
|
|
72
|
+
held = self._held_scopes(token)
|
|
73
|
+
missing = [scope for scope in scopes if scope not in held]
|
|
74
|
+
if not missing:
|
|
75
|
+
return Access.GRANTED
|
|
76
|
+
if vote is not None:
|
|
77
|
+
vote.add_reason(f"The token is missing the scope(s): {', '.join(missing)}.")
|
|
78
|
+
return result
|
|
79
|
+
|
|
80
|
+
@override
|
|
81
|
+
def supports_attribute(self, attribute: str) -> bool:
|
|
82
|
+
"""Tell whether ``attribute`` is a scope request ``parse_oauth2_scope`` would read."""
|
|
83
|
+
return parse_oauth2_scope(attribute) is not None
|
|
84
|
+
|
|
85
|
+
@override
|
|
86
|
+
def supports_type(self, subject_type: str) -> bool:
|
|
87
|
+
"""Vote on any subject: scopes are read from the token alone."""
|
|
88
|
+
del subject_type
|
|
89
|
+
return True
|
|
90
|
+
|
|
91
|
+
def _held_scopes(self, token: TokenInterface) -> frozenset[str]:
|
|
92
|
+
"""Read the scopes the token carries, from a string or a sequence."""
|
|
93
|
+
value = token.get_attribute(self.SCOPE_ATTRIBUTE)
|
|
94
|
+
if isinstance(value, str):
|
|
95
|
+
return frozenset(value.split())
|
|
96
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
97
|
+
items = cast("Iterable[object]", value)
|
|
98
|
+
return frozenset(str(scope) for scope in items)
|
|
99
|
+
return frozenset()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def parse_oauth2_scope(attribute: object) -> tuple[str, ...] | None:
|
|
103
|
+
"""Read the scopes out of an ``OAUTH2_SCOPE(...)`` attribute, or ``None``.
|
|
104
|
+
|
|
105
|
+
The one reader of the attribute :func:`oauth2_scope` writes, shared by the
|
|
106
|
+
voter, the exception listener and the insufficient-scope handler so they
|
|
107
|
+
agree on what a scope request is. A request that names no scopes —
|
|
108
|
+
``OAUTH2_SCOPE()`` or only whitespace inside — or anything not shaped like
|
|
109
|
+
the attribute reads as ``None``, so the voter abstains rather than granting
|
|
110
|
+
every scoped token vacuously.
|
|
111
|
+
"""
|
|
112
|
+
if (
|
|
113
|
+
not isinstance(attribute, str)
|
|
114
|
+
or not attribute.startswith(_PREFIX)
|
|
115
|
+
or not attribute.endswith(_SUFFIX)
|
|
116
|
+
):
|
|
117
|
+
return None
|
|
118
|
+
inner = attribute[len(_PREFIX) : -len(_SUFFIX)]
|
|
119
|
+
scopes = tuple(inner.split())
|
|
120
|
+
if not scopes:
|
|
121
|
+
return None
|
|
122
|
+
return scopes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""The route-surface decorators: the firewall's authorization markers.
|
|
2
|
+
|
|
3
|
+
:class:`~xtr_security_http.decorator.is_granted.IsGranted` and
|
|
4
|
+
:class:`~xtr_security_http.decorator.current_user.CurrentUser` are the markers a
|
|
5
|
+
route carries, and
|
|
6
|
+
:class:`~xtr_security_http.decorator.is_granted_context.IsGrantedContext` is the
|
|
7
|
+
context a closure attribute is handed.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from .current_user import CurrentUser
|
|
13
|
+
from .is_granted import IsGranted
|
|
14
|
+
from .is_granted_context import IsGrantedContext
|
|
15
|
+
|
|
16
|
+
__all__ = ["CurrentUser", "IsGranted", "IsGrantedContext"]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""The marker that injects the authenticated user into an endpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, final
|
|
6
|
+
|
|
7
|
+
from fastapi.params import Depends as DependsParam
|
|
8
|
+
|
|
9
|
+
# The framework reads the dependency's signature at runtime to fill it from the
|
|
10
|
+
# container, so the injection marker and the storage type stay importable here.
|
|
11
|
+
from xtr_dependency_injection import Injected # noqa: TC002
|
|
12
|
+
from xtr_security_core.authentication.token.storage.token_storage_interface import ( # noqa: TC002
|
|
13
|
+
TokenStorageInterface,
|
|
14
|
+
)
|
|
15
|
+
from xtr_security_core.exception import AuthenticationCredentialsNotFoundError, UnsupportedUserError
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from xtr_security_core.user.user_interface import UserInterface
|
|
19
|
+
|
|
20
|
+
__all__ = ["CurrentUser"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@final
|
|
24
|
+
class CurrentUser(DependsParam):
|
|
25
|
+
"""Injects the current user into an endpoint parameter.
|
|
26
|
+
|
|
27
|
+
Used as ``Annotated[User, CurrentUser()]``. By default the parameter must
|
|
28
|
+
have a user: an anonymous request raises
|
|
29
|
+
:class:`~xtr_security_core.exception.AuthenticationCredentialsNotFoundError`,
|
|
30
|
+
which becomes a ``401``. Passing ``optional=True`` — for a parameter typed
|
|
31
|
+
``User | None`` — returns ``None`` for an anonymous request instead. When
|
|
32
|
+
``user_class`` is given, a user of another class raises
|
|
33
|
+
:class:`~xtr_security_core.exception.UnsupportedUserError`. The dependency never
|
|
34
|
+
appears in the generated schema.
|
|
35
|
+
|
|
36
|
+
The token storage the current user comes from is a container-provided
|
|
37
|
+
dependency of the resolver, declared with :data:`~xtr_dependency_injection.Injected`
|
|
38
|
+
— so the request's scoped storage is filled by the framework, never fetched
|
|
39
|
+
from the container by hand.
|
|
40
|
+
|
|
41
|
+
(FastAPI does not hand a dependency the annotation of the parameter it
|
|
42
|
+
fills, so nullability and the expected class are stated on the marker
|
|
43
|
+
rather than read from ``User | None`` — the one departure from the
|
|
44
|
+
spelling in the plan; see the package's DoneClaim.)
|
|
45
|
+
|
|
46
|
+
Attributes:
|
|
47
|
+
optional: Whether an anonymous request yields ``None`` instead of raising.
|
|
48
|
+
user_class: The class a user must be, or ``None`` to accept any.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
optional: bool # pyright: ignore[reportUninitializedInstanceVariable]
|
|
52
|
+
user_class: type | None # pyright: ignore[reportUninitializedInstanceVariable]
|
|
53
|
+
|
|
54
|
+
def __init__(self, user_class: type | None = None, *, optional: bool = False) -> None:
|
|
55
|
+
"""Inject the current user, optionally typed and optionally nullable."""
|
|
56
|
+
object.__setattr__(self, "optional", optional)
|
|
57
|
+
object.__setattr__(self, "user_class", user_class)
|
|
58
|
+
super().__init__(dependency=self._resolve, use_cache=True)
|
|
59
|
+
|
|
60
|
+
async def _resolve(
|
|
61
|
+
self,
|
|
62
|
+
token_storage: Injected[TokenStorageInterface],
|
|
63
|
+
) -> UserInterface | None:
|
|
64
|
+
"""Return the current user, or ``None`` / an error for an anonymous request.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
AuthenticationCredentialsNotFoundError: When no user is
|
|
68
|
+
authenticated and the marker is not ``optional``.
|
|
69
|
+
UnsupportedUserError: When the user is not of ``user_class``.
|
|
70
|
+
"""
|
|
71
|
+
token = token_storage.get_token()
|
|
72
|
+
user = token.get_user() if token is not None else None
|
|
73
|
+
if user is None:
|
|
74
|
+
if self.optional:
|
|
75
|
+
return None
|
|
76
|
+
raise AuthenticationCredentialsNotFoundError(
|
|
77
|
+
"No authenticated user is available for this request.",
|
|
78
|
+
)
|
|
79
|
+
if self.user_class is not None and not isinstance(user, self.user_class):
|
|
80
|
+
raise UnsupportedUserError(
|
|
81
|
+
f"The current user is not a {self.user_class.__name__}.",
|
|
82
|
+
)
|
|
83
|
+
return user
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""The authorization dependency: require an attribute before an endpoint runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import inspect
|
|
7
|
+
from typing import TYPE_CHECKING, Annotated, Final, ParamSpec, TypeVar, cast, overload
|
|
8
|
+
|
|
9
|
+
from fastapi import Depends
|
|
10
|
+
from fastapi.params import Depends as DependsParam
|
|
11
|
+
|
|
12
|
+
# The framework reads the dependency's signature at runtime to fill it from the
|
|
13
|
+
# container, so the injection marker, the request and the service types the
|
|
14
|
+
# resolver receives stay importable here.
|
|
15
|
+
from starlette.requests import Request # noqa: TC002
|
|
16
|
+
from xtr_dependency_injection import Injected # noqa: TC002
|
|
17
|
+
from xtr_security_core.authentication.token.null_token import NullToken
|
|
18
|
+
from xtr_security_core.authentication.token.storage.token_storage_interface import ( # noqa: TC002
|
|
19
|
+
TokenStorageInterface,
|
|
20
|
+
)
|
|
21
|
+
from xtr_security_core.authorization.access_decision import AccessDecision
|
|
22
|
+
from xtr_security_core.authorization.access_decision_manager_interface import ( # noqa: TC002
|
|
23
|
+
AccessDecisionManagerInterface,
|
|
24
|
+
)
|
|
25
|
+
from xtr_security_core.exception import AccessDeniedError
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from collections.abc import Awaitable, Callable
|
|
29
|
+
|
|
30
|
+
__all__ = ["IsGranted"]
|
|
31
|
+
|
|
32
|
+
_P = ParamSpec("_P")
|
|
33
|
+
_R = TypeVar("_R")
|
|
34
|
+
|
|
35
|
+
_HIDDEN_PREFIX: Final = "_xtr_is_granted_"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class IsGranted(DependsParam):
|
|
39
|
+
"""Requires an attribute over a subject before the endpoint runs.
|
|
40
|
+
|
|
41
|
+
Written as a dependency or a decorator:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
@router.delete("/books/{isbn}")
|
|
45
|
+
@IsGranted("ROLE_ADMIN") # below the route decorator
|
|
46
|
+
async def delete_book(isbn: str) -> None: ...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@router.put("/books/{isbn}", dependencies=[IsGranted("BOOK_EDIT", subject=load_book)])
|
|
50
|
+
async def edit(book: Annotated[Book, Depends(load_book)]) -> Book: ...
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The attribute is a string a voter matches, or a callable
|
|
54
|
+
``(IsGrantedContext, subject) -> bool`` the closure voter runs. The subject
|
|
55
|
+
is ``None``, the name of a path parameter, or a callable used as a FastAPI
|
|
56
|
+
dependency — resolved once per request and shared with the endpoint. A
|
|
57
|
+
denial raises an
|
|
58
|
+
:class:`~xtr_security_core.exception.AccessDeniedError`, which the firewall's
|
|
59
|
+
exception listener turns into ``401`` or ``403``; a ``status_code`` answers
|
|
60
|
+
with that status directly instead. It requires a firewall to have run;
|
|
61
|
+
without one the check is made against the anonymous token.
|
|
62
|
+
|
|
63
|
+
The token storage and the access-decision manager the check reads are
|
|
64
|
+
container-provided dependencies of the resolver, declared with
|
|
65
|
+
:data:`~xtr_dependency_injection.Injected` — so the framework fills them from
|
|
66
|
+
the request scope, never fetched from the container by hand.
|
|
67
|
+
|
|
68
|
+
Attributes:
|
|
69
|
+
attribute: The attribute required.
|
|
70
|
+
subject: How the subject is obtained, or ``None``.
|
|
71
|
+
message: The denial message.
|
|
72
|
+
status_code: The status a denial answers with directly, or ``None``.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
attribute: object # pyright: ignore[reportUninitializedInstanceVariable]
|
|
76
|
+
subject: object # pyright: ignore[reportUninitializedInstanceVariable]
|
|
77
|
+
message: str | None # pyright: ignore[reportUninitializedInstanceVariable]
|
|
78
|
+
status_code: int | None # pyright: ignore[reportUninitializedInstanceVariable]
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
attribute: str | Callable[..., bool | Awaitable[bool]],
|
|
83
|
+
subject: str | Callable[..., object] | None = None,
|
|
84
|
+
*,
|
|
85
|
+
message: str | None = None,
|
|
86
|
+
status_code: int | None = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Require ``attribute`` over ``subject`` before the endpoint runs."""
|
|
89
|
+
object.__setattr__(self, "attribute", attribute)
|
|
90
|
+
object.__setattr__(self, "subject", subject)
|
|
91
|
+
object.__setattr__(self, "message", message)
|
|
92
|
+
object.__setattr__(self, "status_code", status_code)
|
|
93
|
+
super().__init__(dependency=self._build(), use_cache=False)
|
|
94
|
+
|
|
95
|
+
def _build(self) -> Callable[..., Awaitable[None]]:
|
|
96
|
+
"""Return the dependency function, wired to resolve the subject as FastAPI cares."""
|
|
97
|
+
subject = self.subject
|
|
98
|
+
if callable(subject):
|
|
99
|
+
dependency = subject
|
|
100
|
+
|
|
101
|
+
async def check_with_dependency(
|
|
102
|
+
resolved: Annotated[object, Depends(dependency)],
|
|
103
|
+
token_storage: Injected[TokenStorageInterface],
|
|
104
|
+
access_decision_manager: Injected[AccessDecisionManagerInterface],
|
|
105
|
+
) -> None:
|
|
106
|
+
await self._decide(resolved, token_storage, access_decision_manager)
|
|
107
|
+
|
|
108
|
+
return check_with_dependency
|
|
109
|
+
|
|
110
|
+
async def check(
|
|
111
|
+
request: Request,
|
|
112
|
+
token_storage: Injected[TokenStorageInterface],
|
|
113
|
+
access_decision_manager: Injected[AccessDecisionManagerInterface],
|
|
114
|
+
) -> None:
|
|
115
|
+
resolved = request.path_params.get(subject) if isinstance(subject, str) else None
|
|
116
|
+
await self._decide(resolved, token_storage, access_decision_manager)
|
|
117
|
+
|
|
118
|
+
return check
|
|
119
|
+
|
|
120
|
+
async def _decide(
|
|
121
|
+
self,
|
|
122
|
+
subject: object,
|
|
123
|
+
token_storage: TokenStorageInterface,
|
|
124
|
+
access_decision_manager: AccessDecisionManagerInterface,
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Decide the attribute over ``subject`` for the request's token.
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
AccessDeniedError: When the attribute is not granted and no
|
|
130
|
+
``status_code`` was set.
|
|
131
|
+
HTTPException: When the attribute is not granted and a
|
|
132
|
+
``status_code`` was set — answered with that status directly.
|
|
133
|
+
"""
|
|
134
|
+
token = token_storage.get_token() or NullToken()
|
|
135
|
+
decision = AccessDecision()
|
|
136
|
+
granted = await access_decision_manager.decide(token, [self.attribute], subject, decision)
|
|
137
|
+
if granted:
|
|
138
|
+
return
|
|
139
|
+
if self.status_code is not None:
|
|
140
|
+
from fastapi import HTTPException # noqa: PLC0415 -- http-only
|
|
141
|
+
|
|
142
|
+
raise HTTPException(
|
|
143
|
+
status_code=self.status_code,
|
|
144
|
+
detail=self.message or "Access denied.",
|
|
145
|
+
)
|
|
146
|
+
raise AccessDeniedError(
|
|
147
|
+
self.message or "Access Denied.",
|
|
148
|
+
attributes=(self.attribute,),
|
|
149
|
+
subject=subject,
|
|
150
|
+
access_decision=decision,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
@overload
|
|
154
|
+
def __call__(self, target: Callable[_P, _R], /) -> Callable[_P, _R]: ...
|
|
155
|
+
|
|
156
|
+
@overload
|
|
157
|
+
def __call__(self, target: object, /) -> object: ...
|
|
158
|
+
|
|
159
|
+
def __call__(self, target: Callable[_P, _R] | object, /) -> Callable[_P, _R] | object:
|
|
160
|
+
"""Attach this check to the endpoint ``target`` as a hidden dependency."""
|
|
161
|
+
if not callable(target):
|
|
162
|
+
return target
|
|
163
|
+
endpoint = cast("Callable[_P, _R]", target)
|
|
164
|
+
return self._decorate(endpoint)
|
|
165
|
+
|
|
166
|
+
def _decorate(self, endpoint: Callable[_P, _R]) -> Callable[_P, _R]:
|
|
167
|
+
"""Return ``endpoint`` taking this check as a hidden dependency."""
|
|
168
|
+
signature = inspect.signature(endpoint)
|
|
169
|
+
taken = set(signature.parameters)
|
|
170
|
+
index = 0
|
|
171
|
+
while f"{_HIDDEN_PREFIX}{index}" in taken:
|
|
172
|
+
index += 1
|
|
173
|
+
name = f"{_HIDDEN_PREFIX}{index}"
|
|
174
|
+
|
|
175
|
+
parameters = list(signature.parameters.values())
|
|
176
|
+
keywords = [p for p in parameters if p.kind is inspect.Parameter.VAR_KEYWORD]
|
|
177
|
+
others = [p for p in parameters if p.kind is not inspect.Parameter.VAR_KEYWORD]
|
|
178
|
+
hidden = inspect.Parameter(name, inspect.Parameter.KEYWORD_ONLY, default=self)
|
|
179
|
+
extended = signature.replace(parameters=[*others, hidden, *keywords])
|
|
180
|
+
|
|
181
|
+
if inspect.iscoroutinefunction(endpoint):
|
|
182
|
+
call = cast("Callable[_P, Awaitable[object]]", endpoint)
|
|
183
|
+
|
|
184
|
+
@functools.wraps(endpoint)
|
|
185
|
+
async def asynchronous(*args: _P.args, **kwargs: _P.kwargs) -> object:
|
|
186
|
+
_ = kwargs.pop(name, None)
|
|
187
|
+
return await call(*args, **kwargs)
|
|
188
|
+
|
|
189
|
+
wrapper = cast("Callable[_P, _R]", asynchronous)
|
|
190
|
+
else:
|
|
191
|
+
|
|
192
|
+
@functools.wraps(endpoint)
|
|
193
|
+
def synchronous(*args: _P.args, **kwargs: _P.kwargs) -> _R:
|
|
194
|
+
_ = kwargs.pop(name, None)
|
|
195
|
+
return endpoint(*args, **kwargs)
|
|
196
|
+
|
|
197
|
+
wrapper = synchronous
|
|
198
|
+
wrapper.__signature__ = extended # pyright: ignore[reportAttributeAccessIssue] # ty: ignore[unresolved-attribute]
|
|
199
|
+
return wrapper
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""What a closure attribute is handed to decide access with.
|
|
2
|
+
|
|
3
|
+
The concrete context is defined in :mod:`xtr_security_core` — the closure voter
|
|
4
|
+
there builds it — and re-exported here so a closure the application writes next
|
|
5
|
+
to :class:`~xtr_security_http.decorator.is_granted.IsGranted` annotates its
|
|
6
|
+
parameter from the decorator surface. The core keeps the definition because the
|
|
7
|
+
voter that constructs it must never reach across into the HTTP edge.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from xtr_security_core.authorization.is_granted_context import IsGrantedContext
|
|
13
|
+
|
|
14
|
+
__all__ = ["IsGrantedContext"]
|