fastapi-m8 1.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.
fastapi_m8/__init__.py ADDED
@@ -0,0 +1,64 @@
1
+ """
2
+ fastapi-m8 — FastAPI application framework for m8 consumer microservices.
3
+
4
+ Public surface (stable):
5
+
6
+ Tier 1 — everyday service API::
7
+
8
+ from fastapi_m8 import create_app, build_auth_deps, AuthDeps
9
+ from fastapi_m8 import create_db_engine, DbEngine
10
+ from fastapi_m8 import ConsumerServiceSettings
11
+
12
+ Tier 2 — health building blocks::
13
+
14
+ from fastapi_m8 import (
15
+ HealthStatus, HealthCheckResult, HealthCheck, HealthAggregatePolicy,
16
+ )
17
+
18
+ Tier 3 — informational / future::
19
+
20
+ from fastapi_m8 import create_async_app, CAPABILITIES, capabilities
21
+ from fastapi_m8 import COMPAT_MATRIX, __version__
22
+ """
23
+
24
+ # Tier 1
25
+ from fastapi_m8._app import AppLifecycle, HealthConfig, create_app
26
+
27
+ # Tier 3
28
+ from fastapi_m8._async_stub import CAPABILITIES, capabilities, create_async_app
29
+ from fastapi_m8._compat import COMPAT_MATRIX
30
+ from fastapi_m8._deps import AuthDeps, build_auth_deps
31
+ from fastapi_m8._engine import DbEngine, create_db_engine
32
+
33
+ # Tier 2
34
+ from fastapi_m8._health import (
35
+ HealthAggregatePolicy,
36
+ HealthCheck,
37
+ HealthCheckResult,
38
+ HealthStatus,
39
+ )
40
+ from fastapi_m8._version import __version__
41
+ from fastapi_m8.config import ConsumerServiceSettings
42
+
43
+ __all__ = [
44
+ "__version__",
45
+ # Tier 1
46
+ "create_app",
47
+ "HealthConfig",
48
+ "AppLifecycle",
49
+ "build_auth_deps",
50
+ "AuthDeps",
51
+ "create_db_engine",
52
+ "DbEngine",
53
+ "ConsumerServiceSettings",
54
+ # Tier 2
55
+ "HealthStatus",
56
+ "HealthCheckResult",
57
+ "HealthCheck",
58
+ "HealthAggregatePolicy",
59
+ # Tier 3
60
+ "create_async_app",
61
+ "CAPABILITIES",
62
+ "capabilities",
63
+ "COMPAT_MATRIX",
64
+ ]
fastapi_m8/_app.py ADDED
@@ -0,0 +1,344 @@
1
+ """
2
+ App factory for fastapi-m8 consumer services.
3
+
4
+ ``create_app`` wires CORS, optional metrics middleware, the health endpoint,
5
+ OpenAPI schema, and a managed lifespan (startup validators + graceful teardown).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import inspect
11
+ import logging
12
+ import secrets
13
+ import time
14
+ from collections.abc import AsyncGenerator, Awaitable, Callable
15
+ from contextlib import asynccontextmanager
16
+ from dataclasses import dataclass, field
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ import anyio
20
+ from fastapi import APIRouter, FastAPI, Request
21
+ from fastapi.middleware.cors import CORSMiddleware
22
+ from fastapi.responses import JSONResponse
23
+
24
+ from fastapi_m8._compat import _COMPAT_STATE, _assert_compat
25
+ from fastapi_m8._health import (
26
+ DEFAULT_TIMEOUT,
27
+ HealthAggregatePolicy,
28
+ HealthCheck,
29
+ HealthCheckResult,
30
+ HealthStatus,
31
+ aggregate,
32
+ run_check,
33
+ )
34
+ from fastapi_m8._version import __version__
35
+
36
+ if TYPE_CHECKING:
37
+ from fastapi_m8._deps import AuthDeps
38
+ from fastapi_m8._engine import DbEngine
39
+ from fastapi_m8.config import ConsumerServiceSettings
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ StartupValidator = Callable[[], Awaitable[None]]
44
+
45
+ _CORS_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
46
+ _CORS_HEADERS = ["Authorization", "Content-Type", "X-Requested-With"]
47
+
48
+
49
+ @dataclass
50
+ class HealthConfig:
51
+ """
52
+ Configuration for the health endpoint.
53
+
54
+ Attributes
55
+ ----------
56
+ checks
57
+ List of health-check callables returning HealthCheckResult.
58
+ timeout
59
+ Per-check timeout in seconds.
60
+ policy
61
+ LENIENT (default) or STRICT aggregate policy.
62
+ detail_public
63
+ If True, expose per-check detail to everyone.
64
+ detail_authorizer
65
+ Override the default X-Internal-Token gate.
66
+ cache_ttl
67
+ Seconds to cache health-check results.
68
+
69
+ """
70
+
71
+ checks: list[HealthCheck] | None = None
72
+ timeout: float = DEFAULT_TIMEOUT
73
+ policy: HealthAggregatePolicy = field(
74
+ default_factory=lambda: HealthAggregatePolicy.LENIENT
75
+ )
76
+ detail_public: bool = False
77
+ detail_authorizer: Callable[[Request], bool | Awaitable[bool]] | None = None
78
+ cache_ttl: float = 2.0
79
+
80
+
81
+ @dataclass
82
+ class AppLifecycle:
83
+ """
84
+ App lifecycle configuration.
85
+
86
+ Attributes
87
+ ----------
88
+ auth_deps
89
+ AuthDeps instance for token validation teardown.
90
+ db_engine
91
+ DbEngine instance to dispose on shutdown.
92
+ startup_validators
93
+ Async callables run before traffic; a raise aborts lifespan.
94
+ configure
95
+ Receives the fully-wired app for static additions.
96
+ lifespan_extras
97
+ Async context manager run inside the managed lifespan.
98
+
99
+ """
100
+
101
+ auth_deps: AuthDeps | None = None
102
+ db_engine: DbEngine | None = None
103
+ startup_validators: list[StartupValidator] | None = None
104
+ configure: Callable[[FastAPI], None] | None = None
105
+ lifespan_extras: Callable | None = None
106
+
107
+
108
+ def _mark_ready(app: FastAPI) -> None:
109
+ app.state.service_ready = True
110
+ app.state.ready_since = time.monotonic()
111
+
112
+
113
+ def _build_lifespan(
114
+ auth_deps: AuthDeps | None,
115
+ db_engine: DbEngine | None,
116
+ startup_validators: list[StartupValidator] | None,
117
+ lifespan_extras: Callable | None,
118
+ ) -> Callable:
119
+ """Return an asynccontextmanager lifespan for the app."""
120
+
121
+ async def _run_startup() -> None:
122
+ for v in startup_validators or []:
123
+ await v()
124
+
125
+ async def _teardown() -> None:
126
+ if auth_deps is not None:
127
+ await auth_deps.close()
128
+ if db_engine is not None:
129
+ db_engine.dispose()
130
+
131
+ @asynccontextmanager
132
+ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: # type: ignore[misc]
133
+ await _run_startup()
134
+ if lifespan_extras is not None:
135
+ async with lifespan_extras(app):
136
+ _mark_ready(app)
137
+ yield
138
+ else:
139
+ _mark_ready(app)
140
+ yield
141
+ await _teardown()
142
+
143
+ return lifespan
144
+
145
+
146
+ def _add_metrics_middleware(app: FastAPI, settings: ConsumerServiceSettings) -> None:
147
+ if not settings.METRICS_ENABLED:
148
+ return
149
+ try:
150
+ from auth_sdk_m8.observability.middleware import ( # noqa: PLC0415
151
+ MetricsMiddleware,
152
+ )
153
+
154
+ app.add_middleware(MetricsMiddleware)
155
+ except ImportError: # pragma: no cover — only fires without [observability] extra
156
+ logger.warning(
157
+ "METRICS_ENABLED but auth-sdk-m8[observability] missing; skipping"
158
+ )
159
+
160
+
161
+ def _build_default_authorizer(
162
+ settings: ConsumerServiceSettings,
163
+ ) -> Callable[[Request], bool]:
164
+ """Return a token authorizer closed over the private API secret."""
165
+ sec = settings.PRIVATE_API_SECRET
166
+
167
+ def _authorizer(request: Request) -> bool:
168
+ if not sec:
169
+ return False
170
+ return secrets.compare_digest(
171
+ request.headers.get("X-Internal-Token", ""),
172
+ sec.get_secret_value(),
173
+ )
174
+
175
+ return _authorizer
176
+
177
+
178
+ async def _gather_health_results(
179
+ app: FastAPI,
180
+ checks: list[HealthCheck],
181
+ timeout: float,
182
+ policy: HealthAggregatePolicy,
183
+ cache_ttl: float,
184
+ ) -> tuple[list[HealthCheckResult], HealthStatus, int]:
185
+ """Run all health checks with caching; return results, status, HTTP code."""
186
+ cache = app.state.health_cache
187
+ if cache and (time.monotonic() - cache[0]) < cache_ttl:
188
+ return cache[1], cache[2], cache[3]
189
+ results: list[HealthCheckResult] = [None] * len(checks) # type: ignore[list-item]
190
+
191
+ async def _run_one(idx: int, check: HealthCheck) -> None:
192
+ results[idx] = await run_check(check, timeout=timeout)
193
+
194
+ async with anyio.create_task_group() as tg:
195
+ for i, c in enumerate(checks):
196
+ tg.start_soon(_run_one, i, c)
197
+ overall = aggregate(results, policy)
198
+ code = 503 if overall is HealthStatus.FAIL else 200
199
+ app.state.health_cache = (time.monotonic(), results, overall, code)
200
+ return results, overall, code
201
+
202
+
203
+ def _build_health_body(
204
+ results: list[HealthCheckResult],
205
+ service_name: str | None,
206
+ service_version: str | None,
207
+ ) -> dict[str, Any]:
208
+ """Return the detailed health response body."""
209
+ return {
210
+ "checks": [r.model_dump() for r in results],
211
+ "service": service_name,
212
+ "version": service_version,
213
+ "fastapi_m8": __version__,
214
+ "auth_sdk_m8": _COMPAT_STATE.get("auth_version"),
215
+ }
216
+
217
+
218
+ def _openapi_config(
219
+ settings: ConsumerServiceSettings,
220
+ service_name: str | None,
221
+ service_version: str | None,
222
+ ) -> dict[str, Any]:
223
+ """Build FastAPI constructor kwargs for title, version, and OpenAPI URLs."""
224
+ return {
225
+ "title": service_name or settings.PROJECT_NAME,
226
+ "version": service_version or "0.0.0",
227
+ "openapi_url": (
228
+ f"{settings.API_PREFIX}/openapi.json" if settings.SET_OPEN_API else None
229
+ ),
230
+ "docs_url": f"{settings.API_PREFIX}/docs" if settings.SET_DOCS else None,
231
+ "redoc_url": f"{settings.API_PREFIX}/redoc" if settings.SET_REDOC else None,
232
+ "generate_unique_id_function": lambda r: f"{r.tags[0]}-{r.name}",
233
+ }
234
+
235
+
236
+ def _init_app_state(app: FastAPI) -> None:
237
+ app.state.service_ready = False
238
+ app.state.ready_since = None
239
+ app.state.health_cache = None
240
+
241
+
242
+ def _add_cors_middleware(app: FastAPI, settings: ConsumerServiceSettings) -> None:
243
+ app.add_middleware(
244
+ CORSMiddleware,
245
+ allow_origins=settings.ALLOWED_ORIGINS,
246
+ allow_credentials=True,
247
+ allow_methods=_CORS_METHODS,
248
+ allow_headers=_CORS_HEADERS,
249
+ max_age=3600,
250
+ )
251
+
252
+
253
+ def _register_health_route(
254
+ app: FastAPI,
255
+ api_prefix: str,
256
+ checks: list[HealthCheck],
257
+ config: HealthConfig,
258
+ authorize: Callable[[Request], bool | Awaitable[bool]],
259
+ service_name: str | None,
260
+ service_version: str | None,
261
+ ) -> None:
262
+ """Register the /health/ endpoint on the app."""
263
+
264
+ async def _is_authorized(request: Request) -> bool:
265
+ res = authorize(request)
266
+ return await res if inspect.isawaitable(res) else bool(res)
267
+
268
+ @app.get(f"{api_prefix}/health/", include_in_schema=False, tags=["health"])
269
+ async def health(request: Request) -> JSONResponse:
270
+ if not request.app.state.service_ready:
271
+ return JSONResponse(
272
+ {"status": "initializing", "ready": False}, status_code=503
273
+ )
274
+ results, overall, code = await _gather_health_results(
275
+ app, checks, config.timeout, config.policy, config.cache_ttl
276
+ )
277
+ logger.debug("health: %s (%d checks)", overall.value, len(results))
278
+ body: dict[str, Any] = {"status": overall.value}
279
+ if config.detail_public or await _is_authorized(request):
280
+ body |= _build_health_body(results, service_name, service_version)
281
+ return JSONResponse(body, status_code=code)
282
+
283
+
284
+ def create_app(
285
+ settings: ConsumerServiceSettings,
286
+ router: APIRouter,
287
+ *,
288
+ service_name: str | None = None,
289
+ service_version: str | None = None,
290
+ health: HealthConfig | None = None,
291
+ lifecycle: AppLifecycle | None = None,
292
+ ) -> FastAPI:
293
+ """
294
+ Wire and return a consumer FastAPI app.
295
+
296
+ Parameters
297
+ ----------
298
+ settings
299
+ Service settings (a ConsumerServiceSettings subclass).
300
+ router
301
+ The domain APIRouter to include.
302
+ service_name
303
+ Human-readable service name (falls back to settings.PROJECT_NAME).
304
+ service_version
305
+ Semantic version string for this service.
306
+ health
307
+ Health endpoint config; defaults to HealthConfig().
308
+ lifecycle
309
+ Lifecycle config (auth, engine, validators); defaults to AppLifecycle().
310
+
311
+ Returns
312
+ -------
313
+ FastAPI
314
+ A fully configured instance.
315
+
316
+ """
317
+ _assert_compat()
318
+ h = health or HealthConfig()
319
+ lc = lifecycle or AppLifecycle()
320
+ checks = list(h.checks or [])
321
+ app = FastAPI(
322
+ lifespan=_build_lifespan(
323
+ lc.auth_deps, lc.db_engine, lc.startup_validators, lc.lifespan_extras
324
+ ),
325
+ **_openapi_config(settings, service_name, service_version),
326
+ )
327
+ _init_app_state(app)
328
+ _add_cors_middleware(app, settings)
329
+ _add_metrics_middleware(app, settings)
330
+ authorize = h.detail_authorizer or _build_default_authorizer(settings)
331
+ _register_health_route(
332
+ app, settings.API_PREFIX, checks, h, authorize, service_name, service_version
333
+ )
334
+ app.include_router(router)
335
+ logger.info(
336
+ "fastapi-m8 %s svc=%s v=%s sdk=%s",
337
+ __version__,
338
+ service_name,
339
+ service_version,
340
+ _COMPAT_STATE.get("auth_version"),
341
+ )
342
+ if lc.configure is not None:
343
+ lc.configure(app)
344
+ return app
@@ -0,0 +1,40 @@
1
+ """Async app stub — reserves the async interface for fastapi-m8 v2.0.0."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ CAPABILITIES: dict[str, bool] = {
8
+ "async": False,
9
+ "plugin_system": False,
10
+ "trace_context": False,
11
+ "db_optional": True,
12
+ "health_detail_gating": True,
13
+ }
14
+
15
+
16
+ def capabilities() -> dict[str, bool]:
17
+ """
18
+ Return a copy of the capability flags.
19
+
20
+ Use this to introspect what the installed version supports before
21
+ calling optional APIs.
22
+ """
23
+ return CAPABILITIES.copy()
24
+
25
+
26
+ def create_async_app(*args: Any, **kwargs: Any) -> Any:
27
+ """
28
+ Placeholder — async app support is planned for fastapi-m8 v2.0.0.
29
+
30
+ Raises
31
+ ------
32
+ NotImplementedError
33
+ Always. Check ``capabilities()['async']`` first.
34
+
35
+ """
36
+ raise NotImplementedError(
37
+ "Async app support is planned for fastapi-m8 v2.0.0. "
38
+ "Check CAPABILITIES['async']. "
39
+ "Track: github.com/EliSerra/fa-auth-m8/issues/1"
40
+ )
fastapi_m8/_compat.py ADDED
@@ -0,0 +1,60 @@
1
+ """
2
+ Runtime compatibility guard between fastapi-m8 and auth-sdk-m8.
3
+
4
+ ``_assert_compat()`` is called from ``create_app()`` and ``build_auth_deps()``
5
+ once per process (thread-safe via ``_lock``).
6
+ """
7
+
8
+ import importlib.metadata as md
9
+ import logging
10
+ import threading
11
+
12
+ from packaging.specifiers import SpecifierSet
13
+
14
+ from fastapi_m8._version import __version__
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # Declarative pairing: fastapi-m8 minor → required version ranges.
19
+ # Add future dependencies here without touching any other code.
20
+ COMPAT_MATRIX: dict[str, dict[str, str]] = {
21
+ "1.0": {"auth-sdk-m8": ">=0.7.0,<0.8.0"},
22
+ }
23
+
24
+ _EXTRAS = "[config,security,fastapi,observability]"
25
+ _lock = threading.Lock()
26
+ _COMPAT_STATE: dict[str, object] = {"checked": False, "auth_version": None}
27
+
28
+
29
+ def _assert_compat() -> None:
30
+ """
31
+ Check installed dependency versions against COMPAT_MATRIX.
32
+
33
+ Raises
34
+ ------
35
+ RuntimeError
36
+ If a required dependency is outside its specified range.
37
+
38
+ """
39
+ with _lock:
40
+ if _COMPAT_STATE["checked"]:
41
+ return
42
+ minor = ".".join(__version__.split(".")[:2])
43
+ reqs = COMPAT_MATRIX.get(minor, {})
44
+ for dist, spec in reqs.items():
45
+ found = md.version(dist)
46
+ if found not in SpecifierSet(spec):
47
+ logger.error(
48
+ "fastapi-m8 %s needs %s%s (found %s)",
49
+ __version__,
50
+ dist,
51
+ spec,
52
+ found,
53
+ )
54
+ raise RuntimeError(
55
+ f"fastapi-m8 {__version__} requires {dist}{spec} "
56
+ f"(found {found}). "
57
+ f"Run: pip install '{dist}{_EXTRAS}{spec}'"
58
+ )
59
+ auth_v = md.version("auth-sdk-m8")
60
+ _COMPAT_STATE.update(checked=True, auth_version=auth_v)
fastapi_m8/_deps.py ADDED
@@ -0,0 +1,186 @@
1
+ """
2
+ Auth dependency builder for fastapi-m8 services.
3
+
4
+ Call ``build_auth_deps(settings)`` **once** per service in ``core/deps.py``
5
+ and share the resulting ``AuthDeps`` instance everywhere. A second call
6
+ builds a second validator and revocation client — there is no implicit cache.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from collections.abc import Callable
13
+ from dataclasses import dataclass
14
+ from typing import TYPE_CHECKING, Annotated, Any
15
+
16
+ from auth_sdk_m8.core.exceptions import InvalidToken
17
+ from auth_sdk_m8.schemas.base import RoleType
18
+ from auth_sdk_m8.schemas.user import UserModel
19
+ from auth_sdk_m8.security import ValidationHooks, build_access_validator
20
+ from fastapi import Depends, HTTPException, status
21
+ from fastapi.security import OAuth2PasswordBearer
22
+
23
+ from fastapi_m8._compat import _assert_compat
24
+ from fastapi_m8._revocation import RemoteRevocationClient, RevocationCheckError
25
+
26
+ if TYPE_CHECKING:
27
+ from fastapi_m8.config import ConsumerServiceSettings
28
+
29
+ _logger = logging.getLogger(__name__)
30
+
31
+ _FORBIDDEN = status.HTTP_403_FORBIDDEN
32
+ _UNAVAILABLE = status.HTTP_503_SERVICE_UNAVAILABLE
33
+ _NO_PRIVILEGES = "The user doesn't have enough privileges"
34
+
35
+
36
+ def _validate_access_token(validator: Any, token: str) -> Any:
37
+ try:
38
+ return validator.validate_access_token(token)
39
+ except InvalidToken as ex:
40
+ raise HTTPException(
41
+ status_code=_FORBIDDEN,
42
+ detail="Could not validate credentials.",
43
+ ) from ex
44
+
45
+
46
+ async def _check_token_revocation(
47
+ revocation_client: RemoteRevocationClient | None, jti: str
48
+ ) -> None:
49
+ if revocation_client is None:
50
+ return
51
+ try:
52
+ if await revocation_client.is_revoked(jti):
53
+ raise HTTPException(
54
+ status_code=_FORBIDDEN,
55
+ detail="Token has been revoked.",
56
+ )
57
+ except RevocationCheckError as ex:
58
+ raise HTTPException(
59
+ status_code=_UNAVAILABLE,
60
+ detail="Token revocation check unavailable.",
61
+ ) from ex
62
+
63
+
64
+ def _build_active_user(payload: Any) -> UserModel:
65
+ payload_dict = payload.model_dump(exclude={"exp", "jti", "type", "sub"})
66
+ payload_dict["id"] = payload.sub
67
+ user = UserModel(**payload_dict)
68
+ if not user.is_active:
69
+ raise HTTPException(status_code=_FORBIDDEN, detail="Inactive user")
70
+ return user
71
+
72
+
73
+ def _require_role(current_user: UserModel, role_limit: RoleType) -> None:
74
+ if not RoleType.is_valid_role_auth(
75
+ current_role=current_user.role,
76
+ role_limit=role_limit,
77
+ ):
78
+ raise HTTPException(status_code=_FORBIDDEN, detail=_NO_PRIVILEGES)
79
+
80
+
81
+ class _LoggingHooks:
82
+ """Emit structured log lines for every token validation outcome."""
83
+
84
+ def on_success(self, *, jti: str, sub: str, token_type: str) -> None:
85
+ _logger.debug("auth.ok type=%s sub=%s jti=%s", token_type, sub, jti)
86
+
87
+ def on_failure(self, *, reason: str, token_type: str) -> None:
88
+ _logger.warning("auth.fail type=%s reason=%s", token_type, reason)
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class AuthDeps:
93
+ """
94
+ Frozen container for all auth-related FastAPI dependencies.
95
+
96
+ Attributes
97
+ ----------
98
+ get_current_user
99
+ Dependency function — returns the authenticated user.
100
+ CurrentUser
101
+ ``Annotated[UserModel, Depends(get_current_user)]``.
102
+ get_current_active_admin
103
+ Dependency that additionally checks ADMIN role.
104
+ get_current_active_superuser
105
+ Checks SUPERADMIN role.
106
+ revocation_client
107
+ The revocation client, or None for stateless mode.
108
+
109
+ """
110
+
111
+ get_current_user: Callable
112
+ CurrentUser: Any
113
+ get_current_active_admin: Callable
114
+ get_current_active_superuser: Callable
115
+ revocation_client: RemoteRevocationClient | None
116
+
117
+ async def close(self) -> None:
118
+ """Teardown owner: close the revocation client (and future clients)."""
119
+ if self.revocation_client is not None:
120
+ await self.revocation_client.close()
121
+
122
+
123
+ def build_auth_deps(settings: ConsumerServiceSettings) -> AuthDeps:
124
+ """
125
+ Build the auth dependency set from service settings.
126
+
127
+ Call once at module load in ``core/deps.py``. A second call creates a
128
+ second validator and revocation client without sharing state.
129
+
130
+ Parameters
131
+ ----------
132
+ settings
133
+ A ``ConsumerServiceSettings`` instance.
134
+
135
+ Returns
136
+ -------
137
+ AuthDeps
138
+ Frozen dataclass with all auth dependencies.
139
+
140
+ """
141
+ _assert_compat()
142
+
143
+ hooks: ValidationHooks = _LoggingHooks() # type: ignore[assignment]
144
+ validator = build_access_validator(settings, hooks)
145
+
146
+ revocation_client: RemoteRevocationClient | None = None
147
+ if settings.is_stateful and settings.AUTH_SERVICE_ROLE == "consumer":
148
+ revocation_client = RemoteRevocationClient(
149
+ introspection_url=str(settings.INTROSPECTION_URL),
150
+ private_api_secret=settings.PRIVATE_API_SECRET.get_secret_value(), # type: ignore[union-attr]
151
+ )
152
+
153
+ reusable_oauth2 = OAuth2PasswordBearer(
154
+ tokenUrl=f"{settings.AUTH_PREFIX}/login/access-token"
155
+ )
156
+ TokenDep = Annotated[str, Depends(reusable_oauth2)]
157
+
158
+ async def get_current_user(token: TokenDep) -> UserModel:
159
+ """Extract and validate the current user from the JWT access token."""
160
+ payload = _validate_access_token(validator, token)
161
+ await _check_token_revocation(revocation_client, payload.jti)
162
+ return _build_active_user(payload)
163
+
164
+ CurrentUser = Annotated[UserModel, Depends(get_current_user)]
165
+
166
+ def get_current_active_admin(current_user: CurrentUser) -> UserModel: # type: ignore[valid-type]
167
+ """Verify at least ADMIN role."""
168
+ _require_role(current_user, RoleType.ADMIN)
169
+ return current_user
170
+
171
+ def get_current_active_superuser(
172
+ current_user: CurrentUser, # type: ignore[valid-type]
173
+ ) -> UserModel:
174
+ """Verify SUPERADMIN role."""
175
+ if not current_user.is_superuser:
176
+ raise HTTPException(status_code=_FORBIDDEN, detail=_NO_PRIVILEGES)
177
+ _require_role(current_user, RoleType.SUPERADMIN)
178
+ return current_user
179
+
180
+ return AuthDeps(
181
+ get_current_user=get_current_user,
182
+ CurrentUser=CurrentUser,
183
+ get_current_active_admin=get_current_active_admin,
184
+ get_current_active_superuser=get_current_active_superuser,
185
+ revocation_client=revocation_client,
186
+ )