pkg-auth 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.
- pkg_auth/__init__.py +15 -0
- pkg_auth/admin/__init__.py +35 -0
- pkg_auth/admin/cli.py +87 -0
- pkg_auth/admin/client.py +401 -0
- pkg_auth/admin/env.py +74 -0
- pkg_auth/admin/helpers.py +113 -0
- pkg_auth/admin/provision_client.py +86 -0
- pkg_auth/admin/settings.py +33 -0
- pkg_auth/authentication/__init__.py +33 -0
- pkg_auth/authentication/adapters/__init__.py +1 -0
- pkg_auth/authentication/adapters/keycloak/__init__.py +6 -0
- pkg_auth/authentication/adapters/keycloak/jwt_decoder.py +105 -0
- pkg_auth/authentication/application/__init__.py +1 -0
- pkg_auth/authentication/application/use_cases/__init__.py +1 -0
- pkg_auth/authentication/application/use_cases/authenticate.py +91 -0
- pkg_auth/authentication/domain/__init__.py +1 -0
- pkg_auth/authentication/domain/entities.py +50 -0
- pkg_auth/authentication/domain/exceptions.py +18 -0
- pkg_auth/authentication/domain/ports.py +26 -0
- pkg_auth/authentication/domain/value_objects.py +42 -0
- pkg_auth/authorization/__init__.py +117 -0
- pkg_auth/authorization/adapters/__init__.py +1 -0
- pkg_auth/authorization/adapters/cache/__init__.py +32 -0
- pkg_auth/authorization/adapters/cache/decorators.py +181 -0
- pkg_auth/authorization/adapters/cache/memory.py +61 -0
- pkg_auth/authorization/adapters/cache/protocol.py +36 -0
- pkg_auth/authorization/adapters/cache/redis.py +60 -0
- pkg_auth/authorization/adapters/django_orm/__init__.py +37 -0
- pkg_auth/authorization/adapters/django_orm/apps.py +24 -0
- pkg_auth/authorization/adapters/django_orm/mixins.py +142 -0
- pkg_auth/authorization/adapters/django_orm/models.py +226 -0
- pkg_auth/authorization/adapters/django_orm/repositories/__init__.py +20 -0
- pkg_auth/authorization/adapters/django_orm/repositories/membership.py +118 -0
- pkg_auth/authorization/adapters/django_orm/repositories/organization.py +73 -0
- pkg_auth/authorization/adapters/django_orm/repositories/organization_service.py +71 -0
- pkg_auth/authorization/adapters/django_orm/repositories/permission_catalog.py +102 -0
- pkg_auth/authorization/adapters/django_orm/repositories/role.py +120 -0
- pkg_auth/authorization/adapters/django_orm/repositories/service.py +60 -0
- pkg_auth/authorization/adapters/django_orm/repositories/user.py +77 -0
- pkg_auth/authorization/adapters/sqlalchemy/__init__.py +90 -0
- pkg_auth/authorization/adapters/sqlalchemy/base.py +55 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/__init__.py +1 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260410_0001_initial_schema.py +293 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260412_0002_add_permission_is_platform.py +39 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0003_permission_visibility.py +65 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0004_permission_description_jsonb.py +52 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0005_services_tables.py +116 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/__init__.py +1 -0
- pkg_auth/authorization/adapters/sqlalchemy/mixins.py +187 -0
- pkg_auth/authorization/adapters/sqlalchemy/models.py +268 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/__init__.py +16 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/membership.py +146 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/organization.py +97 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/organization_service.py +106 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/permission_catalog.py +127 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/role.py +171 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/service.py +93 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/user.py +74 -0
- pkg_auth/authorization/application/__init__.py +1 -0
- pkg_auth/authorization/application/use_cases/__init__.py +1 -0
- pkg_auth/authorization/application/use_cases/_helpers.py +82 -0
- pkg_auth/authorization/application/use_cases/check_permission.py +21 -0
- pkg_auth/authorization/application/use_cases/create_organization.py +41 -0
- pkg_auth/authorization/application/use_cases/create_role.py +69 -0
- pkg_auth/authorization/application/use_cases/delete_membership.py +21 -0
- pkg_auth/authorization/application/use_cases/delete_organization.py +21 -0
- pkg_auth/authorization/application/use_cases/delete_role.py +23 -0
- pkg_auth/authorization/application/use_cases/list_user_organizations.py +21 -0
- pkg_auth/authorization/application/use_cases/provision_default_services.py +38 -0
- pkg_auth/authorization/application/use_cases/register_permission_catalog.py +122 -0
- pkg_auth/authorization/application/use_cases/resolve_auth_context.py +70 -0
- pkg_auth/authorization/application/use_cases/resolve_user_from_jwt.py +34 -0
- pkg_auth/authorization/application/use_cases/set_organization_service.py +50 -0
- pkg_auth/authorization/application/use_cases/sync_permission_catalog.py +86 -0
- pkg_auth/authorization/application/use_cases/sync_service_catalog.py +91 -0
- pkg_auth/authorization/application/use_cases/sync_user_from_jwt.py +32 -0
- pkg_auth/authorization/application/use_cases/update_organization.py +31 -0
- pkg_auth/authorization/application/use_cases/update_role.py +61 -0
- pkg_auth/authorization/application/use_cases/upsert_membership.py +54 -0
- pkg_auth/authorization/cli/__init__.py +1 -0
- pkg_auth/authorization/cli/sync_catalog.py +180 -0
- pkg_auth/authorization/cli/sync_services.py +151 -0
- pkg_auth/authorization/config.py +21 -0
- pkg_auth/authorization/domain/__init__.py +1 -0
- pkg_auth/authorization/domain/entities.py +192 -0
- pkg_auth/authorization/domain/exceptions.py +68 -0
- pkg_auth/authorization/domain/ports.py +217 -0
- pkg_auth/authorization/domain/value_objects.py +208 -0
- pkg_auth/authorization/platform.py +47 -0
- pkg_auth/integrations/__init__.py +0 -0
- pkg_auth/integrations/django/__init__.py +32 -0
- pkg_auth/integrations/django/apps.py +10 -0
- pkg_auth/integrations/django/auth_context_middleware.py +105 -0
- pkg_auth/integrations/django/decorators.py +74 -0
- pkg_auth/integrations/django/install.py +136 -0
- pkg_auth/integrations/django/middleware.py +63 -0
- pkg_auth/integrations/fastapi/__init__.py +26 -0
- pkg_auth/integrations/fastapi/auth_context_dep.py +150 -0
- pkg_auth/integrations/fastapi/auth_factory.py +84 -0
- pkg_auth/integrations/fastapi/decorators.py +55 -0
- pkg_auth/integrations/fastapi/errors.py +72 -0
- pkg_auth/integrations/fastapi/identity_dep.py +41 -0
- pkg_auth/integrations/strawberry/__init__.py +20 -0
- pkg_auth/integrations/strawberry/auth.py +137 -0
- pkg_auth/integrations/strawberry/permissions.py +56 -0
- pkg_auth-3.0.0.dist-info/METADATA +147 -0
- pkg_auth-3.0.0.dist-info/RECORD +110 -0
- pkg_auth-3.0.0.dist-info/WHEEL +5 -0
- pkg_auth-3.0.0.dist-info/entry_points.txt +4 -0
- pkg_auth-3.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""``@require_permission`` function decorator for Django views."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import functools
|
|
5
|
+
from typing import Any, Awaitable, Callable
|
|
6
|
+
|
|
7
|
+
from asgiref.sync import iscoroutinefunction
|
|
8
|
+
from django.http import HttpRequest, HttpResponse, JsonResponse
|
|
9
|
+
|
|
10
|
+
from ...authorization import MissingPermission
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def require_permission(
|
|
14
|
+
perm: str,
|
|
15
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
16
|
+
"""Decorator: require ``perm`` on ``request.auth_context``.
|
|
17
|
+
|
|
18
|
+
Requires ``IdentityMiddleware`` and ``AuthContextMiddleware`` to be
|
|
19
|
+
installed in ``MIDDLEWARE``. The decorator does NOT do its own JWT
|
|
20
|
+
validation — that's the middleware's job.
|
|
21
|
+
|
|
22
|
+
Returns 401 if there's no identity, 403 if the perm is missing.
|
|
23
|
+
|
|
24
|
+
Works for both sync and async views.
|
|
25
|
+
|
|
26
|
+
Example::
|
|
27
|
+
|
|
28
|
+
@require_permission("course:edit")
|
|
29
|
+
async def edit_course(request, course_id):
|
|
30
|
+
...
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def decorator(view: Callable[..., Any]) -> Callable[..., Any]:
|
|
34
|
+
if iscoroutinefunction(view):
|
|
35
|
+
|
|
36
|
+
@functools.wraps(view)
|
|
37
|
+
async def async_wrapper(
|
|
38
|
+
request: HttpRequest, *args: Any, **kwargs: Any
|
|
39
|
+
) -> HttpResponse:
|
|
40
|
+
err = _check(request, perm)
|
|
41
|
+
if err is not None:
|
|
42
|
+
return err
|
|
43
|
+
return await view(request, *args, **kwargs)
|
|
44
|
+
|
|
45
|
+
return async_wrapper
|
|
46
|
+
|
|
47
|
+
@functools.wraps(view)
|
|
48
|
+
def sync_wrapper(
|
|
49
|
+
request: HttpRequest, *args: Any, **kwargs: Any
|
|
50
|
+
) -> HttpResponse:
|
|
51
|
+
err = _check(request, perm)
|
|
52
|
+
if err is not None:
|
|
53
|
+
return err
|
|
54
|
+
return view(request, *args, **kwargs)
|
|
55
|
+
|
|
56
|
+
return sync_wrapper
|
|
57
|
+
|
|
58
|
+
return decorator
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _check(request: HttpRequest, perm: str) -> HttpResponse | None:
|
|
62
|
+
auth_ctx = getattr(request, "auth_context", None)
|
|
63
|
+
if auth_ctx is None:
|
|
64
|
+
identity = getattr(request, "identity", None)
|
|
65
|
+
if identity is None:
|
|
66
|
+
return JsonResponse({"detail": "Not authenticated"}, status=401)
|
|
67
|
+
return JsonResponse(
|
|
68
|
+
{"detail": "Missing X-Organization-Id header"}, status=400,
|
|
69
|
+
)
|
|
70
|
+
try:
|
|
71
|
+
auth_ctx.require(perm)
|
|
72
|
+
except MissingPermission as exc:
|
|
73
|
+
return JsonResponse({"detail": str(exc)}, status=403)
|
|
74
|
+
return None
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Process-wide wiring of pkg_auth dependencies for Django.
|
|
2
|
+
|
|
3
|
+
Django middleware can't easily inject dependencies the way FastAPI's
|
|
4
|
+
``Depends`` system can, so the package wires its use cases as
|
|
5
|
+
process-globals during app startup. Call ``install_pkg_auth(...)`` once
|
|
6
|
+
in your Django ``AppConfig.ready()`` (or settings) to register the
|
|
7
|
+
authentication façade and the use case instances; the middlewares and
|
|
8
|
+
decorators read them from this module.
|
|
9
|
+
|
|
10
|
+
Exactly one of ``sync_user_use_case`` / ``resolve_user_use_case`` must
|
|
11
|
+
be supplied — see the ``make_get_auth_context`` FastAPI docstring for
|
|
12
|
+
the Mode A vs Mode B distinction.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
from ...authentication import AuthenticateTokenUseCase
|
|
19
|
+
from ...authentication.adapters.keycloak import JWTTokenDecoder
|
|
20
|
+
from ...authorization.application.use_cases.resolve_auth_context import (
|
|
21
|
+
ResolveAuthContextUseCase,
|
|
22
|
+
)
|
|
23
|
+
from ...authorization.application.use_cases.resolve_user_from_jwt import (
|
|
24
|
+
ResolveUserFromJwtUseCase,
|
|
25
|
+
)
|
|
26
|
+
from ...authorization.application.use_cases.sync_user_from_jwt import (
|
|
27
|
+
SyncUserFromJwtUseCase,
|
|
28
|
+
)
|
|
29
|
+
from ...authorization.domain.ports import OrganizationRepository
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(slots=True)
|
|
33
|
+
class _PkgAuthRegistry:
|
|
34
|
+
authenticate: AuthenticateTokenUseCase
|
|
35
|
+
resolve_auth: ResolveAuthContextUseCase
|
|
36
|
+
organization_repo: OrganizationRepository
|
|
37
|
+
sync_user: SyncUserFromJwtUseCase | None = None
|
|
38
|
+
resolve_user: ResolveUserFromJwtUseCase | None = None
|
|
39
|
+
cookie_name: str = "access_token"
|
|
40
|
+
header_name: str = "X-Organization-Id"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_REGISTRY: _PkgAuthRegistry | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def install_pkg_auth(
|
|
47
|
+
*,
|
|
48
|
+
keycloak_base_url: str,
|
|
49
|
+
realm: str,
|
|
50
|
+
audience: str,
|
|
51
|
+
resolve_use_case: ResolveAuthContextUseCase,
|
|
52
|
+
organization_repo: OrganizationRepository,
|
|
53
|
+
sync_user_use_case: SyncUserFromJwtUseCase | None = None,
|
|
54
|
+
resolve_user_use_case: ResolveUserFromJwtUseCase | None = None,
|
|
55
|
+
cookie_name: str = "access_token",
|
|
56
|
+
header_name: str = "X-Organization-Id",
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Wire pkg_auth into the Django process.
|
|
59
|
+
|
|
60
|
+
Call this exactly once at startup, e.g. from your project's
|
|
61
|
+
``AppConfig.ready()``. Pass exactly one of ``sync_user_use_case``
|
|
62
|
+
(Mode A — source-of-truth services) or ``resolve_user_use_case``
|
|
63
|
+
(Mode B — consuming services).
|
|
64
|
+
|
|
65
|
+
Mode B (most services)::
|
|
66
|
+
|
|
67
|
+
from pkg_auth.integrations.django import install_pkg_auth
|
|
68
|
+
from pkg_auth.authorization.adapters.django_orm.repositories import (
|
|
69
|
+
DjangoUserRepository, DjangoOrganizationRepository,
|
|
70
|
+
DjangoMembershipRepository,
|
|
71
|
+
)
|
|
72
|
+
from pkg_auth.authorization.application.use_cases.resolve_user_from_jwt import ResolveUserFromJwtUseCase
|
|
73
|
+
from pkg_auth.authorization.application.use_cases.resolve_auth_context import ResolveAuthContextUseCase
|
|
74
|
+
|
|
75
|
+
install_pkg_auth(
|
|
76
|
+
keycloak_base_url="https://auth.example.com",
|
|
77
|
+
realm="itqadem",
|
|
78
|
+
audience="courses-service",
|
|
79
|
+
resolve_user_use_case=ResolveUserFromJwtUseCase(
|
|
80
|
+
user_repo=DjangoUserRepository(),
|
|
81
|
+
),
|
|
82
|
+
resolve_use_case=ResolveAuthContextUseCase(
|
|
83
|
+
membership_repo=DjangoMembershipRepository(),
|
|
84
|
+
),
|
|
85
|
+
organization_repo=DjangoOrganizationRepository(),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
Mode A (source-of-truth services like ``itq_users``)::
|
|
89
|
+
|
|
90
|
+
from pkg_auth.authorization.application.use_cases.sync_user_from_jwt import SyncUserFromJwtUseCase
|
|
91
|
+
|
|
92
|
+
install_pkg_auth(
|
|
93
|
+
...,
|
|
94
|
+
sync_user_use_case=SyncUserFromJwtUseCase(
|
|
95
|
+
user_repo=MyServiceUserRepository(),
|
|
96
|
+
),
|
|
97
|
+
...,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
Platform-admin detection is a service-level concern. Cache your
|
|
101
|
+
platform org id at startup (e.g. in this same ``ready()`` hook),
|
|
102
|
+
then call :func:`pkg_auth.authorization.is_platform_context` from
|
|
103
|
+
your views to compare against ``request.auth_context.organization_id``.
|
|
104
|
+
"""
|
|
105
|
+
global _REGISTRY
|
|
106
|
+
|
|
107
|
+
if (sync_user_use_case is None) == (resolve_user_use_case is None):
|
|
108
|
+
raise ValueError(
|
|
109
|
+
"install_pkg_auth: pass exactly one of "
|
|
110
|
+
"sync_user_use_case (Mode A) or resolve_user_use_case (Mode B)."
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
issuer = f"{keycloak_base_url}/realms/{realm}"
|
|
114
|
+
jwks_uri = f"{issuer}/protocol/openid-connect/certs"
|
|
115
|
+
decoder = JWTTokenDecoder(
|
|
116
|
+
jwks_uri=jwks_uri, issuer=issuer, audience=audience,
|
|
117
|
+
)
|
|
118
|
+
_REGISTRY = _PkgAuthRegistry(
|
|
119
|
+
authenticate=AuthenticateTokenUseCase(token_decoder=decoder),
|
|
120
|
+
resolve_auth=resolve_use_case,
|
|
121
|
+
organization_repo=organization_repo,
|
|
122
|
+
sync_user=sync_user_use_case,
|
|
123
|
+
resolve_user=resolve_user_use_case,
|
|
124
|
+
cookie_name=cookie_name,
|
|
125
|
+
header_name=header_name,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get_registry() -> _PkgAuthRegistry:
|
|
130
|
+
if _REGISTRY is None:
|
|
131
|
+
raise RuntimeError(
|
|
132
|
+
"pkg_auth has not been installed. "
|
|
133
|
+
"Call pkg_auth.integrations.django.install_pkg_auth(...) "
|
|
134
|
+
"from your project's AppConfig.ready()."
|
|
135
|
+
)
|
|
136
|
+
return _REGISTRY
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""IdentityMiddleware — validates JWT, attaches ``request.identity``."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Awaitable, Callable
|
|
5
|
+
|
|
6
|
+
from asgiref.sync import iscoroutinefunction
|
|
7
|
+
from django.http import HttpRequest, HttpResponse, JsonResponse
|
|
8
|
+
|
|
9
|
+
from ...authentication import (
|
|
10
|
+
AuthenticationError,
|
|
11
|
+
InvalidTokenError,
|
|
12
|
+
TokenExpiredError,
|
|
13
|
+
)
|
|
14
|
+
from .install import get_registry
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _extract_token(request: HttpRequest, cookie_name: str) -> str | None:
|
|
18
|
+
auth_header = request.headers.get("Authorization")
|
|
19
|
+
if auth_header and auth_header.startswith("Bearer "):
|
|
20
|
+
token = auth_header.removeprefix("Bearer ").strip()
|
|
21
|
+
if token:
|
|
22
|
+
return token
|
|
23
|
+
cookie_token = request.COOKIES.get(cookie_name)
|
|
24
|
+
if cookie_token:
|
|
25
|
+
return cookie_token
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class IdentityMiddleware:
|
|
30
|
+
"""Lazy JWT validation. Attaches ``request.identity`` (or ``None``).
|
|
31
|
+
|
|
32
|
+
On routes that don't require auth, this middleware is a no-op when
|
|
33
|
+
no token is present (``request.identity = None``). On routes that
|
|
34
|
+
DO require auth, the route's decorator / dependency raises 401
|
|
35
|
+
when ``request.identity`` is ``None``.
|
|
36
|
+
|
|
37
|
+
Token validation errors are NOT raised here — they set
|
|
38
|
+
``request.identity = None`` and let the auth decorator decide. This
|
|
39
|
+
keeps the middleware out of the response path for anonymous routes.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
sync_capable = True
|
|
43
|
+
async_capable = True
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
get_response: Callable[
|
|
48
|
+
[HttpRequest], HttpResponse | Awaitable[HttpResponse]
|
|
49
|
+
],
|
|
50
|
+
) -> None:
|
|
51
|
+
self.get_response = get_response
|
|
52
|
+
self._async = iscoroutinefunction(get_response)
|
|
53
|
+
|
|
54
|
+
def __call__(self, request: HttpRequest) -> HttpResponse | Awaitable[HttpResponse]:
|
|
55
|
+
registry = get_registry()
|
|
56
|
+
token = _extract_token(request, registry.cookie_name)
|
|
57
|
+
request.identity = None # type: ignore[attr-defined]
|
|
58
|
+
if token is not None:
|
|
59
|
+
try:
|
|
60
|
+
request.identity = registry.authenticate.execute(token) # type: ignore[attr-defined]
|
|
61
|
+
except (TokenExpiredError, InvalidTokenError, AuthenticationError):
|
|
62
|
+
request.identity = None # type: ignore[attr-defined]
|
|
63
|
+
return self.get_response(request)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""FastAPI integration for pkg_auth (identity + ACL)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
import fastapi # noqa: F401
|
|
6
|
+
except ImportError as exc: # pragma: no cover
|
|
7
|
+
raise ImportError(
|
|
8
|
+
"pkg_auth.integrations.fastapi requires fastapi. "
|
|
9
|
+
"Install with: pip install pkg-auth[fastapi]"
|
|
10
|
+
) from exc
|
|
11
|
+
|
|
12
|
+
from .auth_factory import Authentication, create_authentication
|
|
13
|
+
from .auth_context_dep import make_get_auth_context
|
|
14
|
+
from .decorators import require_permission
|
|
15
|
+
from .errors import install_exception_handlers
|
|
16
|
+
from .identity_dep import bearer_scheme, extract_token_from_request
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Authentication",
|
|
20
|
+
"create_authentication",
|
|
21
|
+
"make_get_auth_context",
|
|
22
|
+
"require_permission",
|
|
23
|
+
"install_exception_handlers",
|
|
24
|
+
"bearer_scheme",
|
|
25
|
+
"extract_token_from_request",
|
|
26
|
+
]
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""``make_get_auth_context`` factory.
|
|
2
|
+
|
|
3
|
+
Composes ``Authentication.get_identity`` (from the authentication
|
|
4
|
+
module) with the authorization layer's ``ResolveAuthContextUseCase``,
|
|
5
|
+
either ``SyncUserFromJwtUseCase`` (Mode A — writer) or
|
|
6
|
+
``ResolveUserFromJwtUseCase`` (Mode B — reader), and
|
|
7
|
+
``OrganizationRepository`` to produce a single FastAPI dependency that
|
|
8
|
+
returns ``(IdentityContext, AuthContext)``.
|
|
9
|
+
|
|
10
|
+
Exactly one of ``sync_user_use_case`` / ``resolve_user_use_case`` must
|
|
11
|
+
be supplied:
|
|
12
|
+
|
|
13
|
+
- **Mode A** (source-of-truth, extends the ACL schema): pass
|
|
14
|
+
``sync_user_use_case=...`` — the dep will upsert the local user row
|
|
15
|
+
from JWT claims on every request.
|
|
16
|
+
- **Mode B** (consumer, ACL DB is owned by a Mode A peer): pass
|
|
17
|
+
``resolve_user_use_case=...`` — the dep will read-through and raise
|
|
18
|
+
``UserNotProvisioned`` (→ HTTP 403) if the source-of-truth hasn't
|
|
19
|
+
mirrored the user yet.
|
|
20
|
+
|
|
21
|
+
pkg_auth deliberately does NOT bake in a "platform admin fallback"
|
|
22
|
+
here. Platform-admin detection is a *service-level* policy: services
|
|
23
|
+
that want it call :func:`pkg_auth.authorization.is_platform_context`
|
|
24
|
+
inside handlers, comparing the request's ``AuthContext.organization_id``
|
|
25
|
+
against their own cached platform org id. Services that need
|
|
26
|
+
elevated-privilege fallbacks (resolving the caller against a platform
|
|
27
|
+
org when they aren't a member of the requested org) wrap this factory
|
|
28
|
+
in their own dependency. See ``docs/FastAPI.md`` for the canonical
|
|
29
|
+
pattern.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from typing import Awaitable, Callable
|
|
34
|
+
from uuid import UUID
|
|
35
|
+
|
|
36
|
+
from fastapi import Depends, HTTPException, Request, status
|
|
37
|
+
|
|
38
|
+
from ...authentication import IdentityContext
|
|
39
|
+
from ...authorization import (
|
|
40
|
+
AuthContext,
|
|
41
|
+
NotAMember,
|
|
42
|
+
OrgId,
|
|
43
|
+
UnknownOrganization,
|
|
44
|
+
UserNotProvisioned,
|
|
45
|
+
)
|
|
46
|
+
from ...authorization.application.use_cases.resolve_auth_context import (
|
|
47
|
+
ResolveAuthContextUseCase,
|
|
48
|
+
)
|
|
49
|
+
from ...authorization.application.use_cases.resolve_user_from_jwt import (
|
|
50
|
+
ResolveUserFromJwtUseCase,
|
|
51
|
+
)
|
|
52
|
+
from ...authorization.application.use_cases.sync_user_from_jwt import (
|
|
53
|
+
SyncUserFromJwtUseCase,
|
|
54
|
+
)
|
|
55
|
+
from ...authorization.domain.entities import User
|
|
56
|
+
from ...authorization.domain.ports import OrganizationRepository
|
|
57
|
+
|
|
58
|
+
DEFAULT_HEADER_NAME = "X-Organization-Id"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def make_get_auth_context(
|
|
62
|
+
*,
|
|
63
|
+
get_identity: Callable[..., Awaitable[IdentityContext]],
|
|
64
|
+
resolve_use_case: ResolveAuthContextUseCase,
|
|
65
|
+
organization_repo: OrganizationRepository,
|
|
66
|
+
sync_user_use_case: SyncUserFromJwtUseCase | None = None,
|
|
67
|
+
resolve_user_use_case: ResolveUserFromJwtUseCase | None = None,
|
|
68
|
+
header_name: str = DEFAULT_HEADER_NAME,
|
|
69
|
+
) -> Callable[..., Awaitable[tuple[IdentityContext, AuthContext]]]:
|
|
70
|
+
"""Build a FastAPI dependency returning ``(IdentityContext, AuthContext)``.
|
|
71
|
+
|
|
72
|
+
Exactly one of ``sync_user_use_case`` or ``resolve_user_use_case``
|
|
73
|
+
must be supplied. See the module docstring for when to use each.
|
|
74
|
+
|
|
75
|
+
The dependency:
|
|
76
|
+
1. Resolves identity via the injected ``get_identity``.
|
|
77
|
+
2. Either lazily upserts the local user row from JWT claims
|
|
78
|
+
(Mode A) or reads it through and 403s if missing (Mode B).
|
|
79
|
+
3. Reads ``header_name`` from the request and parses it as a
|
|
80
|
+
UUID first, falling back to a slug lookup.
|
|
81
|
+
4. Resolves the user's auth context for that organization.
|
|
82
|
+
|
|
83
|
+
Errors map to:
|
|
84
|
+
- missing header → 400
|
|
85
|
+
- unknown organization → 404
|
|
86
|
+
- user not a member → 403
|
|
87
|
+
- user not provisioned (Mode B) → 403
|
|
88
|
+
"""
|
|
89
|
+
if (sync_user_use_case is None) == (resolve_user_use_case is None):
|
|
90
|
+
raise ValueError(
|
|
91
|
+
"make_get_auth_context: pass exactly one of "
|
|
92
|
+
"sync_user_use_case (Mode A) or resolve_user_use_case (Mode B)."
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
async def dependency(
|
|
96
|
+
request: Request,
|
|
97
|
+
identity: IdentityContext = Depends(get_identity),
|
|
98
|
+
) -> tuple[IdentityContext, AuthContext]:
|
|
99
|
+
raw = request.headers.get(header_name)
|
|
100
|
+
if not raw:
|
|
101
|
+
raise HTTPException(
|
|
102
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
103
|
+
detail=f"Missing {header_name} header",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
user: User
|
|
107
|
+
try:
|
|
108
|
+
if sync_user_use_case is not None:
|
|
109
|
+
user = await sync_user_use_case.execute(
|
|
110
|
+
sub=identity.subject_str,
|
|
111
|
+
email=identity.email_str or "",
|
|
112
|
+
full_name=identity.full_name,
|
|
113
|
+
)
|
|
114
|
+
else:
|
|
115
|
+
assert resolve_user_use_case is not None
|
|
116
|
+
user = await resolve_user_use_case.execute(
|
|
117
|
+
sub=identity.subject_str,
|
|
118
|
+
)
|
|
119
|
+
except UserNotProvisioned as exc:
|
|
120
|
+
raise HTTPException(
|
|
121
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
122
|
+
detail=str(exc),
|
|
123
|
+
) from exc
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
org = await organization_repo.get(OrgId(UUID(raw)))
|
|
127
|
+
except (ValueError, AttributeError):
|
|
128
|
+
org = await organization_repo.get_by_slug(raw)
|
|
129
|
+
if org is None:
|
|
130
|
+
raise HTTPException(
|
|
131
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
132
|
+
detail=f"Organization {raw!r} not found",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
auth_ctx = await resolve_use_case.execute(user.id, org.id)
|
|
137
|
+
except NotAMember as exc:
|
|
138
|
+
raise HTTPException(
|
|
139
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
140
|
+
detail=str(exc),
|
|
141
|
+
) from exc
|
|
142
|
+
except UnknownOrganization as exc:
|
|
143
|
+
raise HTTPException(
|
|
144
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
145
|
+
detail=str(exc),
|
|
146
|
+
) from exc
|
|
147
|
+
|
|
148
|
+
return identity, auth_ctx
|
|
149
|
+
|
|
150
|
+
return dependency
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Authentication façade and factory for FastAPI services."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from fastapi import Depends, HTTPException, Request, status
|
|
7
|
+
from fastapi.security import HTTPAuthorizationCredentials
|
|
8
|
+
|
|
9
|
+
from ...authentication import (
|
|
10
|
+
AuthenticateTokenUseCase,
|
|
11
|
+
AuthenticationError,
|
|
12
|
+
IdentityContext,
|
|
13
|
+
InvalidTokenError,
|
|
14
|
+
TokenExpiredError,
|
|
15
|
+
)
|
|
16
|
+
from ...authentication.adapters.keycloak import JWTTokenDecoder
|
|
17
|
+
from .identity_dep import (
|
|
18
|
+
DEFAULT_COOKIE_NAME,
|
|
19
|
+
bearer_scheme,
|
|
20
|
+
extract_token_from_request,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class Authentication:
|
|
26
|
+
"""FastAPI-side façade over the pure ``AuthenticateTokenUseCase``.
|
|
27
|
+
|
|
28
|
+
Exposes a single dependency, :meth:`get_identity`, which validates
|
|
29
|
+
a JWT and returns an :class:`IdentityContext`. **Authorization is
|
|
30
|
+
handled separately** via the auth_context dependency from the
|
|
31
|
+
authorization module — this façade does identity only.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
use_case: AuthenticateTokenUseCase
|
|
35
|
+
cookie_name: str = DEFAULT_COOKIE_NAME
|
|
36
|
+
|
|
37
|
+
async def get_identity(
|
|
38
|
+
self,
|
|
39
|
+
request: Request,
|
|
40
|
+
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
|
|
41
|
+
) -> IdentityContext:
|
|
42
|
+
"""Dependency: require a valid Keycloak token, return ``IdentityContext``."""
|
|
43
|
+
try:
|
|
44
|
+
token = extract_token_from_request(
|
|
45
|
+
request, credentials, cookie_name=self.cookie_name,
|
|
46
|
+
)
|
|
47
|
+
return self.use_case.execute(token)
|
|
48
|
+
except TokenExpiredError as exc:
|
|
49
|
+
raise HTTPException(
|
|
50
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
51
|
+
detail="Token expired",
|
|
52
|
+
) from exc
|
|
53
|
+
except (InvalidTokenError, AuthenticationError) as exc:
|
|
54
|
+
raise HTTPException(
|
|
55
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
56
|
+
detail=str(exc),
|
|
57
|
+
) from exc
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def create_authentication(
|
|
61
|
+
*,
|
|
62
|
+
keycloak_base_url: str,
|
|
63
|
+
realm: str,
|
|
64
|
+
audience: str,
|
|
65
|
+
cookie_name: str = DEFAULT_COOKIE_NAME,
|
|
66
|
+
) -> Authentication:
|
|
67
|
+
"""High-level helper: build an :class:`Authentication` from Keycloak config.
|
|
68
|
+
|
|
69
|
+
The ``audience`` is the Keycloak client ID this service expects in
|
|
70
|
+
the ``aud`` claim. There is no longer a separate ``client_id``
|
|
71
|
+
parameter because authorization no longer reads claims —
|
|
72
|
+
authorization comes from the ACL database via the authorization
|
|
73
|
+
module.
|
|
74
|
+
"""
|
|
75
|
+
issuer = f"{keycloak_base_url}/realms/{realm}"
|
|
76
|
+
jwks_uri = f"{issuer}/protocol/openid-connect/certs"
|
|
77
|
+
|
|
78
|
+
decoder = JWTTokenDecoder(
|
|
79
|
+
jwks_uri=jwks_uri,
|
|
80
|
+
issuer=issuer,
|
|
81
|
+
audience=audience,
|
|
82
|
+
)
|
|
83
|
+
use_case = AuthenticateTokenUseCase(token_decoder=decoder)
|
|
84
|
+
return Authentication(use_case=use_case, cookie_name=cookie_name)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""``require_permission`` Depends-wrapper for FastAPI routes."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Awaitable, Callable
|
|
5
|
+
|
|
6
|
+
from fastapi import Depends, HTTPException, status
|
|
7
|
+
|
|
8
|
+
from ...authentication import IdentityContext
|
|
9
|
+
from ...authorization import AuthContext, MissingPermission
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def require_permission(
|
|
13
|
+
perm: str,
|
|
14
|
+
*,
|
|
15
|
+
get_auth_context: Callable[
|
|
16
|
+
..., Awaitable[tuple[IdentityContext, AuthContext]]
|
|
17
|
+
],
|
|
18
|
+
) -> Callable[..., Awaitable[tuple[IdentityContext, AuthContext]]]:
|
|
19
|
+
"""Build a FastAPI dependency that enforces a single permission.
|
|
20
|
+
|
|
21
|
+
Usage on a single handler::
|
|
22
|
+
|
|
23
|
+
@router.post("/courses/{id}/publish")
|
|
24
|
+
async def publish(
|
|
25
|
+
id: str,
|
|
26
|
+
bundle = Depends(require_permission(
|
|
27
|
+
"course:publish", get_auth_context=get_auth_context,
|
|
28
|
+
)),
|
|
29
|
+
): ...
|
|
30
|
+
|
|
31
|
+
Or as a route-level dependency (no kwarg in the handler signature)::
|
|
32
|
+
|
|
33
|
+
@router.post(
|
|
34
|
+
"/courses/{id}/publish",
|
|
35
|
+
dependencies=[Depends(require_permission(
|
|
36
|
+
"course:publish", get_auth_context=get_auth_context,
|
|
37
|
+
))],
|
|
38
|
+
)
|
|
39
|
+
async def publish(id: str): ...
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
async def dependency(
|
|
43
|
+
bundle: tuple[IdentityContext, AuthContext] = Depends(get_auth_context),
|
|
44
|
+
) -> tuple[IdentityContext, AuthContext]:
|
|
45
|
+
_, auth_ctx = bundle
|
|
46
|
+
try:
|
|
47
|
+
auth_ctx.require(perm)
|
|
48
|
+
except MissingPermission as exc:
|
|
49
|
+
raise HTTPException(
|
|
50
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
51
|
+
detail=str(exc),
|
|
52
|
+
) from exc
|
|
53
|
+
return bundle
|
|
54
|
+
|
|
55
|
+
return dependency
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""App-wide exception handlers for pkg_auth domain errors."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from fastapi import FastAPI, Request, status
|
|
5
|
+
from fastapi.responses import JSONResponse
|
|
6
|
+
|
|
7
|
+
from ...authentication import (
|
|
8
|
+
AuthenticationError,
|
|
9
|
+
InvalidTokenError,
|
|
10
|
+
TokenExpiredError,
|
|
11
|
+
)
|
|
12
|
+
from ...authorization import (
|
|
13
|
+
AuthorizationError,
|
|
14
|
+
MissingPermission,
|
|
15
|
+
NotAMember,
|
|
16
|
+
UnknownOrganization,
|
|
17
|
+
UnknownPermission,
|
|
18
|
+
UnknownRole,
|
|
19
|
+
UnknownUser,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def install_exception_handlers(app: FastAPI) -> None:
|
|
24
|
+
"""Register catch-all handlers for pkg_auth domain exceptions.
|
|
25
|
+
|
|
26
|
+
Mappings:
|
|
27
|
+
TokenExpiredError → 401
|
|
28
|
+
InvalidTokenError → 401
|
|
29
|
+
AuthenticationError → 401
|
|
30
|
+
NotAMember → 403
|
|
31
|
+
MissingPermission → 403
|
|
32
|
+
AuthorizationError → 403 (catch-all for the hierarchy)
|
|
33
|
+
UnknownOrganization → 404
|
|
34
|
+
UnknownUser → 404
|
|
35
|
+
UnknownRole → 404
|
|
36
|
+
UnknownPermission → 404
|
|
37
|
+
|
|
38
|
+
Services that prefer per-route ``try/except`` can simply not call
|
|
39
|
+
this. The deps and decorators in this package raise ``HTTPException``
|
|
40
|
+
directly so they work either way.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
async def _401(_request: Request, exc: Exception) -> JSONResponse:
|
|
44
|
+
return JSONResponse(
|
|
45
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
46
|
+
content={"detail": str(exc)},
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
async def _403(_request: Request, exc: Exception) -> JSONResponse:
|
|
50
|
+
return JSONResponse(
|
|
51
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
52
|
+
content={"detail": str(exc)},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
async def _404(_request: Request, exc: Exception) -> JSONResponse:
|
|
56
|
+
return JSONResponse(
|
|
57
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
58
|
+
content={"detail": str(exc)},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
app.add_exception_handler(TokenExpiredError, _401)
|
|
62
|
+
app.add_exception_handler(InvalidTokenError, _401)
|
|
63
|
+
app.add_exception_handler(AuthenticationError, _401)
|
|
64
|
+
|
|
65
|
+
app.add_exception_handler(NotAMember, _403)
|
|
66
|
+
app.add_exception_handler(MissingPermission, _403)
|
|
67
|
+
app.add_exception_handler(AuthorizationError, _403)
|
|
68
|
+
|
|
69
|
+
app.add_exception_handler(UnknownOrganization, _404)
|
|
70
|
+
app.add_exception_handler(UnknownUser, _404)
|
|
71
|
+
app.add_exception_handler(UnknownRole, _404)
|
|
72
|
+
app.add_exception_handler(UnknownPermission, _404)
|