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.
Files changed (110) hide show
  1. pkg_auth/__init__.py +15 -0
  2. pkg_auth/admin/__init__.py +35 -0
  3. pkg_auth/admin/cli.py +87 -0
  4. pkg_auth/admin/client.py +401 -0
  5. pkg_auth/admin/env.py +74 -0
  6. pkg_auth/admin/helpers.py +113 -0
  7. pkg_auth/admin/provision_client.py +86 -0
  8. pkg_auth/admin/settings.py +33 -0
  9. pkg_auth/authentication/__init__.py +33 -0
  10. pkg_auth/authentication/adapters/__init__.py +1 -0
  11. pkg_auth/authentication/adapters/keycloak/__init__.py +6 -0
  12. pkg_auth/authentication/adapters/keycloak/jwt_decoder.py +105 -0
  13. pkg_auth/authentication/application/__init__.py +1 -0
  14. pkg_auth/authentication/application/use_cases/__init__.py +1 -0
  15. pkg_auth/authentication/application/use_cases/authenticate.py +91 -0
  16. pkg_auth/authentication/domain/__init__.py +1 -0
  17. pkg_auth/authentication/domain/entities.py +50 -0
  18. pkg_auth/authentication/domain/exceptions.py +18 -0
  19. pkg_auth/authentication/domain/ports.py +26 -0
  20. pkg_auth/authentication/domain/value_objects.py +42 -0
  21. pkg_auth/authorization/__init__.py +117 -0
  22. pkg_auth/authorization/adapters/__init__.py +1 -0
  23. pkg_auth/authorization/adapters/cache/__init__.py +32 -0
  24. pkg_auth/authorization/adapters/cache/decorators.py +181 -0
  25. pkg_auth/authorization/adapters/cache/memory.py +61 -0
  26. pkg_auth/authorization/adapters/cache/protocol.py +36 -0
  27. pkg_auth/authorization/adapters/cache/redis.py +60 -0
  28. pkg_auth/authorization/adapters/django_orm/__init__.py +37 -0
  29. pkg_auth/authorization/adapters/django_orm/apps.py +24 -0
  30. pkg_auth/authorization/adapters/django_orm/mixins.py +142 -0
  31. pkg_auth/authorization/adapters/django_orm/models.py +226 -0
  32. pkg_auth/authorization/adapters/django_orm/repositories/__init__.py +20 -0
  33. pkg_auth/authorization/adapters/django_orm/repositories/membership.py +118 -0
  34. pkg_auth/authorization/adapters/django_orm/repositories/organization.py +73 -0
  35. pkg_auth/authorization/adapters/django_orm/repositories/organization_service.py +71 -0
  36. pkg_auth/authorization/adapters/django_orm/repositories/permission_catalog.py +102 -0
  37. pkg_auth/authorization/adapters/django_orm/repositories/role.py +120 -0
  38. pkg_auth/authorization/adapters/django_orm/repositories/service.py +60 -0
  39. pkg_auth/authorization/adapters/django_orm/repositories/user.py +77 -0
  40. pkg_auth/authorization/adapters/sqlalchemy/__init__.py +90 -0
  41. pkg_auth/authorization/adapters/sqlalchemy/base.py +55 -0
  42. pkg_auth/authorization/adapters/sqlalchemy/migrations/__init__.py +1 -0
  43. pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260410_0001_initial_schema.py +293 -0
  44. pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260412_0002_add_permission_is_platform.py +39 -0
  45. pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0003_permission_visibility.py +65 -0
  46. pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0004_permission_description_jsonb.py +52 -0
  47. pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0005_services_tables.py +116 -0
  48. pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/__init__.py +1 -0
  49. pkg_auth/authorization/adapters/sqlalchemy/mixins.py +187 -0
  50. pkg_auth/authorization/adapters/sqlalchemy/models.py +268 -0
  51. pkg_auth/authorization/adapters/sqlalchemy/repositories/__init__.py +16 -0
  52. pkg_auth/authorization/adapters/sqlalchemy/repositories/membership.py +146 -0
  53. pkg_auth/authorization/adapters/sqlalchemy/repositories/organization.py +97 -0
  54. pkg_auth/authorization/adapters/sqlalchemy/repositories/organization_service.py +106 -0
  55. pkg_auth/authorization/adapters/sqlalchemy/repositories/permission_catalog.py +127 -0
  56. pkg_auth/authorization/adapters/sqlalchemy/repositories/role.py +171 -0
  57. pkg_auth/authorization/adapters/sqlalchemy/repositories/service.py +93 -0
  58. pkg_auth/authorization/adapters/sqlalchemy/repositories/user.py +74 -0
  59. pkg_auth/authorization/application/__init__.py +1 -0
  60. pkg_auth/authorization/application/use_cases/__init__.py +1 -0
  61. pkg_auth/authorization/application/use_cases/_helpers.py +82 -0
  62. pkg_auth/authorization/application/use_cases/check_permission.py +21 -0
  63. pkg_auth/authorization/application/use_cases/create_organization.py +41 -0
  64. pkg_auth/authorization/application/use_cases/create_role.py +69 -0
  65. pkg_auth/authorization/application/use_cases/delete_membership.py +21 -0
  66. pkg_auth/authorization/application/use_cases/delete_organization.py +21 -0
  67. pkg_auth/authorization/application/use_cases/delete_role.py +23 -0
  68. pkg_auth/authorization/application/use_cases/list_user_organizations.py +21 -0
  69. pkg_auth/authorization/application/use_cases/provision_default_services.py +38 -0
  70. pkg_auth/authorization/application/use_cases/register_permission_catalog.py +122 -0
  71. pkg_auth/authorization/application/use_cases/resolve_auth_context.py +70 -0
  72. pkg_auth/authorization/application/use_cases/resolve_user_from_jwt.py +34 -0
  73. pkg_auth/authorization/application/use_cases/set_organization_service.py +50 -0
  74. pkg_auth/authorization/application/use_cases/sync_permission_catalog.py +86 -0
  75. pkg_auth/authorization/application/use_cases/sync_service_catalog.py +91 -0
  76. pkg_auth/authorization/application/use_cases/sync_user_from_jwt.py +32 -0
  77. pkg_auth/authorization/application/use_cases/update_organization.py +31 -0
  78. pkg_auth/authorization/application/use_cases/update_role.py +61 -0
  79. pkg_auth/authorization/application/use_cases/upsert_membership.py +54 -0
  80. pkg_auth/authorization/cli/__init__.py +1 -0
  81. pkg_auth/authorization/cli/sync_catalog.py +180 -0
  82. pkg_auth/authorization/cli/sync_services.py +151 -0
  83. pkg_auth/authorization/config.py +21 -0
  84. pkg_auth/authorization/domain/__init__.py +1 -0
  85. pkg_auth/authorization/domain/entities.py +192 -0
  86. pkg_auth/authorization/domain/exceptions.py +68 -0
  87. pkg_auth/authorization/domain/ports.py +217 -0
  88. pkg_auth/authorization/domain/value_objects.py +208 -0
  89. pkg_auth/authorization/platform.py +47 -0
  90. pkg_auth/integrations/__init__.py +0 -0
  91. pkg_auth/integrations/django/__init__.py +32 -0
  92. pkg_auth/integrations/django/apps.py +10 -0
  93. pkg_auth/integrations/django/auth_context_middleware.py +105 -0
  94. pkg_auth/integrations/django/decorators.py +74 -0
  95. pkg_auth/integrations/django/install.py +136 -0
  96. pkg_auth/integrations/django/middleware.py +63 -0
  97. pkg_auth/integrations/fastapi/__init__.py +26 -0
  98. pkg_auth/integrations/fastapi/auth_context_dep.py +150 -0
  99. pkg_auth/integrations/fastapi/auth_factory.py +84 -0
  100. pkg_auth/integrations/fastapi/decorators.py +55 -0
  101. pkg_auth/integrations/fastapi/errors.py +72 -0
  102. pkg_auth/integrations/fastapi/identity_dep.py +41 -0
  103. pkg_auth/integrations/strawberry/__init__.py +20 -0
  104. pkg_auth/integrations/strawberry/auth.py +137 -0
  105. pkg_auth/integrations/strawberry/permissions.py +56 -0
  106. pkg_auth-3.0.0.dist-info/METADATA +147 -0
  107. pkg_auth-3.0.0.dist-info/RECORD +110 -0
  108. pkg_auth-3.0.0.dist-info/WHEEL +5 -0
  109. pkg_auth-3.0.0.dist-info/entry_points.txt +4 -0
  110. 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)