fastapi-m8 3.0.0__tar.gz → 3.2.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 (50) hide show
  1. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/.codacy.yml +4 -0
  2. fastapi_m8-3.2.0/.dockerignore +20 -0
  3. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/.env.example +34 -2
  4. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/.github/workflows/PiPy.yml +25 -0
  5. fastapi_m8-3.2.0/.github/workflows/ci.yml +54 -0
  6. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/.gitignore +3 -0
  7. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/CHANGELOG.md +95 -0
  8. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/PKG-INFO +70 -9
  9. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/README.md +68 -7
  10. fastapi_m8-3.2.0/SECURITY.md +50 -0
  11. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/fastapi_m8/__init__.py +13 -0
  12. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/fastapi_m8/_app.py +34 -9
  13. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/fastapi_m8/_compat.py +14 -0
  14. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/fastapi_m8/_deps.py +7 -6
  15. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/fastapi_m8/_events.py +37 -27
  16. fastapi_m8-3.2.0/fastapi_m8/_internal_auth.py +238 -0
  17. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/fastapi_m8/_revocation.py +83 -9
  18. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/fastapi_m8/_version.py +1 -1
  19. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/fastapi_m8/config.py +58 -0
  20. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/pyproject.toml +2 -2
  21. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/tests/test_app.py +197 -12
  22. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/tests/test_deps.py +44 -1
  23. fastapi_m8-3.2.0/tests/test_event_signing_gate.py +50 -0
  24. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/tests/test_events.py +42 -39
  25. fastapi_m8-3.2.0/tests/test_internal_auth.py +285 -0
  26. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/tests/test_revocation.py +174 -0
  27. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/.gitattributes +0 -0
  28. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/.github/FUNDING.yml +0 -0
  29. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/.github/dependabot.yml +0 -0
  30. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/.github/workflows/CI.yaml +0 -0
  31. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/.pydocstyle +0 -0
  32. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/LICENSE +0 -0
  33. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/fastapi_m8/_async_stub.py +0 -0
  34. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/fastapi_m8/_engine.py +0 -0
  35. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/fastapi_m8/_health.py +0 -0
  36. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/fastapi_m8/scripts/__init__.py +0 -0
  37. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/fastapi_m8/scripts/docker_start.sh +0 -0
  38. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/fastapi_m8/scripts/pre_start.py +0 -0
  39. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/tests/__init__.py +0 -0
  40. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/tests/conftest.py +0 -0
  41. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/tests/test_app_extra.py +0 -0
  42. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/tests/test_async_stub.py +0 -0
  43. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/tests/test_compat.py +0 -0
  44. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/tests/test_config.py +0 -0
  45. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/tests/test_config_file_secrets.py +0 -0
  46. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/tests/test_engine.py +0 -0
  47. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/tests/test_health.py +0 -0
  48. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/tests/test_host_header_routing.py +0 -0
  49. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/tests/test_meta.py +0 -0
  50. {fastapi_m8-3.0.0 → fastapi_m8-3.2.0}/tests/test_pre_start.py +0 -0
@@ -23,3 +23,7 @@ exclude_paths:
23
23
  - "*.egg-info/**"
24
24
  - ".pytest_cache/**"
25
25
  - "**/__pycache__/**"
26
+ # SECURITY.md is kept markdownlint-clean by hand; excluded here because
27
+ # Codacy's prose analyzer misclassifies a security policy doc (flags standard
28
+ # security acronyms such as CA/TTL/JTI as "undefined" on every revision).
29
+ - "SECURITY.md"
@@ -0,0 +1,20 @@
1
+ .git/
2
+ .github/
3
+ .mypy_cache/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ .vscode/
7
+ .tmp/
8
+ dist/
9
+ build/
10
+ tmp*/
11
+
12
+ __pycache__/
13
+ **/__pycache__/
14
+ *.pyc
15
+ *.pyo
16
+ *.egg-info/
17
+
18
+ .coverage
19
+ coverage.xml
20
+ tests/
@@ -68,8 +68,13 @@ TOKEN_AUDIENCE=my-service
68
68
  # is required at boot unless signing is disabled. Must match the value set on fa-auth.
69
69
  # DEV-ONLY placeholder — replace in staging/production.
70
70
  EVENT_SIGNING_KEY=DEV-ONLY-do-not-use-event-signing-key-Aa1!
71
- # EVENT_SIGNING_ENABLED=false # disable signing entirely (opt-out)
72
- # EVENT_SIGNING_ACCEPT_UNSIGNED=false # transitional: accept signed AND unsigned
71
+ # The two rollout flags below feed the shared check_config_health gate that
72
+ # create_app auto-runs at startup (item 7.x.1). Under STRICT_PRODUCTION_MODE
73
+ # both misconfigurations are FATAL (the app refuses to boot); EVENT_SIGNING_
74
+ # ACCEPT_UNSIGNED is also fatal under ENVIRONMENT=production. Leave both at the
75
+ # secure defaults (signing on, unsigned rejected) for any real deployment.
76
+ # EVENT_SIGNING_ENABLED=false # disable signing entirely (opt-out) — fatal under strict
77
+ # EVENT_SIGNING_ACCEPT_UNSIGNED=false # transitional: accept signed AND unsigned — fatal in prod/strict
73
78
 
74
79
  # ── Auth event stream (fa-auth SSE bridge) ────────────────────────────────────
75
80
  # Optional: start AuthEventStreamClient in lifespan for best-effort cache
@@ -85,7 +90,25 @@ EVENT_SIGNING_KEY=DEV-ONLY-do-not-use-event-signing-key-Aa1!
85
90
  # PRIVATE_API_SECRET=change-me-shared-internal-secret
86
91
 
87
92
  # Revocation failure mode: fail_closed (default, secure) or fail_open (availability).
93
+ # A fail_open opt-out is logged loudly and counted (revocation_check_failures_total
94
+ # {mode="fail_open"}) on every swallowed failure — item 5.5.
88
95
  # ACCESS_REVOCATION_FAILURE_MODE=fail_closed
96
+ # Positive validation cache for JTI checks: 0 (default) = always call fa-auth;
97
+ # >0 caches active=True for N seconds (stream events evict). e.g. 30.
98
+ # REVOCATION_CACHE_TTL_SECONDS=0
99
+
100
+ # ── Per-consumer internal auth (item 9.1) ────────────────────────────────────
101
+ # Unset (default) = legacy mode: PRIVATE_API_SECRET is sent as X-Internal-Token,
102
+ # matching fa-auth's fallback when it has no PRIVATE_API_CONSUMERS registry.
103
+ # Set INTERNAL_CLIENT_ID to this service's registered consumer id to switch to
104
+ # the per-consumer model — PRIVATE_API_SECRET then carries THIS consumer's
105
+ # bootstrap secret and fa-auth gates each private route by granted scope.
106
+ # INTERNAL_CLIENT_ID=my-service
107
+ # Opt into exchanging the bootstrap credential for short-TTL Bearer service
108
+ # tokens at {issuer}/private/v1/service-token (rotation comes from the short TTL):
109
+ # SERVICE_TOKEN_EXCHANGE_ENABLED=false
110
+ # SERVICE_TOKEN_SCOPES=introspection # comma/space list; default introspection
111
+ # SERVICE_TOKEN_REFRESH_LEEWAY_SECONDS=30 # refresh this many seconds before exp
89
112
 
90
113
  # ── Database ──────────────────────────────────────────────────────────────────
91
114
  SELECTED_DB=Mysql # Mysql | Postgres
@@ -103,6 +126,15 @@ REDIS_USER=redisuser
103
126
  REDIS_PASSWORD=change-me
104
127
  REDIS_SSL=false
105
128
 
129
+ # ── Health detail gating (item 9.3) ──────────────────────────────────────────
130
+ # Dedicated credential for the deep-/health detail body gate (X-Internal-Token).
131
+ # Unset (default) = detail body never shown — only {"status": "ok/fail"} is
132
+ # returned to unauthenticated callers (fail-closed). Set to a long-lived secret
133
+ # and present it as X-Internal-Token to receive the full per-check breakdown.
134
+ # MUST NOT equal PRIVATE_API_SECRET — a fatal startup error is raised on reuse.
135
+ # Supports _FILE mount: set HEALTH_DETAIL_CREDENTIAL_FILE=/run/secrets/health_cred.txt
136
+ # HEALTH_DETAIL_CREDENTIAL=change-me-health-detail-credential
137
+
106
138
  # ── Observability ─────────────────────────────────────────────────────────────
107
139
  METRICS_ENABLED=false
108
140
  # METRICS_GROUPS=traffic,performance,reliability,health,auth
@@ -54,3 +54,28 @@ jobs:
54
54
  uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
55
55
  with:
56
56
  password: ${{ secrets.PYPI_API_TOKEN }}
57
+
58
+ sbom:
59
+ name: Generate & attach SBOM
60
+ runs-on: ubuntu-latest
61
+ needs:
62
+ - release-build
63
+ permissions:
64
+ contents: write
65
+ id-token: write
66
+
67
+ steps:
68
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
69
+
70
+ - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
71
+ with:
72
+ python-version: "3.x"
73
+
74
+ - name: Install package
75
+ run: pip install -e ".[all]"
76
+
77
+ - uses: anchore/sbom-action@f325610c9f50a54015d37c8d16cb3b0e2c8f4de0 # v0.18.0
78
+ with:
79
+ artifact-name: fastapi-m8-${{ github.event.release.tag_name }}.sbom.spdx.json
80
+ format: spdx-json
81
+ upload-release-assets: true
@@ -0,0 +1,54 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ test:
14
+ name: Test & lint
15
+ runs-on: ubuntu-latest
16
+
17
+ steps:
18
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
19
+
20
+ - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
21
+ with:
22
+ python-version: "3.12"
23
+
24
+ - name: Install dependencies
25
+ run: pip install -e ".[dev]"
26
+
27
+ - name: Lint (ruff)
28
+ run: ruff check .
29
+
30
+ - name: Type check (mypy)
31
+ run: mypy fastapi_m8
32
+
33
+ - name: Security scan (bandit)
34
+ run: bandit -r fastapi_m8 -x tests
35
+
36
+ - name: Test (pytest)
37
+ run: pytest
38
+
39
+ - name: Dependency audit (pip-audit)
40
+ run: pip-audit
41
+
42
+ secret-scan:
43
+ name: Secret scan (gitleaks)
44
+ runs-on: ubuntu-latest
45
+
46
+ steps:
47
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
48
+ with:
49
+ fetch-depth: 0
50
+
51
+ - uses: gitleaks/gitleaks-action@1938557f6a58837331b99822ab17b8e536e7bef9 # v2.3.0
52
+ env:
53
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
54
+ GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
@@ -216,3 +216,6 @@ __marimo__/
216
216
 
217
217
  # Streamlit
218
218
  .streamlit/secrets.toml
219
+
220
+ Claude.md
221
+ Agents.md
@@ -5,6 +5,101 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · Versioning:
5
5
 
6
6
  ---
7
7
 
8
+ ## [3.2.0] — 2026-06-28 · ungated `/health` constant liveness body (item 9.4 Design B)
9
+
10
+ > **MINOR — additive, backward-compatible.** The only behaviour change is in the ungated
11
+ > `/health` response: callers without a valid `HEALTH_DETAIL_CREDENTIAL` now always
12
+ > receive `200 {"status":"ok"}` instead of the real aggregate status. Callers that
13
+ > already present the credential are unaffected — they still get the real status, per-check
14
+ > detail, and the 503 HTTP code when checks fail. `auth-sdk-m8` floor unchanged at
15
+ > `>=2.1.0,<3.0.0`.
16
+
17
+ ### Changed
18
+
19
+ - **Ungated `/health` body is now a constant liveness response (item 9.4 Design B).**
20
+ Callers without a valid `HEALTH_DETAIL_CREDENTIAL` (or `detail_public=True`) always
21
+ receive `200 {"status":"ok"}`, regardless of the real aggregate health. Previously the
22
+ ungated branch returned `{"status": overall.value}`, which leaked `degraded` — a
23
+ public attack-timing oracle that signals when fail-open degradation is active.
24
+
25
+ Health checks are **not run** for ungated requests (pure liveness, no dependency
26
+ probing); caching is unaffected for credentialed callers. Credentialed callers
27
+ (valid `X-Internal-Token` matching `HEALTH_DETAIL_CREDENTIAL`, or `detail_public=True`)
28
+ continue to receive the real aggregate status, the per-check body, and the correct
29
+ HTTP status code (200 or 503).
30
+
31
+ - **README `Health Detail Gating` section updated** to document the constant ungated
32
+ body and the Design B rationale; the defaults-by-layer table row for
33
+ `HEALTH_DETAIL_CREDENTIAL` updated accordingly.
34
+
35
+ ---
36
+
37
+ ## [3.1.0] — 2026-06-24/25 · consumer-side post-1.0 remediation (9.1 / 5.5 / 7.x.1 / 9.2 / 10.1) + event-stream 9.1 follow-on
38
+
39
+ > **MINOR — additive, backward-compatible.** Default behaviour is unchanged: a consumer
40
+ > with none of the new settings set keeps the legacy single `PRIVATE_API_SECRET`
41
+ > (`X-Internal-Token`) posture and the existing fail-closed revocation path. The post-1.0
42
+ > breaking removals (retiring the shared `PRIVATE_API_SECRET` model) remain **deferred** —
43
+ > `fastapi-m8 3.0.0` / `auth-sdk-m8 2.0.1` are not yet consumed by any live issuer/consumer
44
+ > stack, so this remediation wave folds into a minor rather than forcing a new major.
45
+ > Requires `auth-sdk-m8 >= 2.1.0, < 3.0.0` (floor raised from 2.0.1 by the event-stream
46
+ > follow-on item — `auth_provider` on `AuthEventStreamClient` landed in SDK 2.1.0).
47
+
48
+ ### Added
49
+
50
+ - **Per-consumer credentials & short-TTL service tokens — consumer side (item 9.1).** New
51
+ `fastapi_m8._internal_auth` with `build_internal_auth(settings)` selecting one of three
52
+ modes purely by config:
53
+ - **legacy** (`INTERNAL_CLIENT_ID` unset) — single `X-Internal-Token`, as before;
54
+ - **bootstrap** (`INTERNAL_CLIENT_ID` set) — per-consumer `X-Internal-Client` +
55
+ `X-Internal-Token` on every private call;
56
+ - **service token** (`+ SERVICE_TOKEN_EXCHANGE_ENABLED`) — exchange the bootstrap
57
+ credential at `{issuer}/private/v1/service-token` for a short-TTL `Authorization:
58
+ Bearer` token, refreshed before `exp` and re-exchanged once on a `401`.
59
+
60
+ `RemoteRevocationClient` now attaches the provider's headers per request (with a single
61
+ 401 re-exchange retry) instead of baking `X-Internal-Token` in at construction. New
62
+ public exports: `build_internal_auth`, `InternalAuthProvider`,
63
+ `ServiceTokenInternalAuth`, `derive_service_token_url`. New settings:
64
+ `INTERNAL_CLIENT_ID`, `SERVICE_TOKEN_EXCHANGE_ENABLED`, `SERVICE_TOKEN_SCOPES`,
65
+ `SERVICE_TOKEN_REFRESH_LEEWAY_SECONDS`.
66
+ - **Revocation degradation observability (item 5.5).** New
67
+ `revocation_check_failures_total{mode}` counter and a loud `security.revocation_fail_open`
68
+ log line: a `fail_open` opt-out is now surfaced as a conscious availability-over-safety
69
+ decision, never silent. Consumer-side degradation matrix tested end-to-end
70
+ (`fail_closed` → 503, `fail_open` → token accepted) through `get_current_user`.
71
+ - **`SECURITY.md` (item 9.2).** New consumer-scoped security doc cross-referencing
72
+ `auth-sdk-m8`'s canonical "Service identity and mTLS" pattern for multi-host deployments,
73
+ plus a vulnerability-reporting section.
74
+
75
+ ### Changed
76
+
77
+ - **`build_event_stream_client` routes through `build_internal_auth` (item 9.1 event-stream
78
+ follow-on, 2026-06-25).** The SSE stream now authenticates via the same
79
+ `InternalAuthProvider` the revocation client uses, selected purely by config (legacy /
80
+ bootstrap / service-token). The factory always passes `auth_provider` to
81
+ `AuthEventStreamClient`, which owns the provider lifecycle and closes it on `stop()`.
82
+ Consumer settings are unchanged; the only external requirement change is the SDK floor
83
+ bump to `>=2.1.0`.
84
+ - **`auth-sdk-m8` floor raised to `>=2.1.0`** (was `>=2.0.1`). `2.1.0` ships the
85
+ `auth_provider` parameter on `AuthEventStreamClient` and the framework-agnostic
86
+ `auth_sdk_m8.security.internal_auth` module. The `<3.0.0` ceiling is unchanged.
87
+ - **README (item 10.1).** New *Per-consumer internal auth* subsection and a *Defaults by
88
+ layer (consumer)* table documenting the consumer column (auto-run config-health,
89
+ `ALLOWED_HOSTS` inheritance, `METRICS_SCRAPE_CREDENTIAL`, `_FILE` inheritance,
90
+ `ACCESS_REVOCATION_FAILURE_MODE`). Event-stream section updated to note the shared
91
+ credential provider.
92
+ - **Event-signing rollout flags surfaced for consumers (item 7.x.1).** `.env.example`
93
+ documents `EVENT_SIGNING_ENABLED` / `EVENT_SIGNING_ACCEPT_UNSIGNED` and their strict
94
+ config-health gate (fatal under `STRICT_PRODUCTION_MODE`; `ACCEPT_UNSIGNED` also fatal
95
+ under `ENVIRONMENT=production`). The gate is auto-run via `create_app`; a consumer-side
96
+ test asserts it fires. `.env.example` also gains the new 9.1 vars and
97
+ `REVOCATION_CACHE_TTL_SECONDS`.
98
+ - `COMPAT_MATRIX` `3.1` entry updated to `auth-sdk-m8 >=2.1.0,<3.0.0` (reflects the
99
+ event-stream `auth_provider` requirement).
100
+
101
+ ---
102
+
8
103
  ## [3.0.0] — 2026-06-23 · auth-sdk-m8 2.0.0 alignment — single-mount `/ping` + SDK major floor
9
104
 
10
105
  > **MAJOR.** Two independent breaking changes, either of which alone forces this bump:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-m8
3
- Version: 3.0.0
3
+ Version: 3.2.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]<3.0.0,>=2.0.1
219
+ Requires-Dist: auth-sdk-m8[config,events,fastapi,observability,security]<3.0.0,>=2.1.0
220
220
  Requires-Dist: fastapi>=0.136.3
221
221
  Requires-Dist: httpx>=0.27.0
222
222
  Requires-Dist: packaging>=24.0
@@ -613,10 +613,29 @@ Required only when `TOKEN_MODE=stateful` and `AUTH_SERVICE_ROLE=consumer`.
613
613
  | Variable | Required | Default | Description |
614
614
  |---|---|---|---|
615
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` |
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). |
616
+ | `PRIVATE_API_SECRET` | Yes | — | The credential for private calls. In **legacy** mode it is the single shared secret sent as `X-Internal-Token` (must match auth service); in **per-consumer** mode (see below) it carries *this* consumer's bootstrap secret. |
617
+ | `ACCESS_REVOCATION_FAILURE_MODE` | No | `fail_closed` | `fail_closed` (default, secure — reject tokens when the check is unverifiable, returning **503**) or `fail_open` (accept on network/HTTP error — the opt-out is logged loudly and counted as `revocation_check_failures_total{mode="fail_open"}`). |
618
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). |
619
619
 
620
+ #### Per-consumer internal auth (item 9.1)
621
+
622
+ By default a consumer authenticates private calls with the single shared
623
+ `PRIVATE_API_SECRET` (legacy mode), which matches fa-auth's fallback when it has no
624
+ `PRIVATE_API_CONSUMERS` registry. Set `INTERNAL_CLIENT_ID` to switch to the
625
+ **per-consumer** model: `PRIVATE_API_SECRET` becomes this service's *bootstrap*
626
+ secret, fa-auth authorizes each private route by the credential's granted scope
627
+ (deny-by-default), and the blast radius collapses from the whole fleet to one
628
+ consumer. Optionally exchange the bootstrap credential for short-TTL `Authorization:
629
+ Bearer` service tokens so rotation comes for free from the token TTL. Selection is
630
+ purely by config — the home lab keeps working untouched.
631
+
632
+ | Variable | Required | Default | Description |
633
+ |---|---|---|---|
634
+ | `INTERNAL_CLIENT_ID` | No | — | This consumer's `X-Internal-Client` id. Unset = legacy single-secret mode; set = per-consumer bootstrap mode. |
635
+ | `SERVICE_TOKEN_EXCHANGE_ENABLED` | No | `false` | Exchange the bootstrap credential for short-TTL Bearer service tokens at `{issuer}/private/v1/service-token` (requires `INTERNAL_CLIENT_ID`). |
636
+ | `SERVICE_TOKEN_SCOPES` | No | `["introspection"]` | Scopes requested when minting a service token; fa-auth narrows to the subset the bootstrap credential was granted. |
637
+ | `SERVICE_TOKEN_REFRESH_LEEWAY_SECONDS` | No | `30` | Refresh a cached service token this many seconds before its `exp` so a call never races expiry. |
638
+
620
639
  ### Auth Event Stream (fa-auth SSE bridge)
621
640
 
622
641
  An **optional, best-effort accelerator** for cache eviction. `fa-auth-m8` bridges its
@@ -631,7 +650,12 @@ No second Redis, no broker: consumers speak HTTPS to fa-auth, which they already
631
650
  > HTTP authority path alone.
632
651
 
633
652
  Wire it in your lifespan with `build_event_stream_client`, which constructs the SDK's
634
- `AuthEventStreamClient` from your settings (no SDK internals needed):
653
+ `AuthEventStreamClient` from your settings (no SDK internals needed). The factory uses
654
+ the same internal-auth provider as the revocation client — the stream credential is
655
+ therefore **a single config knob** shared with JTI introspection: legacy
656
+ `X-Internal-Token`, per-consumer bootstrap, or short-TTL `Authorization: Bearer` service
657
+ token, selected by `INTERNAL_CLIENT_ID` / `SERVICE_TOKEN_EXCHANGE_ENABLED` (see
658
+ *Per-consumer internal auth* above).
635
659
 
636
660
  ```python
637
661
  from contextlib import asynccontextmanager
@@ -666,9 +690,9 @@ async def lifespan(app: FastAPI):
666
690
 
667
691
  The client verifies every payload's HMAC signature with `EVENT_SIGNING_KEY` (must match
668
692
  fa-auth), auto-reconnects with jittered backoff, resumes via `Last-Event-ID`, and **never
669
- raises into the host app**. Requires `TOKEN_MODE=stateful` so `INTROSPECTION_URL` and
670
- `PRIVATE_API_SECRET` are present. Behind a reverse proxy, disable response buffering on
671
- the stream endpoint so events and heartbeats pass through promptly.
693
+ raises into the host app**. Requires `TOKEN_MODE=stateful` so `INTROSPECTION_URL` is set.
694
+ Behind a reverse proxy, disable response buffering on the stream endpoint so events and
695
+ heartbeats pass through promptly.
672
696
 
673
697
  | Variable | Required | Default | Description |
674
698
  |---|---|---|---|
@@ -700,13 +724,32 @@ do not connect to Redis directly.
700
724
  | `REDIS_PASSWORD` | Redis password |
701
725
  | `REDIS_SSL` | Enable TLS (`true`/`false`, default `false`) |
702
726
 
727
+ ### Health Detail Gating (items 9.3 + 9.4)
728
+
729
+ | Variable | Default | Description |
730
+ |---|---|---|
731
+ | `HEALTH_DETAIL_CREDENTIAL` | — | Optional credential for the `/health` detail body gate. When **unset**, `/health` returns a constant `{"status":"ok"}` to all callers (fail-closed liveness response). When set, callers must present `X-Internal-Token: <value>` (constant-time match) to receive the real aggregate status, per-check breakdown, and the correct HTTP code (200 or 503). **Must not equal `PRIVATE_API_SECRET`** — accidental reuse is a fatal startup misconfiguration. Supports `_FILE` mount: set `HEALTH_DETAIL_CREDENTIAL_FILE=/run/secrets/health_cred.txt`. |
732
+
733
+ > **Constant ungated body (item 9.4 Design B):** ungated callers always receive
734
+ > `200 {"status":"ok"}` regardless of the real aggregate health (including `degraded`
735
+ > or `fail`). This prevents operational-state leakage on public-HTTPS stacks: a
736
+ > `degraded` response is a timing oracle that signals fail-open degradation is active.
737
+ > Only callers presenting a valid `HEALTH_DETAIL_CREDENTIAL` see the real status, the
738
+ > per-check detail, and the 503 HTTP code when checks fail. Liveness probes and
739
+ > `{API_PREFIX}/ping` are unaffected.
740
+
741
+ > **No-reuse enforcement (item 9.3):** at startup, `create_app` asserts that neither
742
+ > `HEALTH_DETAIL_CREDENTIAL` nor `METRICS_SCRAPE_CREDENTIAL` equals `PRIVATE_API_SECRET`.
743
+ > This ensures each credential is independently rotatable — exposing one value cannot open
744
+ > multiple surfaces. The check raises `ConfigurationError` (fail-closed), aborting startup.
745
+
703
746
  ### Observability
704
747
 
705
748
  | Variable | Default | Description |
706
749
  |---|---|---|
707
750
  | `METRICS_ENABLED` | `false` | Enable Prometheus metrics middleware and the `/metrics` route |
708
751
  | `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. |
752
+ | `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. **Must not equal `PRIVATE_API_SECRET`**. |
710
753
 
711
754
  > **`/metrics` route:** when `METRICS_ENABLED=true`, `create_app` also registers a
712
755
  > `GET /metrics` endpoint (hidden from the schema) rendering the Prometheus registry.
@@ -778,6 +821,24 @@ locally before deploy).
778
821
  > These knobs are inherited from `CommonSettings`; consumer services do not redeclare
779
822
  > them. The same layer is shared by `fa-auth-m8` and every consumer.
780
823
 
824
+ ### Defaults by layer (consumer)
825
+
826
+ How the security-critical settings behave specifically in the **fastapi-m8 consumer**
827
+ layer. This is the consumer column of the fleet-wide defaults-by-layer table (the SDK and
828
+ fa-auth-m8 READMEs hold the issuer/SDK columns). The throughline: secure-by-default,
829
+ inherited from `auth-sdk-m8`, and made *fatal* only when pointed at production.
830
+
831
+ | Control | Consumer behaviour | Becomes fatal when |
832
+ |---|---|---|
833
+ | Config-health gate | `create_app` **auto-runs** `check_config_health` in its lifespan before the app signals ready — no manual wiring | any fatal check trips (raises `ConfigurationError`, app refuses to boot) |
834
+ | `ACCESS_REVOCATION_FAILURE_MODE` | `fail_closed` by default — an unverifiable revocation check returns **503**; `fail_open` is a conscious opt-out, logged loudly and counted (`revocation_check_failures_total{mode="fail_open"}`) | never fatal (it *is* the degradation policy) |
835
+ | `ALLOWED_HOSTS` | inherited from `CommonSettings`; unset = no host check (dev) | unset under `STRICT_PRODUCTION_MODE` (strict fatal); wildcard `*` under strict |
836
+ | `EVENT_SIGNING_ENABLED` / `EVENT_SIGNING_ACCEPT_UNSIGNED` | inherited secure defaults (signing on, unsigned rejected); the gate fires through the consumer's auto-run config-health (item 7.x.1) | `ENABLED=false` under strict; `ACCEPT_UNSIGNED=true` under production or strict |
837
+ | `METRICS_SCRAPE_CREDENTIAL` | unset = `/metrics` relies on network isolation only; set = constant-time `Authorization: Bearer` gate. **Must not equal `PRIVATE_API_SECRET`** (fatal reuse check at startup). | never fatal (network-isolation is a valid posture); reuse of `PRIVATE_API_SECRET` is fatal |
838
+ | `HEALTH_DETAIL_CREDENTIAL` (items 9.3 + 9.4) | unset = `/health` returns a constant `{"status":"ok"}` to all callers (fail-closed liveness; real status never leaked); set = `X-Internal-Token` must match to receive the real aggregate status, per-check breakdown, and correct HTTP code. **Must not equal `PRIVATE_API_SECRET`** (fatal reuse check at startup). | never fatal (no credential = constant liveness response, which is a valid posture); reuse of `PRIVATE_API_SECRET` is fatal |
839
+ | `INTERNAL_CLIENT_ID` (item 9.1) | unset = legacy single `PRIVATE_API_SECRET`; set = per-consumer bootstrap / service-token auth on private calls | never fatal (must be coordinated with the issuer's `PRIVATE_API_CONSUMERS`) |
840
+ | `_FILE` secret mounts | **inherited** from `CommonSettings` — every secret (`PRIVATE_API_SECRET_FILE`, `DB_PASSWORD_FILE`, `METRICS_SCRAPE_CREDENTIAL_FILE`, `HEALTH_DETAIL_CREDENTIAL_FILE`, …) can be sourced from `/run/secrets/*` with no code change | a referenced `<FIELD>_FILE` path is missing (fails closed at construction) |
841
+
781
842
  ---
782
843
 
783
844
  ## API Reference
@@ -357,10 +357,29 @@ Required only when `TOKEN_MODE=stateful` and `AUTH_SERVICE_ROLE=consumer`.
357
357
  | Variable | Required | Default | Description |
358
358
  |---|---|---|---|
359
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` |
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). |
360
+ | `PRIVATE_API_SECRET` | Yes | — | The credential for private calls. In **legacy** mode it is the single shared secret sent as `X-Internal-Token` (must match auth service); in **per-consumer** mode (see below) it carries *this* consumer's bootstrap secret. |
361
+ | `ACCESS_REVOCATION_FAILURE_MODE` | No | `fail_closed` | `fail_closed` (default, secure — reject tokens when the check is unverifiable, returning **503**) or `fail_open` (accept on network/HTTP error — the opt-out is logged loudly and counted as `revocation_check_failures_total{mode="fail_open"}`). |
362
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). |
363
363
 
364
+ #### Per-consumer internal auth (item 9.1)
365
+
366
+ By default a consumer authenticates private calls with the single shared
367
+ `PRIVATE_API_SECRET` (legacy mode), which matches fa-auth's fallback when it has no
368
+ `PRIVATE_API_CONSUMERS` registry. Set `INTERNAL_CLIENT_ID` to switch to the
369
+ **per-consumer** model: `PRIVATE_API_SECRET` becomes this service's *bootstrap*
370
+ secret, fa-auth authorizes each private route by the credential's granted scope
371
+ (deny-by-default), and the blast radius collapses from the whole fleet to one
372
+ consumer. Optionally exchange the bootstrap credential for short-TTL `Authorization:
373
+ Bearer` service tokens so rotation comes for free from the token TTL. Selection is
374
+ purely by config — the home lab keeps working untouched.
375
+
376
+ | Variable | Required | Default | Description |
377
+ |---|---|---|---|
378
+ | `INTERNAL_CLIENT_ID` | No | — | This consumer's `X-Internal-Client` id. Unset = legacy single-secret mode; set = per-consumer bootstrap mode. |
379
+ | `SERVICE_TOKEN_EXCHANGE_ENABLED` | No | `false` | Exchange the bootstrap credential for short-TTL Bearer service tokens at `{issuer}/private/v1/service-token` (requires `INTERNAL_CLIENT_ID`). |
380
+ | `SERVICE_TOKEN_SCOPES` | No | `["introspection"]` | Scopes requested when minting a service token; fa-auth narrows to the subset the bootstrap credential was granted. |
381
+ | `SERVICE_TOKEN_REFRESH_LEEWAY_SECONDS` | No | `30` | Refresh a cached service token this many seconds before its `exp` so a call never races expiry. |
382
+
364
383
  ### Auth Event Stream (fa-auth SSE bridge)
365
384
 
366
385
  An **optional, best-effort accelerator** for cache eviction. `fa-auth-m8` bridges its
@@ -375,7 +394,12 @@ No second Redis, no broker: consumers speak HTTPS to fa-auth, which they already
375
394
  > HTTP authority path alone.
376
395
 
377
396
  Wire it in your lifespan with `build_event_stream_client`, which constructs the SDK's
378
- `AuthEventStreamClient` from your settings (no SDK internals needed):
397
+ `AuthEventStreamClient` from your settings (no SDK internals needed). The factory uses
398
+ the same internal-auth provider as the revocation client — the stream credential is
399
+ therefore **a single config knob** shared with JTI introspection: legacy
400
+ `X-Internal-Token`, per-consumer bootstrap, or short-TTL `Authorization: Bearer` service
401
+ token, selected by `INTERNAL_CLIENT_ID` / `SERVICE_TOKEN_EXCHANGE_ENABLED` (see
402
+ *Per-consumer internal auth* above).
379
403
 
380
404
  ```python
381
405
  from contextlib import asynccontextmanager
@@ -410,9 +434,9 @@ async def lifespan(app: FastAPI):
410
434
 
411
435
  The client verifies every payload's HMAC signature with `EVENT_SIGNING_KEY` (must match
412
436
  fa-auth), auto-reconnects with jittered backoff, resumes via `Last-Event-ID`, and **never
413
- raises into the host app**. Requires `TOKEN_MODE=stateful` so `INTROSPECTION_URL` and
414
- `PRIVATE_API_SECRET` are present. Behind a reverse proxy, disable response buffering on
415
- the stream endpoint so events and heartbeats pass through promptly.
437
+ raises into the host app**. Requires `TOKEN_MODE=stateful` so `INTROSPECTION_URL` is set.
438
+ Behind a reverse proxy, disable response buffering on the stream endpoint so events and
439
+ heartbeats pass through promptly.
416
440
 
417
441
  | Variable | Required | Default | Description |
418
442
  |---|---|---|---|
@@ -444,13 +468,32 @@ do not connect to Redis directly.
444
468
  | `REDIS_PASSWORD` | Redis password |
445
469
  | `REDIS_SSL` | Enable TLS (`true`/`false`, default `false`) |
446
470
 
471
+ ### Health Detail Gating (items 9.3 + 9.4)
472
+
473
+ | Variable | Default | Description |
474
+ |---|---|---|
475
+ | `HEALTH_DETAIL_CREDENTIAL` | — | Optional credential for the `/health` detail body gate. When **unset**, `/health` returns a constant `{"status":"ok"}` to all callers (fail-closed liveness response). When set, callers must present `X-Internal-Token: <value>` (constant-time match) to receive the real aggregate status, per-check breakdown, and the correct HTTP code (200 or 503). **Must not equal `PRIVATE_API_SECRET`** — accidental reuse is a fatal startup misconfiguration. Supports `_FILE` mount: set `HEALTH_DETAIL_CREDENTIAL_FILE=/run/secrets/health_cred.txt`. |
476
+
477
+ > **Constant ungated body (item 9.4 Design B):** ungated callers always receive
478
+ > `200 {"status":"ok"}` regardless of the real aggregate health (including `degraded`
479
+ > or `fail`). This prevents operational-state leakage on public-HTTPS stacks: a
480
+ > `degraded` response is a timing oracle that signals fail-open degradation is active.
481
+ > Only callers presenting a valid `HEALTH_DETAIL_CREDENTIAL` see the real status, the
482
+ > per-check detail, and the 503 HTTP code when checks fail. Liveness probes and
483
+ > `{API_PREFIX}/ping` are unaffected.
484
+
485
+ > **No-reuse enforcement (item 9.3):** at startup, `create_app` asserts that neither
486
+ > `HEALTH_DETAIL_CREDENTIAL` nor `METRICS_SCRAPE_CREDENTIAL` equals `PRIVATE_API_SECRET`.
487
+ > This ensures each credential is independently rotatable — exposing one value cannot open
488
+ > multiple surfaces. The check raises `ConfigurationError` (fail-closed), aborting startup.
489
+
447
490
  ### Observability
448
491
 
449
492
  | Variable | Default | Description |
450
493
  |---|---|---|
451
494
  | `METRICS_ENABLED` | `false` | Enable Prometheus metrics middleware and the `/metrics` route |
452
495
  | `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. |
496
+ | `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. **Must not equal `PRIVATE_API_SECRET`**. |
454
497
 
455
498
  > **`/metrics` route:** when `METRICS_ENABLED=true`, `create_app` also registers a
456
499
  > `GET /metrics` endpoint (hidden from the schema) rendering the Prometheus registry.
@@ -522,6 +565,24 @@ locally before deploy).
522
565
  > These knobs are inherited from `CommonSettings`; consumer services do not redeclare
523
566
  > them. The same layer is shared by `fa-auth-m8` and every consumer.
524
567
 
568
+ ### Defaults by layer (consumer)
569
+
570
+ How the security-critical settings behave specifically in the **fastapi-m8 consumer**
571
+ layer. This is the consumer column of the fleet-wide defaults-by-layer table (the SDK and
572
+ fa-auth-m8 READMEs hold the issuer/SDK columns). The throughline: secure-by-default,
573
+ inherited from `auth-sdk-m8`, and made *fatal* only when pointed at production.
574
+
575
+ | Control | Consumer behaviour | Becomes fatal when |
576
+ |---|---|---|
577
+ | Config-health gate | `create_app` **auto-runs** `check_config_health` in its lifespan before the app signals ready — no manual wiring | any fatal check trips (raises `ConfigurationError`, app refuses to boot) |
578
+ | `ACCESS_REVOCATION_FAILURE_MODE` | `fail_closed` by default — an unverifiable revocation check returns **503**; `fail_open` is a conscious opt-out, logged loudly and counted (`revocation_check_failures_total{mode="fail_open"}`) | never fatal (it *is* the degradation policy) |
579
+ | `ALLOWED_HOSTS` | inherited from `CommonSettings`; unset = no host check (dev) | unset under `STRICT_PRODUCTION_MODE` (strict fatal); wildcard `*` under strict |
580
+ | `EVENT_SIGNING_ENABLED` / `EVENT_SIGNING_ACCEPT_UNSIGNED` | inherited secure defaults (signing on, unsigned rejected); the gate fires through the consumer's auto-run config-health (item 7.x.1) | `ENABLED=false` under strict; `ACCEPT_UNSIGNED=true` under production or strict |
581
+ | `METRICS_SCRAPE_CREDENTIAL` | unset = `/metrics` relies on network isolation only; set = constant-time `Authorization: Bearer` gate. **Must not equal `PRIVATE_API_SECRET`** (fatal reuse check at startup). | never fatal (network-isolation is a valid posture); reuse of `PRIVATE_API_SECRET` is fatal |
582
+ | `HEALTH_DETAIL_CREDENTIAL` (items 9.3 + 9.4) | unset = `/health` returns a constant `{"status":"ok"}` to all callers (fail-closed liveness; real status never leaked); set = `X-Internal-Token` must match to receive the real aggregate status, per-check breakdown, and correct HTTP code. **Must not equal `PRIVATE_API_SECRET`** (fatal reuse check at startup). | never fatal (no credential = constant liveness response, which is a valid posture); reuse of `PRIVATE_API_SECRET` is fatal |
583
+ | `INTERNAL_CLIENT_ID` (item 9.1) | unset = legacy single `PRIVATE_API_SECRET`; set = per-consumer bootstrap / service-token auth on private calls | never fatal (must be coordinated with the issuer's `PRIVATE_API_CONSUMERS`) |
584
+ | `_FILE` secret mounts | **inherited** from `CommonSettings` — every secret (`PRIVATE_API_SECRET_FILE`, `DB_PASSWORD_FILE`, `METRICS_SCRAPE_CREDENTIAL_FILE`, `HEALTH_DETAIL_CREDENTIAL_FILE`, …) can be sourced from `/run/secrets/*` with no code change | a referenced `<FIELD>_FILE` path is missing (fails closed at construction) |
585
+
525
586
  ---
526
587
 
527
588
  ## API Reference
@@ -0,0 +1,50 @@
1
+ # Security
2
+
3
+ `fastapi-m8` is the FastAPI **consumer** framework for the m8 stack. Its runtime
4
+ security posture is inherited from `auth-sdk-m8` (RS256 + strict `iss`/`aud`
5
+ validation, signed events, fail-closed revocation, the response-hardening layer)
6
+ and wired in by `create_app` / `build_auth_deps`. This file documents only the
7
+ consumer-specific transport guidance; the canonical, fleet-wide controls live in
8
+ the **[`auth-sdk-m8` `SECURITY.md`](https://github.com/mano8/auth-sdk-m8/blob/main/SECURITY.md)**.
9
+
10
+ ## Private calls a consumer makes
11
+
12
+ A `fastapi-m8` consumer reaches `fa-auth-m8` over the **private API** only:
13
+
14
+ - JTI-status / revocation introspection (`{issuer}/private/v1/jti-status`),
15
+ - the optional auth event stream (`{issuer}/private/v1/events/stream`),
16
+ - the optional short-TTL service-token exchange (`{issuer}/private/v1/service-token`).
17
+
18
+ These are authenticated at the **app layer** by the per-consumer credential model
19
+ (item 9.1): `X-Internal-Client` + `X-Internal-Token`, or an exchanged
20
+ `Authorization: Bearer` service token, with a legacy single `PRIVATE_API_SECRET`
21
+ fallback. See the README's *Per-consumer internal auth* section.
22
+
23
+ ## Service identity and mTLS (multi-host deployments)
24
+
25
+ The app-layer token/credential check is always the **primary** control. For the
26
+ transport beneath it, follow the canonical
27
+ [**"Service identity and mTLS"**](https://github.com/mano8/auth-sdk-m8/blob/main/SECURITY.md#service-identity-and-mtls-multi-host-deployments)
28
+ section of `auth-sdk-m8`'s `SECURITY.md` — it carries the Traefik internal-entrypoint
29
+ client-cert reference config, the CA/cert generation steps, and the service-mesh
30
+ alternative. From the **consumer** side:
31
+
32
+ - **Single trusted Docker host.** Internal `http://` between the consumer and
33
+ `fa-auth-m8` is acceptable — the Docker network is the isolation boundary. This is
34
+ warned, not blocked, in production (`ALLOW_INTERNAL_HTTP`); `local` is unrestricted.
35
+ - **Multi-host / untrusted network.** The container network no longer provides
36
+ kernel isolation, so add **mTLS** on the path to the issuer's private entrypoint:
37
+ the consumer presents a client certificate that Traefik (or a service-mesh sidecar)
38
+ verifies (`RequireAndVerifyClientCert`). Mount the consumer's client cert/key and
39
+ the CA into the container per the auth-sdk-m8 reference config.
40
+ - **Defense in depth, not a replacement.** Keep the `X-Internal-Token` /
41
+ `X-Internal-Client` / service-token app-layer check enabled alongside mTLS: if cert
42
+ rotation lapses the token check still gates access, and if a token leaks over an
43
+ unencrypted hop mTLS encrypts the channel. Internal HTTPS is **not** a blanket
44
+ mandate — mTLS is the *multi-host* transport control.
45
+
46
+ ## Reporting a vulnerability
47
+
48
+ Report security vulnerabilities privately through GitHub's **Security** tab on this repository —
49
+ **"Report a vulnerability"** — which opens a private security advisory visible only to the
50
+ maintainers. Do not open a public GitHub issue for vulnerabilities. Expected response within 48 h.
@@ -50,6 +50,14 @@ from fastapi_m8._health import (
50
50
  HealthCheckResult,
51
51
  HealthStatus,
52
52
  )
53
+
54
+ # Tier 1 — per-consumer internal-auth for private calls (Phase 9.1)
55
+ from fastapi_m8._internal_auth import (
56
+ InternalAuthProvider,
57
+ ServiceTokenInternalAuth,
58
+ build_internal_auth,
59
+ derive_service_token_url,
60
+ )
53
61
  from fastapi_m8._version import __version__
54
62
  from fastapi_m8.config import ConsumerServiceSettings
55
63
 
@@ -64,6 +72,11 @@ __all__ = [
64
72
  "create_db_engine",
65
73
  "DbEngine",
66
74
  "ConsumerServiceSettings",
75
+ # Tier 1 — per-consumer internal-auth
76
+ "build_internal_auth",
77
+ "InternalAuthProvider",
78
+ "ServiceTokenInternalAuth",
79
+ "derive_service_token_url",
67
80
  # Tier 1 — auth event stream
68
81
  "build_event_stream_client",
69
82
  "AuthEventStreamClient",