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,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,7 @@
1
+ """The token the HTTP edge settles on once a user is established."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .post_authentication_token import PostAuthenticationToken
6
+
7
+ __all__ = ["PostAuthenticationToken"]
@@ -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"]
@@ -0,0 +1,7 @@
1
+ """The entry point that starts authentication for an unauthenticated request."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .authentication_entry_point_interface import AuthenticationEntryPointInterface
6
+
7
+ __all__ = ["AuthenticationEntryPointInterface"]