fastapi-m8 2.0.0__tar.gz → 3.0.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-3.0.0/.github/FUNDING.yml +13 -0
  2. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/CHANGELOG.md +119 -1
  3. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/PKG-INFO +51 -17
  4. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/README.md +49 -15
  5. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/fastapi_m8/_app.py +58 -11
  6. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/fastapi_m8/_compat.py +18 -0
  7. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/fastapi_m8/_revocation.py +92 -0
  8. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/fastapi_m8/_version.py +1 -1
  9. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/fastapi_m8/config.py +31 -13
  10. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/pyproject.toml +2 -2
  11. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/tests/test_app.py +88 -0
  12. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/tests/test_app_extra.py +76 -0
  13. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/tests/test_config.py +2 -2
  14. fastapi_m8-3.0.0/tests/test_config_file_secrets.py +135 -0
  15. fastapi_m8-3.0.0/tests/test_host_header_routing.py +227 -0
  16. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/tests/test_meta.py +27 -4
  17. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/tests/test_revocation.py +148 -0
  18. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/.codacy.yml +0 -0
  19. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/.env.example +0 -0
  20. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/.gitattributes +0 -0
  21. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/.github/dependabot.yml +0 -0
  22. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/.github/workflows/CI.yaml +0 -0
  23. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/.github/workflows/PiPy.yml +0 -0
  24. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/.gitignore +0 -0
  25. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/.pydocstyle +0 -0
  26. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/LICENSE +0 -0
  27. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/fastapi_m8/__init__.py +0 -0
  28. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/fastapi_m8/_async_stub.py +0 -0
  29. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/fastapi_m8/_deps.py +0 -0
  30. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/fastapi_m8/_engine.py +0 -0
  31. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/fastapi_m8/_events.py +0 -0
  32. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/fastapi_m8/_health.py +0 -0
  33. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/fastapi_m8/scripts/__init__.py +0 -0
  34. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/fastapi_m8/scripts/docker_start.sh +0 -0
  35. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/fastapi_m8/scripts/pre_start.py +0 -0
  36. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/tests/__init__.py +0 -0
  37. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/tests/conftest.py +0 -0
  38. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/tests/test_async_stub.py +0 -0
  39. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/tests/test_compat.py +0 -0
  40. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/tests/test_deps.py +0 -0
  41. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/tests/test_engine.py +0 -0
  42. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/tests/test_events.py +0 -0
  43. {fastapi_m8-2.0.0 → fastapi_m8-3.0.0}/tests/test_health.py +0 -0
  44. {fastapi_m8-2.0.0 → fastapi_m8-3.0.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,125 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · Versioning:
5
5
 
6
6
  ---
7
7
 
8
- ## [Unreleased]
8
+ ## [3.0.0] — 2026-06-23 · auth-sdk-m8 2.0.0 alignment — single-mount `/ping` + SDK major floor
9
+
10
+ > **MAJOR.** Two independent breaking changes, either of which alone forces this bump:
11
+ > (1) `mount_service_meta` single-mounts `/ping` at the effective prefix — callers that
12
+ > relied on a bare root `/ping` when a prefix is configured must switch container/sidecar
13
+ > probes to `{API_PREFIX}/ping`; (2) the **required** `auth-sdk-m8` floor crosses a major
14
+ > (`<2.0.0` → `>=2.0.1,<3.0.0`), which removed deprecated SDK APIs — `pip install -U
15
+ > fastapi-m8` now force-upgrades the SDK across that major. (Supersedes the never-released
16
+ > 2.2.0 label: the same work, correctly versioned as a major.)
17
+
18
+ ### ⚠️ Breaking change — `/ping` is now single-mount
19
+
20
+ `auth-sdk-m8 2.0.0` removed the dual-mount behaviour introduced in 1.5.0. When `API_PREFIX`
21
+ is set (the normal consumer case), `/ping` is now mounted **only** at `{API_PREFIX}/ping`
22
+ (e.g. `/api/ping`). The root `/ping` no longer exists when a prefix is configured.
23
+
24
+ - **What was true in 2.1.x:** root `GET /ping` always returned 200 regardless of
25
+ `API_PREFIX`; additionally `GET {API_PREFIX}/ping` was mounted (schema-hidden copy).
26
+ - **What is true in 3.0.x:** only `GET {API_PREFIX}/ping` exists when a prefix is set;
27
+ only `GET /ping` (root) when no prefix is set. The single mount is **always in the
28
+ OpenAPI schema** — it is no longer hidden.
29
+ - **Action required:** update container `livenessProbe` / sidecar healthcheck URLs from
30
+ `/ping` → `{API_PREFIX}/ping` (e.g. `/api/ping`). No Python code change is needed.
31
+
32
+ ### Changed
33
+
34
+ - **Requires `auth-sdk-m8 >= 2.0.1, < 3.0.0`** (was `>= 1.5.0, < 2.0.0`). The
35
+ dependency floor, `COMPAT_MATRIX` `3.0` entry, and `pyproject.toml` pin are updated.
36
+ auth-sdk-m8 2.0.0 also ships `ConsumerScope` / `ConsumerCredential` /
37
+ `ConsumerCredentialRegistry` / `make_consumer_authorizer` (Phase 9.1) and the
38
+ `SECURITY.md` mTLS guidance (Phase 9.2) — available to consumers via the SDK without
39
+ any fastapi-m8 code change.
40
+
41
+ ### Why the floor is a **major** — auth-sdk-m8 2.0.0 dropped deprecated APIs
42
+
43
+ auth-sdk-m8 2.0.0 is a major because it **removes** every previously-deprecated
44
+ surface. fastapi-m8 was never coupled to any of them, so no consumer-facing code,
45
+ import, or setting changes here — the full suite is green at 100 % against the SDK
46
+ 2.0.0 final. The removals, and why fastapi-m8 is already clear of each:
47
+
48
+ - **Redis Pub/Sub event bus** (`auth_sdk_m8.redis_events`: `EventBus` /
49
+ `EventPublisher` / `EventSubscriber`). fastapi-m8 consumes auth events over the
50
+ **fa-auth SSE bridge** (`auth_sdk_m8.events.AuthEventStreamClient`, re-exported as
51
+ `build_event_stream_client` / `AuthEventStreamClient` since 1.4.0), never the
52
+ Redis bus. The retained signing helpers moved to `auth_sdk_m8.events._signing`
53
+ (wire format unchanged); fastapi-m8 does not import them directly.
54
+ - **`ComSecurityHelper.decode_access_token`** + `LEGACY_ACCESS_TOKEN_VALIDATION_CONFIG`.
55
+ fastapi-m8 validates tokens through `build_auth_deps()` → `build_access_validator`
56
+ (`TokenValidator`), the non-deprecated path.
57
+ - **`TOKEN_ALGORITHM`** knob. `ConsumerServiceSettings` exposes `ACCESS_TOKEN_ALGORITHM`
58
+ directly (RS256 default); the deprecated seeding knob was never surfaced.
59
+ - **Module-level `settings_customise_sources()`**. The `_FILE`/Vault source ordering
60
+ comes from the retained `CommonSettings.settings_customise_sources` **classmethod**
61
+ that `ConsumerServiceSettings` inherits — unchanged and still regression-tested.
62
+
63
+ ### Security — floor is `>= 2.0.1` to carry the `pydantic-settings` fix
64
+
65
+ The required `auth-sdk-m8` floor is **`>= 2.0.1`** (not `2.0.0`). auth-sdk-m8 2.0.1
66
+ raised its `[config]` `pydantic-settings` floor `>= 2.14.1` → `>= 2.14.2`, the patch
67
+ that hardens pydantic-settings' nested-secrets source against symlink escape/loop
68
+ traversal. fastapi-m8 does **not** import `pydantic_settings` directly — it inherits
69
+ `CommonSettings` (a `BaseSettings`) from `auth-sdk-m8[config]`, so the fix arrives
70
+ transitively through this floor bump. No separate `pydantic-settings` pin is added
71
+ here: the SDK's `[config]` extra owns that dependency, and duplicating the pin would
72
+ fork a single source of truth.
73
+
74
+ ---
75
+
76
+ ## [2.1.0] — 2026-06-19 · Security-remediation hardening + proxy-routable `{API_PREFIX}/ping`
77
+
78
+ > **Requires `auth-sdk-m8 >= 1.5.0`** — `mount_service_meta` dual-mounts `/ping`.
79
+
80
+ ### Added
81
+
82
+ - **Proxy-routable `/ping`** picked up from `auth-sdk-m8 1.5.0`. `mount_service_meta`
83
+ now dual-mounts the liveness probe: the unchanged root `GET /ping` **plus** a
84
+ `GET {API_PREFIX}/ping` copy. `create_app` already passes `prefix=API_PREFIX`, so
85
+ the prefixed probe appears automatically with **no call-site change** — liveness
86
+ now resolves behind a prefix-routing reverse proxy (Traefik forwards only
87
+ `PathPrefix({API_PREFIX})`, so the root-only `/ping` previously 404'd at the
88
+ gateway while `{API_PREFIX}/meta` resolved). The prefixed copy is
89
+ `include_in_schema=False`, so OpenAPI still carries a single `ping` operation.
90
+ - **`_FILE` secret mounts for consumers** (security remediation 6.1). Documented and
91
+ regression-tested that `ConsumerServiceSettings` inherits the Docker/K8s
92
+ `<FIELD>_FILE` convention from `auth-sdk-m8`'s `CommonSettings` — no consumer code
93
+ change. Any secret can be mounted from a file via `<FIELD>_FILE` (e.g.
94
+ `DB_PASSWORD_FILE`, `PRIVATE_API_SECRET_FILE`, `METRICS_SCRAPE_CREDENTIAL_FILE`)
95
+ pointing under `/run/secrets/*`, so the production overlay keeps plaintext secrets
96
+ out of env files. The mount outranks plaintext `.env`/env values but not explicit
97
+ constructor kwargs; a missing file fails closed at construction; file-sourced
98
+ `SecretStr` values stay masked in `repr`. Coverage spans consumer-declared
99
+ (`METRICS_SCRAPE_CREDENTIAL`), `ConsumerAuthMixin` (`PRIVATE_API_SECRET`), and
100
+ `CommonSettings` (`DB_PASSWORD`) fields.
101
+ - **Revocation-cache observability** (security remediation 7.x.2). The consumer-side
102
+ JTI revocation cache now emits best-effort Prometheus metrics on the shared
103
+ `auth-sdk-m8[observability]` registry: `revocation_cache_lookups_total{result="hit"|"miss"}`
104
+ and a `revocation_cache_ttl_seconds` gauge for the configured stale-window TTL. Emission
105
+ is zero-cost when observability is disabled or the extra is absent. Metrics carry **no
106
+ JTI, user ID, or secret** as a label or value, and cache construction logs the TTL only
107
+ (never the introspection URL or secret) — satisfying the "keys/secrets are never logged"
108
+ acceptance criterion. The SDK owns the event-stream signals (connected/gap/reconnect);
109
+ this is the consumer cache hit/miss + TTL side.
110
+ - `create_app` now **auto-runs the shared `check_config_health()`** (from
111
+ `auth_sdk_m8.core.config`) as an internal startup validator, **prepended** to any
112
+ caller-provided `startup_validators`. It runs inside the lifespan (not at import time),
113
+ so a fatal misconfiguration (e.g. production `localhost` CORS origins, a wildcard
114
+ `ALLOWED_HOSTS` under strict mode) aborts startup with `ConfigurationError` **before**
115
+ user validators run and before the service is marked ready. Consumers now get the same
116
+ production safety checks the auth service already runs, automatically.
117
+
118
+ ### Changed
119
+
120
+ - **Requires `auth-sdk-m8 >= 1.5.0`** (was `>= 1.4.0`). The dependency floor and the
121
+ `COMPAT_MATRIX` `2.1` entry are bumped so the dual-mounted `{API_PREFIX}/ping` is
122
+ guaranteed present; on `auth-sdk-m8 1.4.0` only the root `/ping` exists.
123
+ - `ALLOWED_HOSTS` is no longer redefined on `ConsumerServiceSettings` — it is inherited
124
+ from `CommonSettings` (auth-sdk-m8), the single source of truth. The default is now
125
+ `None` (unset) rather than `[]`; both are falsy, so `TrustedHostMiddleware` is still
126
+ skipped when unset. Production/strict gating lives in `check_config_health`.
9
127
 
10
128
  ---
11
129
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-m8
3
- Version: 2.0.0
3
+ Version: 3.0.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]<3.0.0,>=2.0.1
220
220
  Requires-Dist: fastapi>=0.136.3
221
221
  Requires-Dist: httpx>=0.27.0
222
222
  Requires-Dist: packaging>=24.0
@@ -308,13 +308,15 @@ health checks; the framework wires the rest.
308
308
  |---|---|
309
309
  | JWT validation | `build_auth_deps()` + `auth-sdk-m8` validator |
310
310
  | Role-based access control | `AuthDeps.get_current_active_admin / _superuser` |
311
- | Token revocation (stateful mode) | `RemoteRevocationClient` → `fa-auth-m8` private API |
311
+ | Token revocation (stateful mode) | `RemoteRevocationClient` → `fa-auth-m8` private API (optional short-TTL cache via `REVOCATION_CACHE_TTL_SECONDS`) |
312
+ | Auth event stream (optional) | `build_event_stream_client()` → fa-auth SSE bridge for best-effort cache eviction |
312
313
  | CORS | Auto-wired from `settings.ALLOWED_ORIGINS` |
313
- | Metrics middleware | Optional; toggled via `METRICS_ENABLED` |
314
+ | Metrics middleware + `/metrics` | Optional; toggled via `METRICS_ENABLED`, scrape-gated via `METRICS_SCRAPE_CREDENTIAL` |
315
+ | Response security headers | Tiered hardening from `auth-sdk-m8` (HSTS/CSP express opt-in) |
314
316
  | 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) |
317
+ | Service meta + liveness | Auto-mounted `GET {API_PREFIX}/meta` + `GET {API_PREFIX}/ping` (fail-closed at boot; single-mount at the effective prefix) |
316
318
  | Database lifecycle | `create_db_engine()` wrapping SQLAlchemy |
317
- | Startup validation | `startup_validators` list runs before app signals ready |
319
+ | Startup validation | Auto-run `check_config_health()` + caller `startup_validators` before app signals ready |
318
320
  | Lifespan management | Auth teardown + DB pool dispose on shutdown |
319
321
 
320
322
  **What it is NOT:**
@@ -556,9 +558,17 @@ environment variable.
556
558
 
557
559
  `create_app` auto-mounts the shared service triad from `auth-sdk-m8`: `GET {API_PREFIX}/meta`
558
560
  (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.
561
+ and a dependency-free `GET {API_PREFIX}/ping` liveness probe (→ `{"status": "ok"}`). `/ping` is
562
+ mounted **once** at the effective prefix (single-mount since auth-sdk-m8 2.0.0): when a prefix is
563
+ set, only `{API_PREFIX}/ping` exists so liveness stays reachable behind a prefix-routing reverse
564
+ proxy (Traefik forwards only `PathPrefix({API_PREFIX})`); when no prefix is set, `/ping` is at the
565
+ root. The single mount always appears in the OpenAPI schema. The `/meta` values are sourced from
566
+ these settings, so a consumer **fails closed at boot** if it doesn't declare its identity. Keep
567
+ both separate from a dependency-aware `/health` readiness probe.
568
+
569
+ > **⚠️ Breaking change (3.0.0 / auth-sdk-m8 2.0.0):** root `GET /ping` no longer exists when
570
+ > `API_PREFIX` is set. Update container `livenessProbe` / sidecar healthcheck URLs from `/ping`
571
+ > to `{API_PREFIX}/ping` (e.g. `/api/ping`).
562
572
 
563
573
  | Variable | Required | Default | Description |
564
574
  |---|---|---|---|
@@ -604,6 +614,8 @@ Required only when `TOKEN_MODE=stateful` and `AUTH_SERVICE_ROLE=consumer`.
604
614
  |---|---|---|---|
605
615
  | `INTROSPECTION_URL` | Yes | — | `POST` endpoint on auth service for JTI revocation checks, e.g. `http://auth_user_service:8000/user/private/v1/jti-status` |
606
616
  | `PRIVATE_API_SECRET` | Yes | — | Shared secret for `X-Internal-Token` header (must match auth service) |
617
+ | `ACCESS_REVOCATION_FAILURE_MODE` | No | `fail_closed` | `fail_closed` (default, secure — reject tokens when the check is unverifiable) or `fail_open` (accept on network/HTTP error). |
618
+ | `REVOCATION_CACHE_TTL_SECONDS` | No | `0` | Short-TTL positive validation cache. `0` (default) disables it — every request calls fa-auth. Set to e.g. `30` to trust an `active=True` result for 30 s, skipping the HTTP round-trip; stream events (`session-revoked`/`user-deleted`) evict affected entries and an unresumable gap flushes all (requires the event-stream client). |
607
619
 
608
620
  ### Auth Event Stream (fa-auth SSE bridge)
609
621
 
@@ -692,8 +704,17 @@ do not connect to Redis directly.
692
704
 
693
705
  | Variable | Default | Description |
694
706
  |---|---|---|
695
- | `METRICS_ENABLED` | `false` | Enable Prometheus metrics middleware |
707
+ | `METRICS_ENABLED` | `false` | Enable Prometheus metrics middleware and the `/metrics` route |
696
708
  | `METRICS_GROUPS` | — | Comma-separated groups: `traffic`, `performance`, `reliability`, `health`, `auth`, or `all` |
709
+ | `METRICS_SCRAPE_CREDENTIAL` | — | Optional static bearer credential for the `/metrics` scrape endpoint. When set, requests must present `Authorization: Bearer <value>` (constant-time match). When unset, `/metrics` relies on network isolation only. |
710
+
711
+ > **`/metrics` route:** when `METRICS_ENABLED=true`, `create_app` also registers a
712
+ > `GET /metrics` endpoint (hidden from the schema) rendering the Prometheus registry.
713
+ > Set `METRICS_SCRAPE_CREDENTIAL` to gate scrapes with a bearer credential — configure
714
+ > Prometheus `scrape_configs.authorization.credentials` to match. The revocation cache
715
+ > (when enabled) also emits `revocation_cache_lookups_total{result="hit"|"miss"}` and a
716
+ > `revocation_cache_ttl_seconds` gauge on the same registry; no JTI, user ID, or secret
717
+ > is ever used as a label or value.
697
718
 
698
719
  ### OpenAPI / Docs
699
720
 
@@ -811,7 +832,11 @@ app = create_app(
811
832
 
812
833
  **Lifespan sequence:**
813
834
 
814
- 1. Run `lifecycle.startup_validators` raise any exception to prevent ready signal.
835
+ 1. Run the auto-prepended `check_config_health()` validator (from `auth-sdk-m8`),
836
+ then `lifecycle.startup_validators` — raise any exception to prevent the ready
837
+ signal. The config-health check runs first, so a fatal misconfiguration (e.g.
838
+ production `localhost` CORS origins, a wildcard `ALLOWED_HOSTS` under strict mode)
839
+ aborts startup with `ConfigurationError` before any caller validators run.
815
840
  2. Enter `lifecycle.lifespan_extras` context (if provided).
816
841
  3. Set `app.state.service_ready = True`.
817
842
  4. *(app serves traffic)*
@@ -876,6 +901,7 @@ Returns a frozen dataclass with everything needed for route protection.
876
901
  | `is_active` | `bool` | Account active flag |
877
902
  | `is_superuser` | `bool` | Superuser flag |
878
903
  | `email_verified` | `bool` | Email verification status |
904
+ | `tenant_id` | `uuid.UUID \| None` | Tenant claim (populated when the token carries `tenant_id`; `None` for untenanted/legacy tokens). Requires `auth-sdk-m8 ≥ 1.3.0`. |
879
905
 
880
906
  ---
881
907
 
@@ -1074,8 +1100,8 @@ HTTP 200
1074
1100
  ],
1075
1101
  "service": "Item Service",
1076
1102
  "version": "1.0.0",
1077
- "fastapi_m8": "1.3.0",
1078
- "auth_sdk_m8": "1.1.x"
1103
+ "fastapi_m8": "3.0.0",
1104
+ "auth_sdk_m8": "2.0.x"
1079
1105
  }
1080
1106
  ```
1081
1107
 
@@ -1326,6 +1352,12 @@ async def test_health(client):
1326
1352
 
1327
1353
  | `fastapi-m8` | `auth-sdk-m8` | Python |
1328
1354
  |---|---|---|
1355
+ | `3.0.0` | `>=2.0.1, <3.0.0` | 3.11, 3.12, 3.13, 3.14 |
1356
+ | `2.1.0` | `>=1.5.0, <2.0.0` | 3.11, 3.12, 3.13 |
1357
+ | `2.0.0` | `>=1.4.0, <2.0.0` | 3.11, 3.12, 3.13 |
1358
+ | `1.6.0` | `>=1.3.0, <2.0.0` | 3.11, 3.12, 3.13 |
1359
+ | `1.5.0` | `>=1.2.1, <2.0.0` | 3.11, 3.12, 3.13 |
1360
+ | `1.4.0` | `>=1.2.0, <2.0.0` | 3.11, 3.12, 3.13 |
1329
1361
  | `1.3.0` | `>=1.1.0, <2.0.0` | 3.11, 3.12, 3.13 |
1330
1362
  | `1.2.0` | `>=1.0.0, <2.0.0` | 3.11, 3.12, 3.13 |
1331
1363
  | `1.1.4` | `>=0.7.3, <0.8.0` | 3.11, 3.12, 3.13 |
@@ -1341,9 +1373,11 @@ Check at runtime:
1341
1373
  ```python
1342
1374
  from fastapi_m8 import CAPABILITIES, __version__
1343
1375
 
1344
- print(__version__) # "1.3.0"
1345
- print(CAPABILITIES) # {"async": False, "db_optional": True, ...}
1376
+ print(__version__) # "3.0.0"
1377
+ print(CAPABILITIES) # {"async": False, "plugin_system": False,
1378
+ # "trace_context": False, "db_optional": True,
1379
+ # "health_detail_gating": True}
1346
1380
  ```
1347
1381
 
1348
- `create_async_app()` is a planned API stub for v2.0. Calling it raises
1349
- `NotImplementedError`.
1382
+ `create_async_app()` is a reserved stub for a future async app surface. Calling it
1383
+ raises `NotImplementedError`; check `CAPABILITIES["async"]` before using it.
@@ -52,13 +52,15 @@ health checks; the framework wires the rest.
52
52
  |---|---|
53
53
  | JWT validation | `build_auth_deps()` + `auth-sdk-m8` validator |
54
54
  | Role-based access control | `AuthDeps.get_current_active_admin / _superuser` |
55
- | Token revocation (stateful mode) | `RemoteRevocationClient` → `fa-auth-m8` private API |
55
+ | Token revocation (stateful mode) | `RemoteRevocationClient` → `fa-auth-m8` private API (optional short-TTL cache via `REVOCATION_CACHE_TTL_SECONDS`) |
56
+ | Auth event stream (optional) | `build_event_stream_client()` → fa-auth SSE bridge for best-effort cache eviction |
56
57
  | CORS | Auto-wired from `settings.ALLOWED_ORIGINS` |
57
- | Metrics middleware | Optional; toggled via `METRICS_ENABLED` |
58
+ | Metrics middleware + `/metrics` | Optional; toggled via `METRICS_ENABLED`, scrape-gated via `METRICS_SCRAPE_CREDENTIAL` |
59
+ | Response security headers | Tiered hardening from `auth-sdk-m8` (HSTS/CSP express opt-in) |
58
60
  | 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) |
61
+ | Service meta + liveness | Auto-mounted `GET {API_PREFIX}/meta` + `GET {API_PREFIX}/ping` (fail-closed at boot; single-mount at the effective prefix) |
60
62
  | Database lifecycle | `create_db_engine()` wrapping SQLAlchemy |
61
- | Startup validation | `startup_validators` list runs before app signals ready |
63
+ | Startup validation | Auto-run `check_config_health()` + caller `startup_validators` before app signals ready |
62
64
  | Lifespan management | Auth teardown + DB pool dispose on shutdown |
63
65
 
64
66
  **What it is NOT:**
@@ -300,9 +302,17 @@ environment variable.
300
302
 
301
303
  `create_app` auto-mounts the shared service triad from `auth-sdk-m8`: `GET {API_PREFIX}/meta`
302
304
  (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.
305
+ and a dependency-free `GET {API_PREFIX}/ping` liveness probe (→ `{"status": "ok"}`). `/ping` is
306
+ mounted **once** at the effective prefix (single-mount since auth-sdk-m8 2.0.0): when a prefix is
307
+ set, only `{API_PREFIX}/ping` exists so liveness stays reachable behind a prefix-routing reverse
308
+ proxy (Traefik forwards only `PathPrefix({API_PREFIX})`); when no prefix is set, `/ping` is at the
309
+ root. The single mount always appears in the OpenAPI schema. The `/meta` values are sourced from
310
+ these settings, so a consumer **fails closed at boot** if it doesn't declare its identity. Keep
311
+ both separate from a dependency-aware `/health` readiness probe.
312
+
313
+ > **⚠️ Breaking change (3.0.0 / auth-sdk-m8 2.0.0):** root `GET /ping` no longer exists when
314
+ > `API_PREFIX` is set. Update container `livenessProbe` / sidecar healthcheck URLs from `/ping`
315
+ > to `{API_PREFIX}/ping` (e.g. `/api/ping`).
306
316
 
307
317
  | Variable | Required | Default | Description |
308
318
  |---|---|---|---|
@@ -348,6 +358,8 @@ Required only when `TOKEN_MODE=stateful` and `AUTH_SERVICE_ROLE=consumer`.
348
358
  |---|---|---|---|
349
359
  | `INTROSPECTION_URL` | Yes | — | `POST` endpoint on auth service for JTI revocation checks, e.g. `http://auth_user_service:8000/user/private/v1/jti-status` |
350
360
  | `PRIVATE_API_SECRET` | Yes | — | Shared secret for `X-Internal-Token` header (must match auth service) |
361
+ | `ACCESS_REVOCATION_FAILURE_MODE` | No | `fail_closed` | `fail_closed` (default, secure — reject tokens when the check is unverifiable) or `fail_open` (accept on network/HTTP error). |
362
+ | `REVOCATION_CACHE_TTL_SECONDS` | No | `0` | Short-TTL positive validation cache. `0` (default) disables it — every request calls fa-auth. Set to e.g. `30` to trust an `active=True` result for 30 s, skipping the HTTP round-trip; stream events (`session-revoked`/`user-deleted`) evict affected entries and an unresumable gap flushes all (requires the event-stream client). |
351
363
 
352
364
  ### Auth Event Stream (fa-auth SSE bridge)
353
365
 
@@ -436,8 +448,17 @@ do not connect to Redis directly.
436
448
 
437
449
  | Variable | Default | Description |
438
450
  |---|---|---|
439
- | `METRICS_ENABLED` | `false` | Enable Prometheus metrics middleware |
451
+ | `METRICS_ENABLED` | `false` | Enable Prometheus metrics middleware and the `/metrics` route |
440
452
  | `METRICS_GROUPS` | — | Comma-separated groups: `traffic`, `performance`, `reliability`, `health`, `auth`, or `all` |
453
+ | `METRICS_SCRAPE_CREDENTIAL` | — | Optional static bearer credential for the `/metrics` scrape endpoint. When set, requests must present `Authorization: Bearer <value>` (constant-time match). When unset, `/metrics` relies on network isolation only. |
454
+
455
+ > **`/metrics` route:** when `METRICS_ENABLED=true`, `create_app` also registers a
456
+ > `GET /metrics` endpoint (hidden from the schema) rendering the Prometheus registry.
457
+ > Set `METRICS_SCRAPE_CREDENTIAL` to gate scrapes with a bearer credential — configure
458
+ > Prometheus `scrape_configs.authorization.credentials` to match. The revocation cache
459
+ > (when enabled) also emits `revocation_cache_lookups_total{result="hit"|"miss"}` and a
460
+ > `revocation_cache_ttl_seconds` gauge on the same registry; no JTI, user ID, or secret
461
+ > is ever used as a label or value.
441
462
 
442
463
  ### OpenAPI / Docs
443
464
 
@@ -555,7 +576,11 @@ app = create_app(
555
576
 
556
577
  **Lifespan sequence:**
557
578
 
558
- 1. Run `lifecycle.startup_validators` raise any exception to prevent ready signal.
579
+ 1. Run the auto-prepended `check_config_health()` validator (from `auth-sdk-m8`),
580
+ then `lifecycle.startup_validators` — raise any exception to prevent the ready
581
+ signal. The config-health check runs first, so a fatal misconfiguration (e.g.
582
+ production `localhost` CORS origins, a wildcard `ALLOWED_HOSTS` under strict mode)
583
+ aborts startup with `ConfigurationError` before any caller validators run.
559
584
  2. Enter `lifecycle.lifespan_extras` context (if provided).
560
585
  3. Set `app.state.service_ready = True`.
561
586
  4. *(app serves traffic)*
@@ -620,6 +645,7 @@ Returns a frozen dataclass with everything needed for route protection.
620
645
  | `is_active` | `bool` | Account active flag |
621
646
  | `is_superuser` | `bool` | Superuser flag |
622
647
  | `email_verified` | `bool` | Email verification status |
648
+ | `tenant_id` | `uuid.UUID \| None` | Tenant claim (populated when the token carries `tenant_id`; `None` for untenanted/legacy tokens). Requires `auth-sdk-m8 ≥ 1.3.0`. |
623
649
 
624
650
  ---
625
651
 
@@ -818,8 +844,8 @@ HTTP 200
818
844
  ],
819
845
  "service": "Item Service",
820
846
  "version": "1.0.0",
821
- "fastapi_m8": "1.3.0",
822
- "auth_sdk_m8": "1.1.x"
847
+ "fastapi_m8": "3.0.0",
848
+ "auth_sdk_m8": "2.0.x"
823
849
  }
824
850
  ```
825
851
 
@@ -1070,6 +1096,12 @@ async def test_health(client):
1070
1096
 
1071
1097
  | `fastapi-m8` | `auth-sdk-m8` | Python |
1072
1098
  |---|---|---|
1099
+ | `3.0.0` | `>=2.0.1, <3.0.0` | 3.11, 3.12, 3.13, 3.14 |
1100
+ | `2.1.0` | `>=1.5.0, <2.0.0` | 3.11, 3.12, 3.13 |
1101
+ | `2.0.0` | `>=1.4.0, <2.0.0` | 3.11, 3.12, 3.13 |
1102
+ | `1.6.0` | `>=1.3.0, <2.0.0` | 3.11, 3.12, 3.13 |
1103
+ | `1.5.0` | `>=1.2.1, <2.0.0` | 3.11, 3.12, 3.13 |
1104
+ | `1.4.0` | `>=1.2.0, <2.0.0` | 3.11, 3.12, 3.13 |
1073
1105
  | `1.3.0` | `>=1.1.0, <2.0.0` | 3.11, 3.12, 3.13 |
1074
1106
  | `1.2.0` | `>=1.0.0, <2.0.0` | 3.11, 3.12, 3.13 |
1075
1107
  | `1.1.4` | `>=0.7.3, <0.8.0` | 3.11, 3.12, 3.13 |
@@ -1085,9 +1117,11 @@ Check at runtime:
1085
1117
  ```python
1086
1118
  from fastapi_m8 import CAPABILITIES, __version__
1087
1119
 
1088
- print(__version__) # "1.3.0"
1089
- print(CAPABILITIES) # {"async": False, "db_optional": True, ...}
1120
+ print(__version__) # "3.0.0"
1121
+ print(CAPABILITIES) # {"async": False, "plugin_system": False,
1122
+ # "trace_context": False, "db_optional": True,
1123
+ # "health_detail_gating": True}
1090
1124
  ```
1091
1125
 
1092
- `create_async_app()` is a planned API stub for v2.0. Calling it raises
1093
- `NotImplementedError`.
1126
+ `create_async_app()` is a reserved stub for a future async app surface. Calling it
1127
+ raises `NotImplementedError`; check `CAPABILITIES["async"]` before using it.
@@ -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,24 @@ 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"},
52
+ # 3.0 (MAJOR) aligns with auth-sdk-m8 2.0.0 on two breaking fronts: (1) /ping
53
+ # collapses to a single mount — when a prefix is set it lives only at
54
+ # {prefix}/ping (no root copy), always in the OpenAPI schema, so consumers
55
+ # that relied on root /ping behind a prefix must switch to {API_PREFIX}/ping;
56
+ # (2) the required auth-sdk-m8 floor crosses a major (<2.0.0 → >=2.0.1), which
57
+ # removed deprecated SDK APIs (Redis bus, decode_access_token, TOKEN_ALGORITHM,
58
+ # module-level settings_customise_sources). Either alone forces this major bump.
59
+ # Floor is >=2.0.1 (not 2.0.0) so the transitive pydantic-settings dep is
60
+ # >=2.14.2, carrying the SDK 2.0.1 nested-secrets symlink-traversal fix. See
61
+ # CHANGELOG.
62
+ "3.0": {"auth-sdk-m8": ">=2.0.1,<3.0.0"},
45
63
  }
46
64
 
47
65
  _EXTRAS = "[config,security,fastapi,observability]"