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.
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/.github/workflows/CI.yaml +26 -9
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/.github/workflows/PiPy.yml +5 -7
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/CHANGELOG.md +61 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/PKG-INFO +24 -12
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/README.md +22 -10
- fastapi_m8-3.3.0/constraints-all.txt +237 -0
- fastapi_m8-3.3.0/constraints.txt +114 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/fastapi_m8/_compat.py +10 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/fastapi_m8/_internal_auth.py +5 -2
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/fastapi_m8/_version.py +1 -1
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/fastapi_m8/config.py +68 -7
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/pyproject.toml +2 -2
- fastapi_m8-3.3.0/tests/test_ci_policy.py +136 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/test_config.py +80 -0
- fastapi_m8-3.2.0/.github/workflows/ci.yml +0 -54
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/.codacy.yml +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/.dockerignore +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/.env.example +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/.gitattributes +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/.github/FUNDING.yml +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/.github/dependabot.yml +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/.gitignore +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/.pydocstyle +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/LICENSE +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/SECURITY.md +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/fastapi_m8/__init__.py +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/fastapi_m8/_app.py +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/fastapi_m8/_async_stub.py +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/fastapi_m8/_deps.py +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/fastapi_m8/_engine.py +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/fastapi_m8/_events.py +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/fastapi_m8/_health.py +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/fastapi_m8/_revocation.py +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/fastapi_m8/scripts/__init__.py +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/fastapi_m8/scripts/docker_start.sh +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/fastapi_m8/scripts/pre_start.py +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/__init__.py +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/conftest.py +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/test_app.py +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/test_app_extra.py +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/test_async_stub.py +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/test_compat.py +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/test_config_file_secrets.py +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/test_deps.py +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/test_engine.py +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/test_event_signing_gate.py +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/test_events.py +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/test_health.py +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/test_host_header_routing.py +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/test_internal_auth.py +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/test_meta.py +0 -0
- {fastapi_m8-3.2.0 → fastapi_m8-3.3.0}/tests/test_pre_start.py +0 -0
- {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@
|
|
19
|
+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
17
20
|
|
|
18
21
|
- name: Set up Python
|
|
19
|
-
uses: actions/setup-python@
|
|
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@
|
|
39
|
+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
37
40
|
|
|
38
41
|
- name: Set up Python
|
|
39
|
-
uses: actions/setup-python@
|
|
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@
|
|
56
|
+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
54
57
|
|
|
55
58
|
- name: Set up Python
|
|
56
|
-
uses: actions/setup-python@
|
|
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@
|
|
90
|
+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
88
91
|
|
|
89
92
|
- name: Set up Python ${{ matrix.python-version }}
|
|
90
|
-
uses: actions/setup-python@
|
|
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@
|
|
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@
|
|
15
|
+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
16
16
|
|
|
17
|
-
- uses: actions/setup-python@
|
|
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@
|
|
66
|
+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
69
67
|
|
|
70
|
-
- uses: actions/setup-python@
|
|
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@
|
|
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.
|
|
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.
|
|
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)
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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` (
|
|
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)
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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` (
|
|
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
|
|
10
|
-
the issuer's legacy
|
|
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
|
|
@@ -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
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|