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.
Files changed (44) hide show
  1. fastapi_m8-2.1.0/.github/FUNDING.yml +13 -0
  2. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/CHANGELOG.md +51 -1
  3. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/PKG-INFO +11 -6
  4. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/README.md +9 -4
  5. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/fastapi_m8/_app.py +58 -11
  6. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/fastapi_m8/_compat.py +7 -0
  7. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/fastapi_m8/_revocation.py +92 -0
  8. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/fastapi_m8/_version.py +1 -1
  9. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/fastapi_m8/config.py +30 -13
  10. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/pyproject.toml +2 -2
  11. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/tests/test_app.py +88 -0
  12. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/tests/test_app_extra.py +76 -0
  13. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/tests/test_config.py +2 -2
  14. fastapi_m8-2.1.0/tests/test_config_file_secrets.py +135 -0
  15. fastapi_m8-2.1.0/tests/test_host_header_routing.py +227 -0
  16. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/tests/test_meta.py +20 -0
  17. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/tests/test_revocation.py +148 -0
  18. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/.codacy.yml +0 -0
  19. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/.env.example +0 -0
  20. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/.gitattributes +0 -0
  21. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/.github/dependabot.yml +0 -0
  22. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/.github/workflows/CI.yaml +0 -0
  23. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/.github/workflows/PiPy.yml +0 -0
  24. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/.gitignore +0 -0
  25. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/.pydocstyle +0 -0
  26. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/LICENSE +0 -0
  27. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/fastapi_m8/__init__.py +0 -0
  28. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/fastapi_m8/_async_stub.py +0 -0
  29. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/fastapi_m8/_deps.py +0 -0
  30. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/fastapi_m8/_engine.py +0 -0
  31. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/fastapi_m8/_events.py +0 -0
  32. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/fastapi_m8/_health.py +0 -0
  33. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/fastapi_m8/scripts/__init__.py +0 -0
  34. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/fastapi_m8/scripts/docker_start.sh +0 -0
  35. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/fastapi_m8/scripts/pre_start.py +0 -0
  36. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/tests/__init__.py +0 -0
  37. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/tests/conftest.py +0 -0
  38. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/tests/test_async_stub.py +0 -0
  39. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/tests/test_compat.py +0 -0
  40. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/tests/test_deps.py +0 -0
  41. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/tests/test_engine.py +0 -0
  42. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/tests/test_events.py +0 -0
  43. {fastapi_m8-2.0.0 → fastapi_m8-2.1.0}/tests/test_health.py +0 -0
  44. {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
- ## [Unreleased]
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.0.0
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.4.0
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 prefix-independent `GET /ping` (dependency-free liveness → `{"status": "ok"}`). The `/meta`
560
- values are sourced from these settings, so a consumer **fails closed at boot** if it doesn't
561
- declare its identity. Keep both separate from a dependency-aware `/health` readiness probe.
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 prefix-independent `GET /ping` (dependency-free liveness → `{"status": "ok"}`). The `/meta`
304
- values are sourced from these settings, so a consumer **fails closed at boot** if it doesn't
305
- declare its identity. Keep both separate from a dependency-aware `/health` readiness probe.
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
- def _authorizer(request: Request) -> bool:
177
- if not sec:
178
- return False
179
- return secrets.compare_digest(
180
- request.headers.get("X-Internal-Token", ""),
181
- sec.get_secret_value(),
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
- return _authorizer
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, lc.startup_validators, lc.lifespan_extras
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:
@@ -1,3 +1,3 @@
1
1
  """Single source of truth for the package version."""
2
2
 
3
- __version__ = "2.0.0"
3
+ __version__ = "2.1.0"
@@ -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, field_validator
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
- # Explicit host allowlist for TrustedHostMiddleware.
45
- # Empty (default) = middleware not registered (permissive, safe for dev).
46
- # In production set to your public hostname(s), e.g. "api.example.com".
47
- ALLOWED_HOSTS: list[str] = []
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.0.0"
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.4.0,<2.0.0",
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