fastapi-m8 3.2.0__tar.gz → 3.3.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 (53) hide show
  1. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/.github/workflows/CI.yaml +26 -9
  2. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/.github/workflows/PiPy.yml +5 -7
  3. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/CHANGELOG.md +61 -0
  4. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/PKG-INFO +24 -12
  5. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/README.md +22 -10
  6. fastapi_m8-3.3.0/constraints-all.txt +237 -0
  7. fastapi_m8-3.3.0/constraints.txt +114 -0
  8. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/fastapi_m8/_compat.py +10 -0
  9. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/fastapi_m8/_internal_auth.py +5 -2
  10. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/fastapi_m8/_version.py +1 -1
  11. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/fastapi_m8/config.py +68 -7
  12. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/pyproject.toml +2 -2
  13. fastapi_m8-3.3.0/tests/test_ci_policy.py +136 -0
  14. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/test_config.py +80 -0
  15. fastapi_m8-3.2.0/.github/workflows/ci.yml +0 -54
  16. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/.codacy.yml +0 -0
  17. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/.dockerignore +0 -0
  18. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/.env.example +0 -0
  19. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/.gitattributes +0 -0
  20. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/.github/FUNDING.yml +0 -0
  21. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/.github/dependabot.yml +0 -0
  22. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/.gitignore +0 -0
  23. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/.pydocstyle +0 -0
  24. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/LICENSE +0 -0
  25. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/SECURITY.md +0 -0
  26. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/fastapi_m8/__init__.py +0 -0
  27. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/fastapi_m8/_app.py +0 -0
  28. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/fastapi_m8/_async_stub.py +0 -0
  29. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/fastapi_m8/_deps.py +0 -0
  30. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/fastapi_m8/_engine.py +0 -0
  31. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/fastapi_m8/_events.py +0 -0
  32. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/fastapi_m8/_health.py +0 -0
  33. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/fastapi_m8/_revocation.py +0 -0
  34. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/fastapi_m8/scripts/__init__.py +0 -0
  35. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/fastapi_m8/scripts/docker_start.sh +0 -0
  36. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/fastapi_m8/scripts/pre_start.py +0 -0
  37. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/__init__.py +0 -0
  38. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/conftest.py +0 -0
  39. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/test_app.py +0 -0
  40. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/test_app_extra.py +0 -0
  41. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/test_async_stub.py +0 -0
  42. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/test_compat.py +0 -0
  43. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/test_config_file_secrets.py +0 -0
  44. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/test_deps.py +0 -0
  45. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/test_engine.py +0 -0
  46. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/test_event_signing_gate.py +0 -0
  47. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/test_events.py +0 -0
  48. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/test_health.py +0 -0
  49. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/test_host_header_routing.py +0 -0
  50. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/test_internal_auth.py +0 -0
  51. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/test_meta.py +0 -0
  52. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/test_pre_start.py +0 -0
  53. {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/test_revocation.py +0 -0
@@ -6,6 +6,9 @@ on:
6
6
  pull_request:
7
7
  branches: ["main"]
8
8
 
9
+ permissions:
10
+ contents: read
11
+
9
12
  env:
10
13
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
11
14
 
@@ -13,10 +16,10 @@ jobs:
13
16
  lint:
14
17
  runs-on: ubuntu-latest
15
18
  steps:
16
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
19
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
17
20
 
18
21
  - name: Set up Python
19
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
22
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
20
23
  with:
21
24
  python-version: "3.14"
22
25
  cache: "pip"
@@ -33,10 +36,10 @@ jobs:
33
36
  typecheck:
34
37
  runs-on: ubuntu-latest
35
38
  steps:
36
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
39
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
37
40
 
38
41
  - name: Set up Python
39
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
42
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
40
43
  with:
41
44
  python-version: "3.14"
42
45
  cache: "pip"
@@ -50,10 +53,10 @@ jobs:
50
53
  security:
51
54
  runs-on: ubuntu-latest
52
55
  steps:
53
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
56
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
54
57
 
55
58
  - name: Set up Python
56
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
59
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
57
60
  with:
58
61
  python-version: "3.14"
59
62
 
@@ -84,10 +87,10 @@ jobs:
84
87
  python-version: ["3.11", "3.12", "3.13", "3.14"]
85
88
 
86
89
  steps:
87
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
90
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
88
91
 
89
92
  - name: Set up Python ${{ matrix.python-version }}
90
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
93
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
91
94
  with:
92
95
  python-version: ${{ matrix.python-version }}
93
96
  cache: "pip"
@@ -100,7 +103,7 @@ jobs:
100
103
 
101
104
  - name: Upload coverage to Codecov
102
105
  if: matrix.python-version == '3.11' && github.actor != 'dependabot[bot]'
103
- uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
106
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
104
107
  with:
105
108
  token: ${{ secrets.CODECOV_TOKEN }}
106
109
  files: coverage.xml
@@ -112,3 +115,17 @@ jobs:
112
115
  with:
113
116
  project-token: ${{ secrets.CODACY_PROJECT_TOKEN }}
114
117
  coverage-reports: coverage.xml
118
+
119
+ secret-scan:
120
+ name: Secret scan (gitleaks)
121
+ runs-on: ubuntu-latest
122
+
123
+ steps:
124
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
125
+ with:
126
+ fetch-depth: 0
127
+
128
+ - uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0
129
+ env:
130
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
131
+ GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
@@ -12,9 +12,9 @@ jobs:
12
12
  runs-on: ubuntu-latest
13
13
 
14
14
  steps:
15
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
15
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
16
16
 
17
- - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
17
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
18
18
  with:
19
19
  python-version: "3.x"
20
20
 
@@ -52,8 +52,6 @@ jobs:
52
52
 
53
53
  - name: Publish release distributions to PyPI
54
54
  uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
55
- with:
56
- password: ${{ secrets.PYPI_API_TOKEN }}
57
55
 
58
56
  sbom:
59
57
  name: Generate & attach SBOM
@@ -65,16 +63,16 @@ jobs:
65
63
  id-token: write
66
64
 
67
65
  steps:
68
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
66
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
69
67
 
70
- - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
68
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
71
69
  with:
72
70
  python-version: "3.x"
73
71
 
74
72
  - name: Install package
75
73
  run: pip install -e ".[all]"
76
74
 
77
- - uses: anchore/sbom-action@f325610c9f50a54015d37c8d16cb3b0e2c8f4de0 # v0.18.0
75
+ - uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
78
76
  with:
79
77
  artifact-name: fastapi-m8-${{ github.event.release.tag_name }}.sbom.spdx.json
80
78
  format: spdx-json
@@ -5,6 +5,67 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · Versioning:
5
5
 
6
6
  ---
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ---
11
+
12
+ ## [3.3.0] — 2026-07-02 · legacy consumer private-auth is development-only (item 11.2b) + supply-chain hardening (11.6/11.7/11.8)
13
+
14
+ > **MINOR — security remediation, no public API removed.** The only runtime behaviour change
15
+ > is a new fail-closed boot check: a production/strict consumer (`ENVIRONMENT=production` or
16
+ > `STRICT_PRODUCTION_MODE=true`) that still runs legacy single-shared-secret private auth
17
+ > (`INTROSPECTION_URL` set, `INTERNAL_CLIENT_ID` unset) now raises at settings construction.
18
+ > Such a consumer already could not authenticate against a hardened `fa-auth-m8` issuer
19
+ > (item 11.2a retired the shared-secret fallback), so this converts a latent deployment fault
20
+ > into an explicit error rather than changing any working configuration. Local/development is
21
+ > unaffected. `auth-sdk-m8` floor raised to `>=2.1.1,<3.0.0` (carries the SDK's latest
22
+ > dependency/CI maintenance release; no new SDK API is required).
23
+
24
+ ### Security
25
+
26
+ - **Legacy single-shared-secret private-auth mode is now development-only (OWASP remediation
27
+ item 11.2b).** `ConsumerServiceSettings` gained a `model_validator` that fails settings
28
+ construction when a production/strict consumer (`ENVIRONMENT=production` or
29
+ `STRICT_PRODUCTION_MODE=true`) has `INTROSPECTION_URL` configured but no
30
+ `INTERNAL_CLIENT_ID`. `fa-auth-m8` has retired the issuer's legacy single-shared-secret
31
+ private-API fallback (item 11.2a), so a production/strict consumer left in legacy
32
+ token-only mode is guaranteed to fail against a hardened issuer — this turns that latent
33
+ deployment fault into an explicit, fail-closed boot error. Legacy mode remains valid for
34
+ local/development.
35
+ - **Coherent per-consumer private-auth group validation.** The same validator now rejects
36
+ (in every environment) `SERVICE_TOKEN_EXCHANGE_ENABLED=true` without `INTERNAL_CLIENT_ID`
37
+ (exchange has no identity to present and would silently downgrade to legacy) and
38
+ `INTERNAL_CLIENT_ID` set without `PRIVATE_API_SECRET` (per-consumer mode needs its
39
+ bootstrap secret). No validation message echoes a secret value.
40
+ - **PyPI Trusted Publishing is now the sole publish mechanism (item 11.6).** The long-lived
41
+ `PYPI_API_TOKEN` secret was removed from `PiPy.yml`; the release job publishes via OIDC
42
+ (`id-token: write`) into a protected `pypi` environment. No API token is stored in the repo
43
+ or referenced by any workflow.
44
+ - **CI policy tests (item 11.7).** New `tests/test_ci_policy.py` asserts the supply-chain
45
+ posture in CI: no `PYPI_API_TOKEN` reference, `id-token: write` present, the protected
46
+ `pypi` environment is used, the duplicate `ci.yml` is gone, the secret-scan job is present,
47
+ every action is SHA-pinned in both workflows, and key runtime packages are pinned in
48
+ `constraints-all.txt`.
49
+ - **Locked dependency constraints (item 11.8).** `constraints.txt` and `constraints-all.txt`
50
+ are generated via `pip-compile` from public PyPI only (no custom index), giving CI a
51
+ reproducible, fully-pinned install closure.
52
+
53
+ ### Changed
54
+
55
+ - CI workflows consolidated — the secret-scan job was absorbed into `CI.yaml` and the
56
+ duplicate `ci.yml` removed; dependabot SHA bumps for `checkout`, `setup-python`,
57
+ `sbom-action`, `codecov-action`, and `gitleaks-action` applied across both workflows.
58
+ - `COMPAT_MATRIX` gains a `3.3` entry (`auth-sdk-m8 >=2.1.0,<3.0.0`, unchanged floor).
59
+
60
+ ### Docs
61
+
62
+ - README *Per-consumer internal auth* section and the defaults-by-layer table now document
63
+ that legacy mode is development-only and fatal under production/strict; removed wording
64
+ that implied the issuer still offers a shared-secret fallback. Compatibility table updated
65
+ with the `3.1.0`–`3.3.0` rows.
66
+
67
+ ---
68
+
8
69
  ## [3.2.0] — 2026-06-28 · ungated `/health` constant liveness body (item 9.4 Design B)
9
70
 
10
71
  > **MINOR — additive, backward-compatible.** The only behaviour change is in the ungated
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-m8
3
- Version: 3.2.0
3
+ Version: 3.3.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.1.0
219
+ Requires-Dist: auth-sdk-m8[config,events,fastapi,observability,security]<3.0.0,>=2.1.1
220
220
  Requires-Dist: fastapi>=0.136.3
221
221
  Requires-Dist: httpx>=0.27.0
222
222
  Requires-Dist: packaging>=24.0
@@ -620,18 +620,27 @@ Required only when `TOKEN_MODE=stateful` and `AUTH_SERVICE_ROLE=consumer`.
620
620
  #### Per-consumer internal auth (item 9.1)
621
621
 
622
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.
623
+ `PRIVATE_API_SECRET` (legacy mode). **Legacy mode is development-only.** `fa-auth-m8`
624
+ has retired the issuer's legacy single-shared-secret fallback — its
625
+ `PRIVATE_API_CONSUMERS` registry is required in production/strict (item 11.2a) — so a
626
+ production/strict consumer left in legacy mode is guaranteed to fail against a hardened
627
+ issuer. Set `INTERNAL_CLIENT_ID` to switch to the **per-consumer** model:
628
+ `PRIVATE_API_SECRET` becomes this service's *bootstrap* secret, fa-auth authorizes each
629
+ private route by the credential's granted scope (deny-by-default), and the blast radius
630
+ collapses from the whole fleet to one consumer. Optionally exchange the bootstrap
631
+ credential for short-TTL `Authorization: Bearer` service tokens so rotation comes for
632
+ free from the token TTL. Selection is purely by config — the home lab keeps working
633
+ untouched.
634
+
635
+ > **Fatal under production/strict (item 11.2b).** When `ENVIRONMENT=production` or
636
+ > `STRICT_PRODUCTION_MODE=true`, settings validation fails at construction if
637
+ > `INTROSPECTION_URL` is set but `INTERNAL_CLIENT_ID` is unset. Two coherence rules
638
+ > apply in every environment: `SERVICE_TOKEN_EXCHANGE_ENABLED=true` requires
639
+ > `INTERNAL_CLIENT_ID`, and setting `INTERNAL_CLIENT_ID` requires `PRIVATE_API_SECRET`.
631
640
 
632
641
  | Variable | Required | Default | Description |
633
642
  |---|---|---|---|
634
- | `INTERNAL_CLIENT_ID` | No | — | This consumer's `X-Internal-Client` id. Unset = legacy single-secret mode; set = per-consumer bootstrap mode. |
643
+ | `INTERNAL_CLIENT_ID` | No (Yes in prod/strict when `INTROSPECTION_URL` is set) | — | This consumer's `X-Internal-Client` id. Unset = legacy single-secret mode (development-only; rejected under production/strict once `INTROSPECTION_URL` is set); set = per-consumer bootstrap mode. |
635
644
  | `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
645
  | `SERVICE_TOKEN_SCOPES` | No | `["introspection"]` | Scopes requested when minting a service token; fa-auth narrows to the subset the bootstrap credential was granted. |
637
646
  | `SERVICE_TOKEN_REFRESH_LEEWAY_SECONDS` | No | `30` | Refresh a cached service token this many seconds before its `exp` so a call never races expiry. |
@@ -836,7 +845,7 @@ inherited from `auth-sdk-m8`, and made *fatal* only when pointed at production.
836
845
  | `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
846
  | `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
847
  | `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`) |
848
+ | `INTERNAL_CLIENT_ID` (items 9.1 + 11.2b) | unset = legacy single `PRIVATE_API_SECRET` (development-only); set = per-consumer bootstrap / service-token auth on private calls (must be coordinated with the issuer's `PRIVATE_API_CONSUMERS`) | **fatal** under production/strict when `INTROSPECTION_URL` is set but `INTERNAL_CLIENT_ID` is unset; also fatal (any env) when `SERVICE_TOKEN_EXCHANGE_ENABLED=true` without `INTERNAL_CLIENT_ID`, or `INTERNAL_CLIENT_ID` set without `PRIVATE_API_SECRET` |
840
849
  | `_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
850
 
842
851
  ---
@@ -1413,6 +1422,9 @@ async def test_health(client):
1413
1422
 
1414
1423
  | `fastapi-m8` | `auth-sdk-m8` | Python |
1415
1424
  |---|---|---|
1425
+ | `3.3.0` | `>=2.1.1, <3.0.0` | 3.11, 3.12, 3.13, 3.14 |
1426
+ | `3.2.0` | `>=2.1.0, <3.0.0` | 3.11, 3.12, 3.13, 3.14 |
1427
+ | `3.1.0` | `>=2.1.0, <3.0.0` | 3.11, 3.12, 3.13, 3.14 |
1416
1428
  | `3.0.0` | `>=2.0.1, <3.0.0` | 3.11, 3.12, 3.13, 3.14 |
1417
1429
  | `2.1.0` | `>=1.5.0, <2.0.0` | 3.11, 3.12, 3.13 |
1418
1430
  | `2.0.0` | `>=1.4.0, <2.0.0` | 3.11, 3.12, 3.13 |
@@ -364,18 +364,27 @@ Required only when `TOKEN_MODE=stateful` and `AUTH_SERVICE_ROLE=consumer`.
364
364
  #### Per-consumer internal auth (item 9.1)
365
365
 
366
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.
367
+ `PRIVATE_API_SECRET` (legacy mode). **Legacy mode is development-only.** `fa-auth-m8`
368
+ has retired the issuer's legacy single-shared-secret fallback — its
369
+ `PRIVATE_API_CONSUMERS` registry is required in production/strict (item 11.2a) — so a
370
+ production/strict consumer left in legacy mode is guaranteed to fail against a hardened
371
+ issuer. Set `INTERNAL_CLIENT_ID` to switch to the **per-consumer** model:
372
+ `PRIVATE_API_SECRET` becomes this service's *bootstrap* secret, fa-auth authorizes each
373
+ private route by the credential's granted scope (deny-by-default), and the blast radius
374
+ collapses from the whole fleet to one consumer. Optionally exchange the bootstrap
375
+ credential for short-TTL `Authorization: Bearer` service tokens so rotation comes for
376
+ free from the token TTL. Selection is purely by config — the home lab keeps working
377
+ untouched.
378
+
379
+ > **Fatal under production/strict (item 11.2b).** When `ENVIRONMENT=production` or
380
+ > `STRICT_PRODUCTION_MODE=true`, settings validation fails at construction if
381
+ > `INTROSPECTION_URL` is set but `INTERNAL_CLIENT_ID` is unset. Two coherence rules
382
+ > apply in every environment: `SERVICE_TOKEN_EXCHANGE_ENABLED=true` requires
383
+ > `INTERNAL_CLIENT_ID`, and setting `INTERNAL_CLIENT_ID` requires `PRIVATE_API_SECRET`.
375
384
 
376
385
  | Variable | Required | Default | Description |
377
386
  |---|---|---|---|
378
- | `INTERNAL_CLIENT_ID` | No | — | This consumer's `X-Internal-Client` id. Unset = legacy single-secret mode; set = per-consumer bootstrap mode. |
387
+ | `INTERNAL_CLIENT_ID` | No (Yes in prod/strict when `INTROSPECTION_URL` is set) | — | This consumer's `X-Internal-Client` id. Unset = legacy single-secret mode (development-only; rejected under production/strict once `INTROSPECTION_URL` is set); set = per-consumer bootstrap mode. |
379
388
  | `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
389
  | `SERVICE_TOKEN_SCOPES` | No | `["introspection"]` | Scopes requested when minting a service token; fa-auth narrows to the subset the bootstrap credential was granted. |
381
390
  | `SERVICE_TOKEN_REFRESH_LEEWAY_SECONDS` | No | `30` | Refresh a cached service token this many seconds before its `exp` so a call never races expiry. |
@@ -580,7 +589,7 @@ inherited from `auth-sdk-m8`, and made *fatal* only when pointed at production.
580
589
  | `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
590
  | `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
591
  | `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`) |
592
+ | `INTERNAL_CLIENT_ID` (items 9.1 + 11.2b) | unset = legacy single `PRIVATE_API_SECRET` (development-only); set = per-consumer bootstrap / service-token auth on private calls (must be coordinated with the issuer's `PRIVATE_API_CONSUMERS`) | **fatal** under production/strict when `INTROSPECTION_URL` is set but `INTERNAL_CLIENT_ID` is unset; also fatal (any env) when `SERVICE_TOKEN_EXCHANGE_ENABLED=true` without `INTERNAL_CLIENT_ID`, or `INTERNAL_CLIENT_ID` set without `PRIVATE_API_SECRET` |
584
593
  | `_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
594
 
586
595
  ---
@@ -1157,6 +1166,9 @@ async def test_health(client):
1157
1166
 
1158
1167
  | `fastapi-m8` | `auth-sdk-m8` | Python |
1159
1168
  |---|---|---|
1169
+ | `3.3.0` | `>=2.1.1, <3.0.0` | 3.11, 3.12, 3.13, 3.14 |
1170
+ | `3.2.0` | `>=2.1.0, <3.0.0` | 3.11, 3.12, 3.13, 3.14 |
1171
+ | `3.1.0` | `>=2.1.0, <3.0.0` | 3.11, 3.12, 3.13, 3.14 |
1160
1172
  | `3.0.0` | `>=2.0.1, <3.0.0` | 3.11, 3.12, 3.13, 3.14 |
1161
1173
  | `2.1.0` | `>=1.5.0, <2.0.0` | 3.11, 3.12, 3.13 |
1162
1174
  | `2.0.0` | `>=1.4.0, <2.0.0` | 3.11, 3.12, 3.13 |
@@ -0,0 +1,237 @@
1
+ #
2
+ # This file is autogenerated by pip-compile with Python 3.12
3
+ # by the following command:
4
+ #
5
+ # pip-compile --extra=all --extra=dev --no-emit-index-url --output-file=constraints-all.txt pyproject.toml
6
+ #
7
+ alembic==1.18.5
8
+ # via fastapi-m8
9
+ annotated-doc==0.0.4
10
+ # via fastapi
11
+ annotated-types==0.7.0
12
+ # via pydantic
13
+ anyio[trio]==4.14.1
14
+ # via
15
+ # fastapi-m8
16
+ # fastapi-m8 (pyproject.toml)
17
+ # httpx
18
+ # pytest-anyio
19
+ # starlette
20
+ asgi-lifespan==2.1.0
21
+ # via fastapi-m8 (pyproject.toml)
22
+ ast-serialize==0.6.0
23
+ # via mypy
24
+ attrs==26.1.0
25
+ # via
26
+ # outcome
27
+ # trio
28
+ auth-sdk-m8[config,events,fastapi,observability,security]==2.1.1
29
+ # via
30
+ # fastapi-m8
31
+ # fastapi-m8 (pyproject.toml)
32
+ bandit==1.9.4
33
+ # via fastapi-m8 (pyproject.toml)
34
+ boolean-py==5.0
35
+ # via license-expression
36
+ cachecontrol[filecache]==0.14.4
37
+ # via
38
+ # cachecontrol
39
+ # pip-audit
40
+ certifi==2026.6.17
41
+ # via
42
+ # httpcore
43
+ # httpx
44
+ # requests
45
+ cffi==2.0.0
46
+ # via cryptography
47
+ charset-normalizer==3.4.7
48
+ # via requests
49
+ coverage[toml]==7.14.3
50
+ # via
51
+ # fastapi-m8 (pyproject.toml)
52
+ # pytest-cov
53
+ cryptography==49.0.0
54
+ # via auth-sdk-m8
55
+ cyclonedx-python-lib==11.11.0
56
+ # via pip-audit
57
+ defusedxml==0.7.1
58
+ # via py-serializable
59
+ dnspython==2.8.0
60
+ # via email-validator
61
+ email-validator==2.3.0
62
+ # via auth-sdk-m8
63
+ fastapi==0.139.0
64
+ # via
65
+ # auth-sdk-m8
66
+ # fastapi-m8
67
+ # fastapi-m8 (pyproject.toml)
68
+ fastapi-m8[all,db,mysql,postgres] @ file:///workspace/fastapi-m8
69
+ # via fastapi-m8 (pyproject.toml)
70
+ filelock==3.29.4
71
+ # via cachecontrol
72
+ greenlet==3.5.3
73
+ # via sqlalchemy
74
+ h11==0.16.0
75
+ # via httpcore
76
+ httpcore==1.0.9
77
+ # via httpx
78
+ httpx==0.28.1
79
+ # via
80
+ # auth-sdk-m8
81
+ # fastapi-m8
82
+ # fastapi-m8 (pyproject.toml)
83
+ idna==3.18
84
+ # via
85
+ # anyio
86
+ # email-validator
87
+ # httpx
88
+ # requests
89
+ # trio
90
+ iniconfig==2.3.0
91
+ # via pytest
92
+ librt==0.12.0
93
+ # via mypy
94
+ license-expression==30.4.4
95
+ # via cyclonedx-python-lib
96
+ mako==1.3.12
97
+ # via alembic
98
+ markdown-it-py==4.2.0
99
+ # via rich
100
+ markupsafe==3.0.3
101
+ # via mako
102
+ mdurl==0.1.2
103
+ # via markdown-it-py
104
+ msgpack==1.2.1
105
+ # via cachecontrol
106
+ mypy==2.1.0
107
+ # via fastapi-m8 (pyproject.toml)
108
+ mypy-extensions==1.1.0
109
+ # via mypy
110
+ outcome==1.3.0.post0
111
+ # via trio
112
+ packageurl-python==0.17.6
113
+ # via cyclonedx-python-lib
114
+ packaging==26.2
115
+ # via
116
+ # fastapi-m8
117
+ # fastapi-m8 (pyproject.toml)
118
+ # pip-audit
119
+ # pip-requirements-parser
120
+ # pytest
121
+ pathspec==1.1.1
122
+ # via mypy
123
+ pip-api==0.0.34
124
+ # via pip-audit
125
+ pip-audit==2.10.1
126
+ # via fastapi-m8 (pyproject.toml)
127
+ pip-requirements-parser==32.0.1
128
+ # via pip-audit
129
+ platformdirs==4.10.0
130
+ # via pip-audit
131
+ pluggy==1.6.0
132
+ # via
133
+ # pytest
134
+ # pytest-cov
135
+ prometheus-client==0.25.0
136
+ # via auth-sdk-m8
137
+ psycopg2-binary==2.9.12
138
+ # via fastapi-m8
139
+ py-serializable==2.1.0
140
+ # via cyclonedx-python-lib
141
+ pycparser==3.0
142
+ # via cffi
143
+ pydantic==2.13.4
144
+ # via
145
+ # auth-sdk-m8
146
+ # fastapi
147
+ # pydantic-settings
148
+ # sqlmodel
149
+ pydantic-core==2.46.4
150
+ # via pydantic
151
+ pydantic-settings==2.14.2
152
+ # via auth-sdk-m8
153
+ pygments==2.20.0
154
+ # via
155
+ # pytest
156
+ # rich
157
+ pyjwt==2.13.0
158
+ # via auth-sdk-m8
159
+ pymysql==1.2.0
160
+ # via fastapi-m8
161
+ pyparsing==3.3.2
162
+ # via pip-requirements-parser
163
+ pytest==9.1.1
164
+ # via
165
+ # fastapi-m8 (pyproject.toml)
166
+ # pytest-anyio
167
+ # pytest-cov
168
+ pytest-anyio==0.0.0
169
+ # via fastapi-m8 (pyproject.toml)
170
+ pytest-cov==7.1.0
171
+ # via fastapi-m8 (pyproject.toml)
172
+ python-dotenv==1.2.2
173
+ # via pydantic-settings
174
+ pyyaml==6.0.3
175
+ # via bandit
176
+ requests==2.34.2
177
+ # via
178
+ # cachecontrol
179
+ # pip-audit
180
+ rich==15.0.0
181
+ # via
182
+ # bandit
183
+ # pip-audit
184
+ ruff==0.15.20
185
+ # via fastapi-m8 (pyproject.toml)
186
+ sniffio==1.3.1
187
+ # via
188
+ # asgi-lifespan
189
+ # trio
190
+ sortedcontainers==2.4.0
191
+ # via
192
+ # cyclonedx-python-lib
193
+ # trio
194
+ sqlalchemy==2.0.51
195
+ # via
196
+ # alembic
197
+ # fastapi-m8
198
+ # sqlmodel
199
+ sqlmodel==0.0.39
200
+ # via fastapi-m8
201
+ starlette==1.3.1
202
+ # via
203
+ # auth-sdk-m8
204
+ # fastapi
205
+ stevedore==5.8.0
206
+ # via bandit
207
+ tenacity==9.1.4
208
+ # via fastapi-m8 (pyproject.toml)
209
+ tomli==2.4.1
210
+ # via pip-audit
211
+ tomli-w==1.2.0
212
+ # via pip-audit
213
+ trio==0.33.0
214
+ # via anyio
215
+ typing-extensions==4.16.0
216
+ # via
217
+ # alembic
218
+ # anyio
219
+ # cyclonedx-python-lib
220
+ # fastapi
221
+ # mypy
222
+ # pydantic
223
+ # pydantic-core
224
+ # sqlalchemy
225
+ # sqlmodel
226
+ # starlette
227
+ # typing-inspection
228
+ typing-inspection==0.4.2
229
+ # via
230
+ # fastapi
231
+ # pydantic
232
+ # pydantic-settings
233
+ urllib3==2.7.0
234
+ # via requests
235
+
236
+ # The following packages are considered to be unsafe in a requirements file:
237
+ # pip
@@ -0,0 +1,114 @@
1
+ #
2
+ # This file is autogenerated by pip-compile with Python 3.12
3
+ # by the following command:
4
+ #
5
+ # pip-compile --extra=all --output-file=constraints.txt pyproject.toml
6
+ #
7
+ alembic==1.18.5
8
+ # via fastapi-m8
9
+ annotated-doc==0.0.4
10
+ # via fastapi
11
+ annotated-types==0.7.0
12
+ # via pydantic
13
+ anyio==4.14.1
14
+ # via
15
+ # fastapi-m8
16
+ # fastapi-m8 (pyproject.toml)
17
+ # httpx
18
+ # starlette
19
+ auth-sdk-m8[config,events,fastapi,observability,security]==2.1.1
20
+ # via
21
+ # fastapi-m8
22
+ # fastapi-m8 (pyproject.toml)
23
+ certifi==2026.6.17
24
+ # via
25
+ # httpcore
26
+ # httpx
27
+ cffi==2.0.0
28
+ # via cryptography
29
+ cryptography==49.0.0
30
+ # via auth-sdk-m8
31
+ dnspython==2.8.0
32
+ # via email-validator
33
+ email-validator==2.3.0
34
+ # via auth-sdk-m8
35
+ fastapi==0.139.0
36
+ # via
37
+ # auth-sdk-m8
38
+ # fastapi-m8
39
+ # fastapi-m8 (pyproject.toml)
40
+ fastapi-m8[db,mysql,postgres] @ file:///workspace/fastapi-m8
41
+ # via fastapi-m8 (pyproject.toml)
42
+ greenlet==3.5.3
43
+ # via sqlalchemy
44
+ h11==0.16.0
45
+ # via httpcore
46
+ httpcore==1.0.9
47
+ # via httpx
48
+ httpx==0.28.1
49
+ # via
50
+ # auth-sdk-m8
51
+ # fastapi-m8
52
+ # fastapi-m8 (pyproject.toml)
53
+ idna==3.18
54
+ # via
55
+ # anyio
56
+ # email-validator
57
+ # httpx
58
+ mako==1.3.12
59
+ # via alembic
60
+ markupsafe==3.0.3
61
+ # via mako
62
+ packaging==26.2
63
+ # via
64
+ # fastapi-m8
65
+ # fastapi-m8 (pyproject.toml)
66
+ prometheus-client==0.25.0
67
+ # via auth-sdk-m8
68
+ psycopg2-binary==2.9.12
69
+ # via fastapi-m8
70
+ pycparser==3.0
71
+ # via cffi
72
+ pydantic==2.13.4
73
+ # via
74
+ # auth-sdk-m8
75
+ # fastapi
76
+ # pydantic-settings
77
+ # sqlmodel
78
+ pydantic-core==2.46.4
79
+ # via pydantic
80
+ pydantic-settings==2.14.2
81
+ # via auth-sdk-m8
82
+ pyjwt==2.13.0
83
+ # via auth-sdk-m8
84
+ pymysql==1.2.0
85
+ # via fastapi-m8
86
+ python-dotenv==1.2.2
87
+ # via pydantic-settings
88
+ sqlalchemy==2.0.51
89
+ # via
90
+ # alembic
91
+ # fastapi-m8
92
+ # sqlmodel
93
+ sqlmodel==0.0.39
94
+ # via fastapi-m8
95
+ starlette==1.3.1
96
+ # via
97
+ # auth-sdk-m8
98
+ # fastapi
99
+ typing-extensions==4.16.0
100
+ # via
101
+ # alembic
102
+ # anyio
103
+ # fastapi
104
+ # pydantic
105
+ # pydantic-core
106
+ # sqlalchemy
107
+ # sqlmodel
108
+ # starlette
109
+ # typing-inspection
110
+ typing-inspection==0.4.2
111
+ # via
112
+ # fastapi
113
+ # pydantic
114
+ # pydantic-settings
@@ -74,6 +74,16 @@ COMPAT_MATRIX: dict[str, dict[str, str]] = {
74
74
  # status, per-check detail, and the 503 HTTP code on FAIL. No SDK change required;
75
75
  # auth-sdk-m8 floor stays at >=2.1.0. See CHANGELOG.
76
76
  "3.2": {"auth-sdk-m8": ">=2.1.0,<3.0.0"},
77
+ # 3.3 (MINOR) makes legacy single-shared-secret consumer private-auth
78
+ # development-only (item 11.2b): ConsumerServiceSettings fails closed at
79
+ # construction when a production/strict consumer has INTROSPECTION_URL set but
80
+ # no INTERNAL_CLIENT_ID (fa-auth-m8 retired the issuer's legacy fallback,
81
+ # item 11.2a), and rejects incoherent per-consumer config groups. Also lands
82
+ # PyPI Trusted Publishing (OIDC, no long-lived token), CI policy tests, and
83
+ # pip-compiled locked constraints (items 11.6/11.7/11.8). Floor raised to
84
+ # auth-sdk-m8 >=2.1.1 to carry the SDK's latest dependency/CI maintenance
85
+ # release (no new SDK API is required by 3.3). See CHANGELOG.
86
+ "3.3": {"auth-sdk-m8": ">=2.1.1,<3.0.0"},
77
87
  }
78
88
 
79
89
  _EXTRAS = "[config,security,fastapi,observability]"
@@ -6,8 +6,11 @@ JTI-status / revocation introspection endpoint — by one of three modes, select
6
6
  purely by configuration so the home lab keeps working unchanged:
7
7
 
8
8
  * **legacy** — ``INTERNAL_CLIENT_ID`` unset: send the single shared
9
- ``PRIVATE_API_SECRET`` as ``X-Internal-Token`` (the pre-9.1 behaviour; matches
10
- the issuer's legacy fallback when it has no per-consumer registry).
9
+ ``PRIVATE_API_SECRET`` as ``X-Internal-Token`` (the pre-9.1 behaviour).
10
+ **Development-only:** ``fa-auth-m8`` has retired the issuer's legacy
11
+ single-shared-secret fallback, so this mode is rejected under production/strict
12
+ once introspection is configured (see
13
+ ``ConsumerServiceSettings._validate_consumer_private_auth``, item 11.2b).
11
14
  * **bootstrap** — ``INTERNAL_CLIENT_ID`` set, exchange disabled: send the
12
15
  per-consumer ``X-Internal-Client`` + ``X-Internal-Token`` bootstrap pair on
13
16
  every private call. The blast radius is now one consumer, and the issuer gates
@@ -1,3 +1,3 @@
1
1
  """Single source of truth for the package version."""
2
2
 
3
- __version__ = "3.2.0"
3
+ __version__ = "3.3.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, SecretStr
26
+ from pydantic import Field, SecretStr, model_validator
27
27
 
28
28
 
29
29
  class ConsumerServiceSettings(
@@ -81,16 +81,23 @@ class ConsumerServiceSettings(
81
81
  # ── Per-consumer internal-auth (9.1, consumer side) ──────────────────────
82
82
  # Identity this consumer presents on private calls to fa-auth. Unset
83
83
  # (default) keeps the legacy single-secret mode: PRIVATE_API_SECRET is sent
84
- # as X-Internal-Token, matching the issuer's legacy fallback when it has no
85
- # PRIVATE_API_CONSUMERS registry. Set to this service's registered consumer
86
- # id to switch to the per-consumer model — PRIVATE_API_SECRET then carries
87
- # *this consumer's* bootstrap secret, and the issuer gates each private route
88
- # by the credential's granted scope (deny-by-default).
84
+ # as X-Internal-Token. This mode is DEVELOPMENT-ONLY and unsafe for a
85
+ # hardened deployment fa-auth-m8 has retired the issuer's legacy
86
+ # single-shared-secret fallback (its PRIVATE_API_CONSUMERS registry is now
87
+ # required in production/strict, item 11.2a), so a production/strict consumer
88
+ # left in legacy mode is guaranteed to fail against a hardened issuer. That is
89
+ # why ``_validate_consumer_private_auth`` (item 11.2b) makes legacy mode fatal
90
+ # once INTROSPECTION_URL is configured under production/strict. Set this to
91
+ # the service's registered consumer id to switch to the per-consumer model —
92
+ # PRIVATE_API_SECRET then carries *this consumer's* bootstrap secret, and the
93
+ # issuer gates each private route by the credential's granted scope
94
+ # (deny-by-default).
89
95
  INTERNAL_CLIENT_ID: str | None = Field(
90
96
  None,
91
97
  description=(
92
98
  "This consumer's X-Internal-Client id for per-consumer private auth. "
93
- "Unset = legacy single shared PRIVATE_API_SECRET mode."
99
+ "Unset = legacy single shared PRIVATE_API_SECRET mode "
100
+ "(development-only; rejected under production/strict)."
94
101
  ),
95
102
  )
96
103
  # When INTERNAL_CLIENT_ID is set, opt into exchanging the bootstrap
@@ -184,3 +191,57 @@ class ConsumerServiceSettings(
184
191
  range=self.CONTRACT_RANGE,
185
192
  ),
186
193
  )
194
+
195
+ @model_validator(mode="after")
196
+ def _validate_consumer_private_auth(self) -> "ConsumerServiceSettings":
197
+ """
198
+ Enforce a coherent per-consumer private-auth configuration (item 11.2b).
199
+
200
+ Three rules, checked at settings construction so a misconfigured consumer
201
+ fails closed before it can serve traffic. None of the messages echo a
202
+ secret value.
203
+
204
+ 1. **Coherent group — exchange needs an identity.**
205
+ ``SERVICE_TOKEN_EXCHANGE_ENABLED`` selects a *per-consumer* mode; with
206
+ no ``INTERNAL_CLIENT_ID`` the exchange has nothing to present and
207
+ ``build_internal_auth`` would silently fall back to legacy, so it is a
208
+ configuration error rather than a valid state.
209
+ 2. **Coherent group — per-consumer mode needs its bootstrap secret.**
210
+ When ``INTERNAL_CLIENT_ID`` is set, ``PRIVATE_API_SECRET`` carries this
211
+ consumer's bootstrap secret and must be present.
212
+ 3. **Legacy mode is development-only.** ``fa-auth-m8`` has retired the
213
+ issuer's legacy single-shared-secret private-API fallback (its
214
+ ``PRIVATE_API_CONSUMERS`` registry is required in production/strict,
215
+ item 11.2a). A production/strict consumer that still runs legacy
216
+ token-only mode (``INTROSPECTION_URL`` configured, ``INTERNAL_CLIENT_ID``
217
+ unset) is therefore guaranteed to fail against a hardened issuer — a
218
+ deployment fault, not a runtime mode — so it is rejected here. Legacy
219
+ mode stays valid for local/development only.
220
+ """
221
+ if self.SERVICE_TOKEN_EXCHANGE_ENABLED and not self.INTERNAL_CLIENT_ID:
222
+ raise ValueError(
223
+ "CONFIG: SERVICE_TOKEN_EXCHANGE_ENABLED=true requires "
224
+ "INTERNAL_CLIENT_ID — service-token exchange is a per-consumer "
225
+ "private-auth mode and has no identity to present without it."
226
+ )
227
+ if self.INTERNAL_CLIENT_ID and self.PRIVATE_API_SECRET is None:
228
+ raise ValueError(
229
+ "CONFIG: INTERNAL_CLIENT_ID is set but PRIVATE_API_SECRET is "
230
+ "missing — per-consumer private auth needs this consumer's "
231
+ "bootstrap secret."
232
+ )
233
+ is_production = self.ENVIRONMENT == "production" or self.STRICT_PRODUCTION_MODE
234
+ if (
235
+ is_production
236
+ and self.INTROSPECTION_URL is not None
237
+ and not self.INTERNAL_CLIENT_ID
238
+ ):
239
+ raise ValueError(
240
+ "CONFIG: production/strict consumer has INTROSPECTION_URL set but "
241
+ "no INTERNAL_CLIENT_ID — the legacy single-shared-secret private "
242
+ "API mode is development-only and is rejected by a hardened "
243
+ "fa-auth-m8 issuer (item 11.2b). Set INTERNAL_CLIENT_ID to this "
244
+ "service's registered consumer id; its PRIVATE_API_SECRET then "
245
+ "carries the per-consumer bootstrap secret."
246
+ )
247
+ return self
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "fastapi-m8"
7
- version = "3.2.0"
7
+ version = "3.3.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]>=2.1.0,<3.0.0",
30
+ "auth-sdk-m8[config,security,fastapi,observability,events]>=2.1.1,<3.0.0",
31
31
  ]
32
32
 
33
33
  [project.optional-dependencies]
@@ -0,0 +1,136 @@
1
+ """CI and release-workflow policy tests — findings 11.6, 11.7, 11.8."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+ REPO_ROOT = Path(__file__).resolve().parents[1]
10
+ WORKFLOWS = REPO_ROOT / ".github" / "workflows"
11
+ CI_YAML = WORKFLOWS / "CI.yaml"
12
+ PIPY_YML = WORKFLOWS / "PiPy.yml"
13
+
14
+ _SHA_RE = re.compile(r"^[0-9a-f]{40}$")
15
+ _USES_RE = re.compile(r"uses:\s+([a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+@(\S+))")
16
+
17
+
18
+ def _load_yaml(path: Path) -> dict[str, Any]:
19
+ with path.open() as fh:
20
+ return yaml.safe_load(fh) # type: ignore[no-any-return]
21
+
22
+
23
+ def _action_refs(path: Path) -> list[tuple[str, str]]:
24
+ """Return (full-ref, sha-candidate) for every action ``uses:`` in a workflow."""
25
+ results: list[tuple[str, str]] = []
26
+ for m in _USES_RE.finditer(path.read_text()):
27
+ full_ref = m.group(1)
28
+ sha_part = m.group(2).split("#")[0].strip()
29
+ results.append((full_ref, sha_part))
30
+ return results
31
+
32
+
33
+ # ── 11.6 PyPI Trusted Publishing — no long-lived API token ─────────────────
34
+
35
+
36
+ def test_pypi_workflow_no_api_token() -> None:
37
+ """PiPy.yml must not reference PYPI_API_TOKEN; OIDC Trusted Publishing only."""
38
+ assert "PYPI_API_TOKEN" not in PIPY_YML.read_text()
39
+
40
+
41
+ def test_pypi_publish_job_has_oidc_permission() -> None:
42
+ """The pypi-publish job must declare id-token: write for OIDC."""
43
+ wf = _load_yaml(PIPY_YML)
44
+ perms = wf["jobs"]["pypi-publish"].get("permissions", {})
45
+ assert perms.get("id-token") == "write"
46
+
47
+
48
+ def test_pypi_publish_job_uses_protected_environment() -> None:
49
+ """The pypi-publish job must target a named protected environment."""
50
+ wf = _load_yaml(PIPY_YML)
51
+ env = wf["jobs"]["pypi-publish"].get("environment")
52
+ assert env is not None, "pypi-publish must declare an environment."
53
+ name = env.get("name") if isinstance(env, dict) else env
54
+ assert name, "pypi-publish environment must have a non-empty name."
55
+
56
+
57
+ # ── 11.7 CI workflow consolidation — one canonical gate ────────────────────
58
+
59
+
60
+ def test_no_duplicate_ci_yml() -> None:
61
+ """ci.yml must not exist — CI.yaml is the single canonical quality gate."""
62
+ assert not (WORKFLOWS / "ci.yml").exists(), (
63
+ "Found duplicate ci.yml; the canonical workflow is CI.yaml."
64
+ )
65
+
66
+
67
+ def test_ci_yaml_has_secret_scan_job() -> None:
68
+ """CI.yaml must contain the gitleaks secret-scan job."""
69
+ wf = _load_yaml(CI_YAML)
70
+ assert "secret-scan" in wf["jobs"], "CI.yaml must include a secret-scan job."
71
+
72
+
73
+ def test_ci_yaml_actions_are_sha_pinned() -> None:
74
+ """Every action reference in CI.yaml must be pinned to a full 40-char commit SHA."""
75
+ refs = _action_refs(CI_YAML)
76
+ assert refs, "No action references found in CI.yaml."
77
+ for full_ref, sha_part in refs:
78
+ assert _SHA_RE.match(sha_part), (
79
+ f"CI.yaml: '{full_ref}' is not SHA-pinned — use a full 40-char commit hash."
80
+ )
81
+
82
+
83
+ def test_pipy_yml_actions_are_sha_pinned() -> None:
84
+ """Every action reference in PiPy.yml must be pinned to a full 40-char commit SHA."""
85
+ refs = _action_refs(PIPY_YML)
86
+ assert refs, "No action references found in PiPy.yml."
87
+ for full_ref, sha_part in refs:
88
+ assert _SHA_RE.match(sha_part), (
89
+ f"PiPy.yml: '{full_ref}' is not SHA-pinned — use a full 40-char commit hash."
90
+ )
91
+
92
+
93
+ # ── 11.8 Locked resolver outputs / package index policy ────────────────────
94
+
95
+
96
+ def test_constraints_all_exists() -> None:
97
+ """constraints-all.txt must exist as the locked all+dev dependency snapshot."""
98
+ assert (REPO_ROOT / "constraints-all.txt").exists()
99
+
100
+
101
+ def test_constraints_all_pins_key_runtime_deps() -> None:
102
+ """constraints-all.txt must pin every key runtime package from the closure."""
103
+ content = (REPO_ROOT / "constraints-all.txt").read_text().lower()
104
+ # Packages may appear with extras: pkg[extras]==version, so match name + optional extras + ==
105
+ required = [
106
+ "fastapi",
107
+ "httpx",
108
+ "pydantic",
109
+ "anyio",
110
+ "sqlmodel",
111
+ "auth-sdk-m8",
112
+ "pyjwt",
113
+ "cryptography",
114
+ "prometheus-client",
115
+ ]
116
+ for pkg in required:
117
+ # Match "pkg==" or "pkg[...]==" (extras bracket)
118
+ assert re.search(rf"\b{re.escape(pkg)}(?:\[[^\]]*\])?==", content), (
119
+ f"constraints-all.txt is missing a pin for '{pkg}'. "
120
+ "Re-run pip-compile to regenerate."
121
+ )
122
+
123
+
124
+ def test_constraints_no_custom_index_url() -> None:
125
+ """Constraint files must reference only the public PyPI index."""
126
+ for name in ("constraints.txt", "constraints-all.txt"):
127
+ path = REPO_ROOT / name
128
+ if not path.exists():
129
+ continue
130
+ content = path.read_text()
131
+ assert "--index-url" not in content, (
132
+ f"{name}: must not contain --index-url (public PyPI only)."
133
+ )
134
+ assert "--extra-index-url" not in content, (
135
+ f"{name}: must not contain --extra-index-url (public PyPI only)."
136
+ )
@@ -84,3 +84,83 @@ def test_event_stream_read_timeout_rejects_out_of_range() -> None:
84
84
  """A read timeout above the ceiling fails validation (le=3600)."""
85
85
  with pytest.raises(ValidationError, match="EVENT_STREAM_READ_TIMEOUT"):
86
86
  make_settings(EVENT_STREAM_READ_TIMEOUT=99999)
87
+
88
+
89
+ # ── Per-consumer private-auth validation (item 11.2b) ─────────────────────────
90
+
91
+ _INTROSPECTION = "http://auth:8000/user/private/v1/jti-status"
92
+
93
+
94
+ @pytest.mark.parametrize(
95
+ "prod_overrides",
96
+ [
97
+ {"ENVIRONMENT": "production"},
98
+ {"STRICT_PRODUCTION_MODE": True},
99
+ ],
100
+ )
101
+ def test_production_introspection_without_client_id_raises(
102
+ prod_overrides: dict,
103
+ ) -> None:
104
+ """Prod/strict + INTROSPECTION_URL + no INTERNAL_CLIENT_ID is fatal (item 11.2b)."""
105
+ with pytest.raises(ValidationError, match="INTERNAL_CLIENT_ID"):
106
+ make_settings(
107
+ INTROSPECTION_URL=_INTROSPECTION,
108
+ PRIVATE_API_SECRET=SecretStr("bootstrap-secret"),
109
+ **prod_overrides,
110
+ )
111
+
112
+
113
+ def test_production_introspection_with_client_id_ok() -> None:
114
+ """Prod + INTROSPECTION_URL + per-consumer identity is accepted."""
115
+ s = make_settings(
116
+ ENVIRONMENT="production",
117
+ INTROSPECTION_URL=_INTROSPECTION,
118
+ INTERNAL_CLIENT_ID="test-svc",
119
+ PRIVATE_API_SECRET=SecretStr("bootstrap-secret"),
120
+ )
121
+ assert s.INTERNAL_CLIENT_ID == "test-svc"
122
+
123
+
124
+ def test_local_legacy_mode_allowed() -> None:
125
+ """Development (local) legacy token-only mode remains valid (item 11.2b)."""
126
+ s = make_settings(
127
+ ENVIRONMENT="local",
128
+ INTROSPECTION_URL=_INTROSPECTION,
129
+ PRIVATE_API_SECRET=SecretStr("shared-secret"),
130
+ )
131
+ assert s.INTERNAL_CLIENT_ID is None
132
+
133
+
134
+ def test_production_without_introspection_allowed() -> None:
135
+ """A prod consumer with no private calls at all is unaffected by 11.2b."""
136
+ s = make_settings(ENVIRONMENT="production")
137
+ assert s.INTROSPECTION_URL is None
138
+ assert s.INTERNAL_CLIENT_ID is None
139
+
140
+
141
+ def test_service_token_exchange_without_client_id_raises() -> None:
142
+ """Coherent-group: exchange has no identity to present without a client id."""
143
+ with pytest.raises(ValidationError, match="SERVICE_TOKEN_EXCHANGE_ENABLED"):
144
+ make_settings(
145
+ INTROSPECTION_URL=_INTROSPECTION,
146
+ PRIVATE_API_SECRET=SecretStr("bootstrap-secret"),
147
+ SERVICE_TOKEN_EXCHANGE_ENABLED=True,
148
+ )
149
+
150
+
151
+ def test_client_id_without_private_secret_raises() -> None:
152
+ """Coherent-group: per-consumer mode needs this consumer's bootstrap secret."""
153
+ with pytest.raises(ValidationError, match="PRIVATE_API_SECRET"):
154
+ make_settings(INTERNAL_CLIENT_ID="test-svc")
155
+
156
+
157
+ def test_private_auth_validation_error_hides_secret() -> None:
158
+ """The fatal 11.2b message must not echo the bootstrap secret value."""
159
+ secret = "super-secret-bootstrap-value"
160
+ with pytest.raises(ValidationError) as exc:
161
+ make_settings(
162
+ ENVIRONMENT="production",
163
+ INTROSPECTION_URL=_INTROSPECTION,
164
+ PRIVATE_API_SECRET=SecretStr(secret),
165
+ )
166
+ assert secret not in str(exc.value)
@@ -1,54 +0,0 @@
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 }}
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes