fastapi-m8 2.0.0__tar.gz → 2.1.0__tar.gz
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-2.1.0/.github/FUNDING.yml +13 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/CHANGELOG.md +51 -1
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/PKG-INFO +11 -6
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/README.md +9 -4
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/fastapi_m8/_app.py +58 -11
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/fastapi_m8/_compat.py +7 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/fastapi_m8/_revocation.py +92 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/fastapi_m8/_version.py +1 -1
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/fastapi_m8/config.py +30 -13
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/pyproject.toml +2 -2
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/tests/test_app.py +88 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/tests/test_app_extra.py +76 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/tests/test_config.py +2 -2
- fastapi_m8-2.1.0/tests/test_config_file_secrets.py +135 -0
- fastapi_m8-2.1.0/tests/test_host_header_routing.py +227 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/tests/test_meta.py +20 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/tests/test_revocation.py +148 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/.codacy.yml +0 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/.env.example +0 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/.gitattributes +0 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/.github/dependabot.yml +0 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/.github/workflows/CI.yaml +0 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/.github/workflows/PiPy.yml +0 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/.gitignore +0 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/.pydocstyle +0 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/LICENSE +0 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/fastapi_m8/__init__.py +0 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/fastapi_m8/_async_stub.py +0 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/fastapi_m8/_deps.py +0 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/fastapi_m8/_engine.py +0 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/fastapi_m8/_events.py +0 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/fastapi_m8/_health.py +0 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/fastapi_m8/scripts/__init__.py +0 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/fastapi_m8/scripts/docker_start.sh +0 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/fastapi_m8/scripts/pre_start.py +0 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/tests/__init__.py +0 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/tests/conftest.py +0 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/tests/test_async_stub.py +0 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/tests/test_compat.py +0 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/tests/test_deps.py +0 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/tests/test_engine.py +0 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/tests/test_events.py +0 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/tests/test_health.py +0 -0
- {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/tests/test_pre_start.py +0 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# These are supported funding model platforms
|
|
2
|
+
|
|
3
|
+
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
|
4
|
+
patreon: # Replace with a single Patreon username
|
|
5
|
+
open_collective: # Replace with a single Open Collective username
|
|
6
|
+
ko_fi: eliserra
|
|
7
|
+
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
|
8
|
+
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
|
9
|
+
liberapay: # Replace with a single Liberapay username
|
|
10
|
+
issuehunt: # Replace with a single IssueHunt username
|
|
11
|
+
otechie: # Replace with a single Otechie username
|
|
12
|
+
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
|
13
|
+
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
|
@@ -5,7 +5,57 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · Versioning:
|
|
|
5
5
|
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
## [
|
|
8
|
+
## [2.1.0] — 2026-06-19 · Security-remediation hardening + proxy-routable `{API_PREFIX}/ping`
|
|
9
|
+
|
|
10
|
+
> **Requires `auth-sdk-m8 >= 1.5.0`** — `mount_service_meta` dual-mounts `/ping`.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Proxy-routable `/ping`** picked up from `auth-sdk-m8 1.5.0`. `mount_service_meta`
|
|
15
|
+
now dual-mounts the liveness probe: the unchanged root `GET /ping` **plus** a
|
|
16
|
+
`GET {API_PREFIX}/ping` copy. `create_app` already passes `prefix=API_PREFIX`, so
|
|
17
|
+
the prefixed probe appears automatically with **no call-site change** — liveness
|
|
18
|
+
now resolves behind a prefix-routing reverse proxy (Traefik forwards only
|
|
19
|
+
`PathPrefix({API_PREFIX})`, so the root-only `/ping` previously 404'd at the
|
|
20
|
+
gateway while `{API_PREFIX}/meta` resolved). The prefixed copy is
|
|
21
|
+
`include_in_schema=False`, so OpenAPI still carries a single `ping` operation.
|
|
22
|
+
- **`_FILE` secret mounts for consumers** (security remediation 6.1). Documented and
|
|
23
|
+
regression-tested that `ConsumerServiceSettings` inherits the Docker/K8s
|
|
24
|
+
`<FIELD>_FILE` convention from `auth-sdk-m8`'s `CommonSettings` — no consumer code
|
|
25
|
+
change. Any secret can be mounted from a file via `<FIELD>_FILE` (e.g.
|
|
26
|
+
`DB_PASSWORD_FILE`, `PRIVATE_API_SECRET_FILE`, `METRICS_SCRAPE_CREDENTIAL_FILE`)
|
|
27
|
+
pointing under `/run/secrets/*`, so the production overlay keeps plaintext secrets
|
|
28
|
+
out of env files. The mount outranks plaintext `.env`/env values but not explicit
|
|
29
|
+
constructor kwargs; a missing file fails closed at construction; file-sourced
|
|
30
|
+
`SecretStr` values stay masked in `repr`. Coverage spans consumer-declared
|
|
31
|
+
(`METRICS_SCRAPE_CREDENTIAL`), `ConsumerAuthMixin` (`PRIVATE_API_SECRET`), and
|
|
32
|
+
`CommonSettings` (`DB_PASSWORD`) fields.
|
|
33
|
+
- **Revocation-cache observability** (security remediation 7.x.2). The consumer-side
|
|
34
|
+
JTI revocation cache now emits best-effort Prometheus metrics on the shared
|
|
35
|
+
`auth-sdk-m8[observability]` registry: `revocation_cache_lookups_total{result="hit"|"miss"}`
|
|
36
|
+
and a `revocation_cache_ttl_seconds` gauge for the configured stale-window TTL. Emission
|
|
37
|
+
is zero-cost when observability is disabled or the extra is absent. Metrics carry **no
|
|
38
|
+
JTI, user ID, or secret** as a label or value, and cache construction logs the TTL only
|
|
39
|
+
(never the introspection URL or secret) — satisfying the "keys/secrets are never logged"
|
|
40
|
+
acceptance criterion. The SDK owns the event-stream signals (connected/gap/reconnect);
|
|
41
|
+
this is the consumer cache hit/miss + TTL side.
|
|
42
|
+
- `create_app` now **auto-runs the shared `check_config_health()`** (from
|
|
43
|
+
`auth_sdk_m8.core.config`) as an internal startup validator, **prepended** to any
|
|
44
|
+
caller-provided `startup_validators`. It runs inside the lifespan (not at import time),
|
|
45
|
+
so a fatal misconfiguration (e.g. production `localhost` CORS origins, a wildcard
|
|
46
|
+
`ALLOWED_HOSTS` under strict mode) aborts startup with `ConfigurationError` **before**
|
|
47
|
+
user validators run and before the service is marked ready. Consumers now get the same
|
|
48
|
+
production safety checks the auth service already runs, automatically.
|
|
49
|
+
|
|
50
|
+
### Changed
|
|
51
|
+
|
|
52
|
+
- **Requires `auth-sdk-m8 >= 1.5.0`** (was `>= 1.4.0`). The dependency floor and the
|
|
53
|
+
`COMPAT_MATRIX` `2.1` entry are bumped so the dual-mounted `{API_PREFIX}/ping` is
|
|
54
|
+
guaranteed present; on `auth-sdk-m8 1.4.0` only the root `/ping` exists.
|
|
55
|
+
- `ALLOWED_HOSTS` is no longer redefined on `ConsumerServiceSettings` — it is inherited
|
|
56
|
+
from `CommonSettings` (auth-sdk-m8), the single source of truth. The default is now
|
|
57
|
+
`None` (unset) rather than `[]`; both are falsy, so `TrustedHostMiddleware` is still
|
|
58
|
+
skipped when unset. Production/strict gating lives in `check_config_health`.
|
|
9
59
|
|
|
10
60
|
---
|
|
11
61
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-m8
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: FastAPI application framework for m8 consumer microservices.
|
|
5
5
|
Author-email: Eli Serra <e.serra173@gmail.com>
|
|
6
6
|
License: Apache License
|
|
@@ -216,7 +216,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
216
216
|
Classifier: Topic :: Software Development :: Libraries
|
|
217
217
|
Requires-Python: >=3.11
|
|
218
218
|
Requires-Dist: anyio>=4.0
|
|
219
|
-
Requires-Dist: auth-sdk-m8[config,events,fastapi,observability,security]<2.0.0,>=1.
|
|
219
|
+
Requires-Dist: auth-sdk-m8[config,events,fastapi,observability,security]<2.0.0,>=1.5.0
|
|
220
220
|
Requires-Dist: fastapi>=0.136.3
|
|
221
221
|
Requires-Dist: httpx>=0.27.0
|
|
222
222
|
Requires-Dist: packaging>=24.0
|
|
@@ -312,7 +312,7 @@ health checks; the framework wires the rest.
|
|
|
312
312
|
| CORS | Auto-wired from `settings.ALLOWED_ORIGINS` |
|
|
313
313
|
| Metrics middleware | Optional; toggled via `METRICS_ENABLED` |
|
|
314
314
|
| Health endpoint | `GET {API_PREFIX}/health/` with optional detail gating |
|
|
315
|
-
| Service meta + liveness | Auto-mounted `GET {API_PREFIX}/meta` + `GET /ping` (fail-closed at boot) |
|
|
315
|
+
| Service meta + liveness | Auto-mounted `GET {API_PREFIX}/meta` + `GET /ping` (also `GET {API_PREFIX}/ping`; fail-closed at boot) |
|
|
316
316
|
| Database lifecycle | `create_db_engine()` wrapping SQLAlchemy |
|
|
317
317
|
| Startup validation | `startup_validators` list runs before app signals ready |
|
|
318
318
|
| Lifespan management | Auth teardown + DB pool dispose on shutdown |
|
|
@@ -556,9 +556,14 @@ environment variable.
|
|
|
556
556
|
|
|
557
557
|
`create_app` auto-mounts the shared service triad from `auth-sdk-m8`: `GET {API_PREFIX}/meta`
|
|
558
558
|
(cacheable service/version/contract identity, read by clients pre-auth to assert compatibility)
|
|
559
|
-
and a
|
|
560
|
-
|
|
561
|
-
|
|
559
|
+
and a dependency-free `GET /ping` liveness probe (→ `{"status": "ok"}`). `/ping` is mounted at
|
|
560
|
+
**both** the root (so direct container/sidecar probes stay independent of the app's prefix config)
|
|
561
|
+
and at `{API_PREFIX}/ping` (so liveness stays reachable behind a prefix-routing reverse proxy such
|
|
562
|
+
as Traefik, which forwards only `PathPrefix({API_PREFIX})` — a root-only `/ping` would 404 at the
|
|
563
|
+
gateway). The prefixed copy is hidden from the schema, so OpenAPI still lists a single `ping`
|
|
564
|
+
operation. The `/meta` values are sourced from these settings, so a consumer **fails closed at
|
|
565
|
+
boot** if it doesn't declare its identity. Keep both separate from a dependency-aware `/health`
|
|
566
|
+
readiness probe.
|
|
562
567
|
|
|
563
568
|
| Variable | Required | Default | Description |
|
|
564
569
|
|---|---|---|---|
|
|
@@ -56,7 +56,7 @@ health checks; the framework wires the rest.
|
|
|
56
56
|
| CORS | Auto-wired from `settings.ALLOWED_ORIGINS` |
|
|
57
57
|
| Metrics middleware | Optional; toggled via `METRICS_ENABLED` |
|
|
58
58
|
| Health endpoint | `GET {API_PREFIX}/health/` with optional detail gating |
|
|
59
|
-
| Service meta + liveness | Auto-mounted `GET {API_PREFIX}/meta` + `GET /ping` (fail-closed at boot) |
|
|
59
|
+
| Service meta + liveness | Auto-mounted `GET {API_PREFIX}/meta` + `GET /ping` (also `GET {API_PREFIX}/ping`; fail-closed at boot) |
|
|
60
60
|
| Database lifecycle | `create_db_engine()` wrapping SQLAlchemy |
|
|
61
61
|
| Startup validation | `startup_validators` list runs before app signals ready |
|
|
62
62
|
| Lifespan management | Auth teardown + DB pool dispose on shutdown |
|
|
@@ -300,9 +300,14 @@ environment variable.
|
|
|
300
300
|
|
|
301
301
|
`create_app` auto-mounts the shared service triad from `auth-sdk-m8`: `GET {API_PREFIX}/meta`
|
|
302
302
|
(cacheable service/version/contract identity, read by clients pre-auth to assert compatibility)
|
|
303
|
-
and a
|
|
304
|
-
|
|
305
|
-
|
|
303
|
+
and a dependency-free `GET /ping` liveness probe (→ `{"status": "ok"}`). `/ping` is mounted at
|
|
304
|
+
**both** the root (so direct container/sidecar probes stay independent of the app's prefix config)
|
|
305
|
+
and at `{API_PREFIX}/ping` (so liveness stays reachable behind a prefix-routing reverse proxy such
|
|
306
|
+
as Traefik, which forwards only `PathPrefix({API_PREFIX})` — a root-only `/ping` would 404 at the
|
|
307
|
+
gateway). The prefixed copy is hidden from the schema, so OpenAPI still lists a single `ping`
|
|
308
|
+
operation. The `/meta` values are sourced from these settings, so a consumer **fails closed at
|
|
309
|
+
boot** if it doesn't declare its identity. Keep both separate from a dependency-aware `/health`
|
|
310
|
+
readiness probe.
|
|
306
311
|
|
|
307
312
|
| Variable | Required | Default | Description |
|
|
308
313
|
|---|---|---|---|
|
|
@@ -9,7 +9,6 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import inspect
|
|
11
11
|
import logging
|
|
12
|
-
import secrets
|
|
13
12
|
import time
|
|
14
13
|
from collections.abc import AsyncGenerator, Awaitable, Callable
|
|
15
14
|
from contextlib import asynccontextmanager
|
|
@@ -18,10 +17,15 @@ from typing import TYPE_CHECKING, Any
|
|
|
18
17
|
|
|
19
18
|
import anyio
|
|
20
19
|
from auth_sdk_m8.controllers.meta import mount_service_meta
|
|
20
|
+
from auth_sdk_m8.core.config import check_config_health
|
|
21
|
+
from auth_sdk_m8.security.guards import (
|
|
22
|
+
make_internal_token_authorizer,
|
|
23
|
+
make_scrape_credential_guard,
|
|
24
|
+
)
|
|
21
25
|
from auth_sdk_m8.security.headers import add_security_headers_middleware
|
|
22
|
-
from fastapi import APIRouter, FastAPI, Request
|
|
26
|
+
from fastapi import APIRouter, Depends, FastAPI, Request
|
|
23
27
|
from fastapi.middleware.cors import CORSMiddleware
|
|
24
|
-
from fastapi.responses import JSONResponse
|
|
28
|
+
from fastapi.responses import JSONResponse, Response
|
|
25
29
|
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
|
26
30
|
|
|
27
31
|
from fastapi_m8._compat import _COMPAT_STATE, _assert_compat
|
|
@@ -146,6 +150,23 @@ def _build_lifespan(
|
|
|
146
150
|
return lifespan
|
|
147
151
|
|
|
148
152
|
|
|
153
|
+
def _build_config_health_validator(
|
|
154
|
+
settings: ConsumerServiceSettings,
|
|
155
|
+
) -> StartupValidator:
|
|
156
|
+
"""
|
|
157
|
+
Return a startup validator running the shared ``check_config_health``.
|
|
158
|
+
|
|
159
|
+
The validator runs inside the lifespan (not at import time) and raises
|
|
160
|
+
``ConfigurationError`` on fatal misconfiguration, aborting startup before
|
|
161
|
+
any caller-provided validators run.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
async def _validate_config_health() -> None:
|
|
165
|
+
check_config_health(settings, logger)
|
|
166
|
+
|
|
167
|
+
return _validate_config_health
|
|
168
|
+
|
|
169
|
+
|
|
149
170
|
def _add_metrics_middleware(app: FastAPI, settings: ConsumerServiceSettings) -> None:
|
|
150
171
|
if not settings.METRICS_ENABLED:
|
|
151
172
|
return
|
|
@@ -172,16 +193,37 @@ def _build_default_authorizer(
|
|
|
172
193
|
) -> Callable[[Request], bool]:
|
|
173
194
|
"""Return a token authorizer closed over the private API secret."""
|
|
174
195
|
sec = settings.PRIVATE_API_SECRET
|
|
196
|
+
return make_internal_token_authorizer(sec.get_secret_value() if sec else None)
|
|
197
|
+
|
|
175
198
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
199
|
+
def _register_metrics_route(app: FastAPI, settings: ConsumerServiceSettings) -> None:
|
|
200
|
+
"""
|
|
201
|
+
Register ``/metrics`` with an optional scrape-credential guard (1.4).
|
|
202
|
+
|
|
203
|
+
The route is only wired when ``METRICS_ENABLED=True``. When
|
|
204
|
+
``METRICS_SCRAPE_CREDENTIAL`` is unset the guard is a no-op and the network
|
|
205
|
+
boundary (internal entrypoint) remains the sole control. When set, requests
|
|
206
|
+
must present ``Authorization: Bearer <credential>`` (constant-time match).
|
|
207
|
+
"""
|
|
208
|
+
if not settings.METRICS_ENABLED:
|
|
209
|
+
return
|
|
210
|
+
cred_field = settings.METRICS_SCRAPE_CREDENTIAL
|
|
211
|
+
guard = make_scrape_credential_guard(
|
|
212
|
+
cred_field.get_secret_value() if cred_field else None
|
|
213
|
+
)
|
|
214
|
+
try:
|
|
215
|
+
from auth_sdk_m8.observability import metrics as _obs # noqa: PLC0415
|
|
216
|
+
except ImportError: # pragma: no cover
|
|
217
|
+
logger.warning(
|
|
218
|
+
"METRICS_ENABLED but auth-sdk-m8[observability] missing; "
|
|
219
|
+
"skipping /metrics route"
|
|
182
220
|
)
|
|
221
|
+
return
|
|
183
222
|
|
|
184
|
-
|
|
223
|
+
@app.get("/metrics", include_in_schema=False, dependencies=[Depends(guard)])
|
|
224
|
+
def _metrics_endpoint() -> Response:
|
|
225
|
+
data, content_type = _obs.render()
|
|
226
|
+
return Response(content=data, media_type=content_type)
|
|
185
227
|
|
|
186
228
|
|
|
187
229
|
async def _gather_health_results(
|
|
@@ -360,9 +402,13 @@ def create_app(
|
|
|
360
402
|
h = health or HealthConfig()
|
|
361
403
|
lc = lifecycle or AppLifecycle()
|
|
362
404
|
checks = list(h.checks or [])
|
|
405
|
+
startup_validators = [
|
|
406
|
+
_build_config_health_validator(settings),
|
|
407
|
+
*(lc.startup_validators or []),
|
|
408
|
+
]
|
|
363
409
|
app = FastAPI(
|
|
364
410
|
lifespan=_build_lifespan(
|
|
365
|
-
lc.auth_deps, lc.db_engine,
|
|
411
|
+
lc.auth_deps, lc.db_engine, startup_validators, lc.lifespan_extras
|
|
366
412
|
),
|
|
367
413
|
**_openapi_config(settings, service_name, service_version),
|
|
368
414
|
)
|
|
@@ -371,6 +417,7 @@ def create_app(
|
|
|
371
417
|
_add_trusted_host_middleware(app, settings)
|
|
372
418
|
add_security_headers_middleware(app, settings)
|
|
373
419
|
_add_metrics_middleware(app, settings)
|
|
420
|
+
_register_metrics_route(app, settings)
|
|
374
421
|
authorize = h.detail_authorizer or _build_default_authorizer(settings)
|
|
375
422
|
_register_health_route(
|
|
376
423
|
app, settings.API_PREFIX, checks, h, authorize, service_name, service_version
|
|
@@ -42,6 +42,13 @@ COMPAT_MATRIX: dict[str, dict[str, str]] = {
|
|
|
42
42
|
# at boot). Requires auth-sdk-m8 1.4.0, which ships mount_service_meta +
|
|
43
43
|
# ServiceMeta — see CHANGELOG. BREAKING: consumers must declare their meta.
|
|
44
44
|
"2.0": {"auth-sdk-m8": ">=1.4.0,<2.0.0"},
|
|
45
|
+
# 2.1 requires auth-sdk-m8 1.5.0, where mount_service_meta dual-mounts /ping:
|
|
46
|
+
# the unchanged root /ping plus a {API_PREFIX}/ping copy so liveness stays
|
|
47
|
+
# reachable behind a prefix-routing reverse proxy (Traefik forwards only
|
|
48
|
+
# PathPrefix({API_PREFIX}), so a root-only /ping 404s at the gateway). The
|
|
49
|
+
# create_app call site is unchanged — it already passes prefix=API_PREFIX — so
|
|
50
|
+
# the prefixed probe is picked up automatically on upgrade. See CHANGELOG.
|
|
51
|
+
"2.1": {"auth-sdk-m8": ">=1.5.0,<2.0.0"},
|
|
45
52
|
}
|
|
46
53
|
|
|
47
54
|
_EXTRAS = "[config,security,fastapi,observability]"
|
|
@@ -5,6 +5,8 @@ Checks JTI status via the auth service private introspection endpoint.
|
|
|
5
5
|
Instantiated only by ``build_auth_deps``; never import directly.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
8
10
|
import logging
|
|
9
11
|
import time
|
|
10
12
|
|
|
@@ -13,6 +15,75 @@ import httpx
|
|
|
13
15
|
_logger = logging.getLogger(__name__)
|
|
14
16
|
|
|
15
17
|
|
|
18
|
+
def _get_obs():
|
|
19
|
+
"""
|
|
20
|
+
Return the auth-sdk-m8 observability ``metrics`` module, or ``None``.
|
|
21
|
+
|
|
22
|
+
Observability is an optional extra (``auth-sdk-m8[observability]``); the
|
|
23
|
+
revocation cache must keep working without it, so the import is guarded and
|
|
24
|
+
metric emission is best-effort. Never raises.
|
|
25
|
+
"""
|
|
26
|
+
try:
|
|
27
|
+
from auth_sdk_m8.observability import metrics as obs # noqa: PLC0415
|
|
28
|
+
except ImportError: # pragma: no cover — observability extra always installed
|
|
29
|
+
return None
|
|
30
|
+
return obs
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _CacheMetrics:
|
|
34
|
+
"""
|
|
35
|
+
Consumer-side revocation-cache metrics, registered on the SDK registry.
|
|
36
|
+
|
|
37
|
+
Carries no JTI, user ID, or secret as a label or value — only the
|
|
38
|
+
``result`` (``hit``/``miss``) dimension and the configured TTL — so the
|
|
39
|
+
acceptance criterion "keys/secrets are never logged" holds for metrics too.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, lookups, ttl_seconds) -> None: # noqa: ANN001
|
|
43
|
+
self.lookups = lookups
|
|
44
|
+
self.ttl_seconds = ttl_seconds
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# (registry, metrics) — rebuilt when the SDK swaps its registry (tests do this).
|
|
48
|
+
# Holding the registry object (not its id) prevents id-reuse aliasing after GC.
|
|
49
|
+
_cache_metrics: tuple[object, _CacheMetrics] | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _get_cache_metrics() -> _CacheMetrics | None:
|
|
53
|
+
"""
|
|
54
|
+
Return the revocation-cache metrics, registering them once on demand.
|
|
55
|
+
|
|
56
|
+
Returns ``None`` when observability is unavailable (extra not installed) or
|
|
57
|
+
disabled (``METRICS_ENABLED=false``) — so the cache has zero metric cost in
|
|
58
|
+
that case, mirroring the SDK's best-effort emission. Never raises.
|
|
59
|
+
"""
|
|
60
|
+
obs = _get_obs()
|
|
61
|
+
if obs is None or obs.get() is None:
|
|
62
|
+
return None
|
|
63
|
+
registry = obs.REGISTRY
|
|
64
|
+
global _cache_metrics
|
|
65
|
+
if _cache_metrics is not None and _cache_metrics[0] is registry:
|
|
66
|
+
return _cache_metrics[1]
|
|
67
|
+
from prometheus_client import Counter, Gauge # noqa: PLC0415
|
|
68
|
+
|
|
69
|
+
metrics = _CacheMetrics(
|
|
70
|
+
lookups=Counter(
|
|
71
|
+
"revocation_cache_lookups_total",
|
|
72
|
+
"JTI revocation-cache lookups by outcome (result: hit | miss)",
|
|
73
|
+
["result"],
|
|
74
|
+
registry=registry,
|
|
75
|
+
),
|
|
76
|
+
ttl_seconds=Gauge(
|
|
77
|
+
"revocation_cache_ttl_seconds",
|
|
78
|
+
"Configured revocation-cache stale-window TTL in seconds "
|
|
79
|
+
"(0 = caching disabled)",
|
|
80
|
+
registry=registry,
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
_cache_metrics = (registry, metrics)
|
|
84
|
+
return metrics
|
|
85
|
+
|
|
86
|
+
|
|
16
87
|
class RevocationCheckError(Exception):
|
|
17
88
|
"""Raised when the revocation check fails in fail-closed mode."""
|
|
18
89
|
|
|
@@ -93,9 +164,13 @@ class RemoteRevocationClient:
|
|
|
93
164
|
"""Initialise the HTTP client with auth headers and timeouts."""
|
|
94
165
|
self._url = introspection_url
|
|
95
166
|
self._fail_closed = fail_closed
|
|
167
|
+
self._cache_ttl = cache_ttl
|
|
96
168
|
self._cache: JtiRevocationCache | None = (
|
|
97
169
|
JtiRevocationCache(cache_ttl) if cache_ttl > 0 else None
|
|
98
170
|
)
|
|
171
|
+
if self._cache is not None:
|
|
172
|
+
# TTL only — never the introspection URL host or any secret.
|
|
173
|
+
_logger.info("revocation.cache enabled ttl_seconds=%d", cache_ttl)
|
|
99
174
|
self._client = httpx.AsyncClient(
|
|
100
175
|
headers={"X-Internal-Token": private_api_secret},
|
|
101
176
|
timeout=httpx.Timeout(
|
|
@@ -121,7 +196,9 @@ class RemoteRevocationClient:
|
|
|
121
196
|
if self._cache is not None:
|
|
122
197
|
cached = self._cache.get(jti)
|
|
123
198
|
if cached is not None:
|
|
199
|
+
self._record_lookup("hit")
|
|
124
200
|
return cached # False = not revoked (active cached)
|
|
201
|
+
self._record_lookup("miss")
|
|
125
202
|
try:
|
|
126
203
|
response = await self._client.post(self._url, json={"jti": jti})
|
|
127
204
|
response.raise_for_status()
|
|
@@ -135,6 +212,21 @@ class RemoteRevocationClient:
|
|
|
135
212
|
raise RevocationCheckError(str(exc)) from exc
|
|
136
213
|
return False
|
|
137
214
|
|
|
215
|
+
def _record_lookup(self, result: str) -> None:
|
|
216
|
+
"""
|
|
217
|
+
Record a cache lookup outcome (``hit``/``miss``); best-effort.
|
|
218
|
+
|
|
219
|
+
Also (idempotently) publishes the configured stale-window TTL gauge —
|
|
220
|
+
done here rather than in ``__init__`` because metrics setup runs after
|
|
221
|
+
``build_auth_deps``, so the gauge would otherwise be a no-op at boot.
|
|
222
|
+
No JTI, user ID, or secret is ever passed as a label or value.
|
|
223
|
+
"""
|
|
224
|
+
cache_metrics = _get_cache_metrics()
|
|
225
|
+
if cache_metrics is None:
|
|
226
|
+
return
|
|
227
|
+
cache_metrics.lookups.labels(result=result).inc()
|
|
228
|
+
cache_metrics.ttl_seconds.set(self._cache_ttl)
|
|
229
|
+
|
|
138
230
|
def evict_jti(self, jti: str) -> None:
|
|
139
231
|
"""Remove one JTI from the cache (no-op when cache is disabled)."""
|
|
140
232
|
if self._cache is not None:
|
|
@@ -23,7 +23,7 @@ from auth_sdk_m8.core.config import CommonSettings
|
|
|
23
23
|
from auth_sdk_m8.core.consumer import ConsumerAuthMixin
|
|
24
24
|
from auth_sdk_m8.observability.settings import ObservabilitySettingsMixin
|
|
25
25
|
from auth_sdk_m8.schemas.meta import ServiceContract, ServiceMeta
|
|
26
|
-
from pydantic import Field,
|
|
26
|
+
from pydantic import Field, SecretStr
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
class ConsumerServiceSettings(
|
|
@@ -37,14 +37,25 @@ class ConsumerServiceSettings(
|
|
|
37
37
|
``PRIVATE_API_SECRET`` from ``ConsumerAuthMixin``, and all common
|
|
38
38
|
fields (``SECRET_KEY``, ``TOKEN_MODE``, ``ALLOWED_ORIGINS``,
|
|
39
39
|
``SQLALCHEMY_DATABASE_URI``, ``API_PREFIX``, …) from ``CommonSettings``.
|
|
40
|
+
|
|
41
|
+
**Secret files (`_FILE` mounts).** ``settings_customise_sources`` is inherited
|
|
42
|
+
from ``CommonSettings``, so every secret field — including consumer-declared
|
|
43
|
+
ones like ``METRICS_SCRAPE_CREDENTIAL`` — can be sourced from a mounted file by
|
|
44
|
+
setting ``<FIELD>_FILE`` (e.g. ``DB_PASSWORD_FILE``, ``PRIVATE_API_SECRET_FILE``,
|
|
45
|
+
``METRICS_SCRAPE_CREDENTIAL_FILE``) to a path under ``/run/secrets/*``. The file
|
|
46
|
+
mount outranks plaintext ``.env``/env values but not explicit constructor
|
|
47
|
+
kwargs, and a missing file fails closed at construction. This lets the
|
|
48
|
+
production overlay keep plaintext secrets out of env files with no consumer
|
|
49
|
+
code change (security remediation 6.1).
|
|
40
50
|
"""
|
|
41
51
|
|
|
42
52
|
AUTH_PREFIX: str = "/auth"
|
|
43
53
|
TABLES_PREFIX: str = "app"
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
|
|
54
|
+
# ``ALLOWED_HOSTS`` (host allowlist for TrustedHostMiddleware) is owned by
|
|
55
|
+
# ``CommonSettings`` (auth-sdk-m8) — the single source of truth. Unset/empty
|
|
56
|
+
# (default ``None``) = middleware not registered (permissive, safe for dev);
|
|
57
|
+
# in production set your public hostname(s), e.g. "api.example.com". Its
|
|
58
|
+
# production/strict gating lives in ``check_config_health``.
|
|
48
59
|
|
|
49
60
|
# Response security-header knobs (SECURITY_HEADERS_ENABLED, HSTS_ENABLED,
|
|
50
61
|
# HSTS_MAX_AGE, HSTS_INCLUDE_SUBDOMAINS, CONTENT_SECURITY_POLICY_ENABLED,
|
|
@@ -66,6 +77,20 @@ class ConsumerServiceSettings(
|
|
|
66
77
|
# by JTI/user, an unresumable gap flushes all (requires event stream client).
|
|
67
78
|
REVOCATION_CACHE_TTL_SECONDS: int = Field(0, ge=0)
|
|
68
79
|
|
|
80
|
+
# Metrics scrape credential for the ``/metrics`` endpoint (auth-sdk-m8 guard 1.4).
|
|
81
|
+
# Unset (default) = network-isolation only; ``/metrics`` answers without auth.
|
|
82
|
+
# Set to a long-lived static secret and configure Prometheus
|
|
83
|
+
# ``scrape_configs.authorization.credentials`` to match — guards are
|
|
84
|
+
# constant-time via ``auth_sdk_m8.security.guards.make_scrape_credential_guard``.
|
|
85
|
+
METRICS_SCRAPE_CREDENTIAL: SecretStr | None = Field(
|
|
86
|
+
None,
|
|
87
|
+
description=(
|
|
88
|
+
"Optional static bearer credential for the /metrics scrape endpoint. "
|
|
89
|
+
"When set, requests must present Authorization: Bearer <value>. "
|
|
90
|
+
"When unset, /metrics relies on network isolation only."
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
|
|
69
94
|
# Service/contract metadata served at ``{API_PREFIX}/meta`` (see
|
|
70
95
|
# auth_sdk_m8.controllers.meta). These are **required** so every consumer
|
|
71
96
|
# fails closed at boot if it doesn't declare its identity — clients read
|
|
@@ -100,11 +125,3 @@ class ConsumerServiceSettings(
|
|
|
100
125
|
range=self.CONTRACT_RANGE,
|
|
101
126
|
),
|
|
102
127
|
)
|
|
103
|
-
|
|
104
|
-
@field_validator("ALLOWED_HOSTS", mode="before")
|
|
105
|
-
@classmethod
|
|
106
|
-
def _parse_allowed_hosts(cls, v: object) -> list[str]:
|
|
107
|
-
"""Accept a comma-separated string or list from the environment."""
|
|
108
|
-
if isinstance(v, str):
|
|
109
|
-
return [h.strip() for h in v.split(",") if h.strip()]
|
|
110
|
-
return list(v) if v else [] # type: ignore[call-overload]
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "fastapi-m8"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.1.0"
|
|
8
8
|
description = "FastAPI application framework for m8 consumer microservices."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
@@ -27,7 +27,7 @@ dependencies = [
|
|
|
27
27
|
"httpx>=0.27.0",
|
|
28
28
|
"packaging>=24.0",
|
|
29
29
|
"anyio>=4.0",
|
|
30
|
-
"auth-sdk-m8[config,security,fastapi,observability,events]>=1.
|
|
30
|
+
"auth-sdk-m8[config,security,fastapi,observability,events]>=1.5.0,<2.0.0",
|
|
31
31
|
]
|
|
32
32
|
|
|
33
33
|
[project.optional-dependencies]
|
|
@@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock
|
|
|
8
8
|
|
|
9
9
|
import pytest
|
|
10
10
|
from asgi_lifespan import LifespanManager
|
|
11
|
+
from auth_sdk_m8.core.exceptions import ConfigurationError
|
|
11
12
|
from fastapi import APIRouter
|
|
12
13
|
from httpx import ASGITransport, AsyncClient
|
|
13
14
|
|
|
@@ -234,6 +235,93 @@ async def test_startup_validator_fail_prevents_ready(test_router: APIRouter) ->
|
|
|
234
235
|
assert a.state.service_ready is False
|
|
235
236
|
|
|
236
237
|
|
|
238
|
+
# ── Auto config-health (item 1.1) ─────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@pytest.mark.anyio
|
|
242
|
+
async def test_config_health_blocks_lifespan_on_production_localhost_cors(
|
|
243
|
+
test_router: APIRouter,
|
|
244
|
+
) -> None:
|
|
245
|
+
"""Production localhost CORS origins fail config-health during lifespan."""
|
|
246
|
+
a = create_app(
|
|
247
|
+
make_settings(
|
|
248
|
+
**_BASE, ENVIRONMENT="production", ALLOWED_HOSTS=["api.example.com"]
|
|
249
|
+
),
|
|
250
|
+
test_router,
|
|
251
|
+
)
|
|
252
|
+
with pytest.raises(ConfigurationError):
|
|
253
|
+
async with a.router.lifespan_context(a):
|
|
254
|
+
pass
|
|
255
|
+
assert a.state.service_ready is False
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@pytest.mark.anyio
|
|
259
|
+
async def test_config_health_blocks_lifespan_on_strict_wildcard_hosts(
|
|
260
|
+
test_router: APIRouter,
|
|
261
|
+
) -> None:
|
|
262
|
+
"""A wildcard ALLOWED_HOSTS under strict mode fails config-health."""
|
|
263
|
+
a = create_app(
|
|
264
|
+
make_settings(
|
|
265
|
+
**_BASE,
|
|
266
|
+
ENVIRONMENT="production",
|
|
267
|
+
STRICT_PRODUCTION_MODE=True,
|
|
268
|
+
ALLOWED_HOSTS=["*"],
|
|
269
|
+
BACKEND_CORS_ORIGINS="https://app.example.com",
|
|
270
|
+
FRONTEND_HOST="https://app.example.com",
|
|
271
|
+
),
|
|
272
|
+
test_router,
|
|
273
|
+
)
|
|
274
|
+
with pytest.raises(ConfigurationError):
|
|
275
|
+
async with a.router.lifespan_context(a):
|
|
276
|
+
pass
|
|
277
|
+
assert a.state.service_ready is False
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@pytest.mark.anyio
|
|
281
|
+
async def test_user_validators_skipped_when_config_health_fails(
|
|
282
|
+
test_router: APIRouter,
|
|
283
|
+
) -> None:
|
|
284
|
+
"""A caller validator never runs when config-health fails first."""
|
|
285
|
+
ran: list[str] = []
|
|
286
|
+
|
|
287
|
+
async def user_validator() -> None:
|
|
288
|
+
ran.append("user")
|
|
289
|
+
|
|
290
|
+
a = create_app(
|
|
291
|
+
make_settings(
|
|
292
|
+
**_BASE, ENVIRONMENT="production", ALLOWED_HOSTS=["api.example.com"]
|
|
293
|
+
),
|
|
294
|
+
test_router,
|
|
295
|
+
lifecycle=AppLifecycle(startup_validators=[user_validator]),
|
|
296
|
+
)
|
|
297
|
+
with pytest.raises(ConfigurationError):
|
|
298
|
+
async with a.router.lifespan_context(a):
|
|
299
|
+
pass
|
|
300
|
+
assert ran == []
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@pytest.mark.anyio
|
|
304
|
+
async def test_config_health_runs_before_user_validators(
|
|
305
|
+
test_router: APIRouter,
|
|
306
|
+
) -> None:
|
|
307
|
+
"""Config-health is prepended: it runs, then caller validators, in order."""
|
|
308
|
+
order: list[str] = []
|
|
309
|
+
|
|
310
|
+
async def user_validator() -> None:
|
|
311
|
+
# service_ready is still False — startup has not completed yet.
|
|
312
|
+
order.append("user")
|
|
313
|
+
|
|
314
|
+
a = create_app(
|
|
315
|
+
make_settings(**_BASE),
|
|
316
|
+
test_router,
|
|
317
|
+
lifecycle=AppLifecycle(startup_validators=[user_validator]),
|
|
318
|
+
)
|
|
319
|
+
async with a.router.lifespan_context(a):
|
|
320
|
+
order.append("ready")
|
|
321
|
+
assert order == ["user", "ready"]
|
|
322
|
+
assert a.state.service_ready is True
|
|
323
|
+
|
|
324
|
+
|
|
237
325
|
# ── Lifespan teardown ─────────────────────────────────────────────────────────
|
|
238
326
|
|
|
239
327
|
|