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 +64 -0
- fastapi_m8/_app.py +344 -0
- fastapi_m8/_async_stub.py +40 -0
- fastapi_m8/_compat.py +60 -0
- fastapi_m8/_deps.py +186 -0
- fastapi_m8/_engine.py +82 -0
- fastapi_m8/_health.py +198 -0
- fastapi_m8/_revocation.py +69 -0
- fastapi_m8/_version.py +3 -0
- fastapi_m8/config.py +41 -0
- fastapi_m8/scripts/__init__.py +1 -0
- fastapi_m8/scripts/docker_start.sh +8 -0
- fastapi_m8/scripts/pre_start.py +90 -0
- fastapi_m8-1.0.0.dist-info/METADATA +1159 -0
- fastapi_m8-1.0.0.dist-info/RECORD +18 -0
- fastapi_m8-1.0.0.dist-info/WHEEL +4 -0
- fastapi_m8-1.0.0.dist-info/entry_points.txt +2 -0
- fastapi_m8-1.0.0.dist-info/licenses/LICENSE +201 -0
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
|
+
)
|