fastapi-m8 3.3.0__tar.gz → 4.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/.codacy.yml +2 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/.dockerignore +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/.gitignore +1 -4
- fastapi_m8-4.0.0/.gitleaks.toml +15 -0
- fastapi_m8-4.0.0/AGENTS.md +27 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/CHANGELOG.md +129 -1
- fastapi_m8-4.0.0/CLAUDE.md +27 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/PKG-INFO +159 -12
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/README.md +155 -10
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/fastapi_m8/__init__.py +22 -1
- fastapi_m8-4.0.0/fastapi_m8/_api_key.py +415 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/fastapi_m8/_compat.py +7 -0
- fastapi_m8-4.0.0/fastapi_m8/_deps.py +502 -0
- fastapi_m8-4.0.0/fastapi_m8/_revocation.py +567 -0
- fastapi_m8-4.0.0/fastapi_m8/_route_audit.py +77 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/fastapi_m8/_version.py +1 -1
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/fastapi_m8/config.py +110 -1
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/fastapi_m8/scripts/docker_start.sh +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/pyproject.toml +8 -3
- fastapi_m8-4.0.0/tests/test_api_key.py +618 -0
- fastapi_m8-4.0.0/tests/test_api_key_deps.py +438 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_compat.py +30 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_deps.py +341 -35
- fastapi_m8-4.0.0/tests/test_packaging.py +164 -0
- fastapi_m8-4.0.0/tests/test_public_typing.py +118 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_revocation.py +389 -38
- fastapi_m8-4.0.0/tests/test_route_audit.py +151 -0
- fastapi_m8-3.3.0/fastapi_m8/_deps.py +0 -231
- fastapi_m8-3.3.0/fastapi_m8/_revocation.py +0 -321
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/.env.example +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/.gitattributes +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/.github/FUNDING.yml +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/.github/dependabot.yml +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/.github/workflows/CI.yaml +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/.github/workflows/PiPy.yml +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/.pydocstyle +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/LICENSE +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/SECURITY.md +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/constraints-all.txt +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/constraints.txt +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/fastapi_m8/_app.py +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/fastapi_m8/_async_stub.py +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/fastapi_m8/_engine.py +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/fastapi_m8/_events.py +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/fastapi_m8/_health.py +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/fastapi_m8/_internal_auth.py +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/fastapi_m8/scripts/__init__.py +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/fastapi_m8/scripts/pre_start.py +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/__init__.py +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/conftest.py +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_app.py +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_app_extra.py +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_async_stub.py +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_ci_policy.py +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_config.py +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_config_file_secrets.py +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_engine.py +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_event_signing_gate.py +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_events.py +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_health.py +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_host_header_routing.py +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_internal_auth.py +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_meta.py +0 -0
- {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_pre_start.py +0 -0
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
title = "fastapi-m8 gitleaks config"
|
|
2
|
+
|
|
3
|
+
[extend]
|
|
4
|
+
useDefault = true
|
|
5
|
+
|
|
6
|
+
[allowlist]
|
|
7
|
+
description = "Project-wide allowlist"
|
|
8
|
+
paths = [
|
|
9
|
+
# Test fixtures may contain deterministic low-entropy test values
|
|
10
|
+
'''tests/''',
|
|
11
|
+
]
|
|
12
|
+
# Add SHAs of any prior accidental secret commits here after rotation:
|
|
13
|
+
# commits = [
|
|
14
|
+
# "abc123def456", # description of what was rotated and when
|
|
15
|
+
# ]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# fastapi-m8
|
|
2
|
+
|
|
3
|
+
## Layer
|
|
4
|
+
Platform (FastAPI application framework)
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Purpose
|
|
9
|
+
Application factory wiring `auth-sdk-m8` into FastAPI services using `fa-auth-m8`.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Rules
|
|
14
|
+
- No business logic
|
|
15
|
+
- Only reusable service scaffolding
|
|
16
|
+
- Must remain minimal and reusable across services
|
|
17
|
+
- No coupling to fa-auth-m8 or any domain service
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Workspace integration
|
|
22
|
+
|
|
23
|
+
When nested in a workspace, locate the nearest ancestor containing
|
|
24
|
+
`.workspace/policy.index.json` and apply its `python` policy.
|
|
25
|
+
|
|
26
|
+
If no workspace policy exists, use this file, `pyproject.toml`, repository
|
|
27
|
+
documentation and existing CI as the authoritative local context.`
|
|
@@ -5,7 +5,135 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · Versioning:
|
|
|
5
5
|
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
## [
|
|
8
|
+
## [4.0.0] — 2026-07-17 · auth-sdk-m8 3.0.0 alignment — canonical role/flag invariant + JTI-status v2 + remote API-key principal
|
|
9
|
+
|
|
10
|
+
> **MAJOR — coordinated release with `auth-sdk-m8 3.0.0`.** `fastapi-m8` raises the SDK
|
|
11
|
+
> floor to `>=3.0.0,<4.0.0` to consume the canonical role/flag authorization invariant
|
|
12
|
+
> (`has_superuser_privileges()` and `has_minimum_role()`), the subject-bound JTI-status v2
|
|
13
|
+
> introspection contract, and the API-key principal/introspection schemas. All role guards
|
|
14
|
+
> now delegate to the shared SDK policy helpers, and the new remote API-key principal
|
|
15
|
+
> dependencies (`get_current_api_key_reader`/`_writer` and `require_api_key_role(...)`)
|
|
16
|
+
> resolve a key to its owner's **current** authority through the issuer's introspection
|
|
17
|
+
> endpoint, with no administrative or superuser capability surface. The API-key path is
|
|
18
|
+
> inherently fail-closed, never caches a positive principal, and never retries after
|
|
19
|
+
> transmission — ensuring a role downgrade lands on the key's very next request.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **JTI-status v2 (subject-bound, generation-tagged)** — `RemoteRevocationClient`
|
|
24
|
+
now speaks the v2 introspection contract: the request asserts the subject read
|
|
25
|
+
from the token (`{jti, expected_user_id, schema_version}`), and an active reply
|
|
26
|
+
carries the owner's `auth_generation`, which tags the cache entry it produces.
|
|
27
|
+
As defense in depth an active reply naming a different subject is refused and
|
|
28
|
+
never cached. **The v2 decision never falls open:** a reply this release cannot
|
|
29
|
+
interpret — malformed, unknown `schema_version`, or a pre-2.0 issuer's bare
|
|
30
|
+
`{"active": true}` with no generation — raises the new `RevocationDecisionError`
|
|
31
|
+
(a `RevocationCheckError`, so dependencies still answer `503`) regardless of
|
|
32
|
+
`ACCESS_REVOCATION_FAILURE_MODE`, which remains a transport-outage escape only.
|
|
33
|
+
- **`AuthDeps.handle_auth_event(event)`** — the supported `on_event` handler for
|
|
34
|
+
`build_event_stream_client`, so no service re-derives the eviction rules
|
|
35
|
+
locally. Accepts **both v1 and v2** `session-revoked` events for the rollout
|
|
36
|
+
interval in which the issuer emits v2 before every consumer has upgraded, and
|
|
37
|
+
routes `user-deleted` as before. v2 events apply the exact `<`/`==`/`>`
|
|
38
|
+
watermark rule per user, deduplicated on the durable `event_id` (never the SSE
|
|
39
|
+
transport id, which resets on issuer restart), so a replay is idempotent while
|
|
40
|
+
sibling JTI events at one generation stay distinguishable. A user-wide event
|
|
41
|
+
evicts the user's entries older than the revoking generation and spares
|
|
42
|
+
sessions minted after it. A v1 event carries no generation to compare, so it
|
|
43
|
+
evicts the whole user; an unusable payload flushes the cache.
|
|
44
|
+
- **Remote API-key principal dependencies** — `AuthDeps` gains
|
|
45
|
+
`get_current_api_key_principal`, the `require_api_key_role(...)` factory, and its
|
|
46
|
+
`get_current_api_key_reader`/`_writer` specializations, built by the same single
|
|
47
|
+
`build_auth_deps()` call as the JWT guards. A key presented on `X-API-Key` is
|
|
48
|
+
resolved to its **owner's current authority** through the issuer's
|
|
49
|
+
`POST /private/v1/api-keys/introspect`, then authorized with the same shared SDK
|
|
50
|
+
predicate the JWT guards use. The key itself carries no role, so a
|
|
51
|
+
`writer → reader` downgrade denies the key's very next request in every token
|
|
52
|
+
mode. Effective authority is an intersection that can only narrow: the owner's
|
|
53
|
+
live role ∩ the key's immutable `access_mode` ∩ the matching audience ∩ the route
|
|
54
|
+
policy. The members are `None` unless `API_KEY_INTROSPECTION_ENABLED=true`.
|
|
55
|
+
- **`ApiKeyIntrospectionClient`** — the issuer introspection client, alongside
|
|
56
|
+
`RemoteRevocationClient` and following the same split (the SDK owns the schemas,
|
|
57
|
+
the transport lives here). Bounded connection pool and concurrency semaphore,
|
|
58
|
+
redirects disabled, a hard response-size cap enforced mid-read, a consecutive-
|
|
59
|
+
failure circuit breaker, and pool-timeout-bounded load shedding.
|
|
60
|
+
- **Dedicated API-key configuration group** — `API_KEY_INTROSPECTION_ENABLED`,
|
|
61
|
+
`API_KEY_INTROSPECTION_URL` (or derived from `INTROSPECTION_URL`),
|
|
62
|
+
`_SCHEMA_VERSION`, `_CONNECT_TIMEOUT`, `_READ_TIMEOUT`, `_POOL_TIMEOUT`,
|
|
63
|
+
`_MAX_CONCURRENCY`, `_MAX_RESPONSE_BYTES`, `_CIRCUIT_FAILURE_THRESHOLD`, and
|
|
64
|
+
`_CIRCUIT_RESET_SECONDS`. Enabling the feature without an endpoint,
|
|
65
|
+
`INTERNAL_CLIENT_ID`, `PRIVATE_API_SECRET`, or with a schema version this SDK
|
|
66
|
+
release cannot speak **fails at startup**, so a half-configured guard never
|
|
67
|
+
serves traffic.
|
|
68
|
+
- **Public exports** — `API_KEY_HEADER`, `ApiKeyIntrospectionError`,
|
|
69
|
+
`ApiKeyQuotaExceededError`, and `derive_api_key_introspection_url`.
|
|
70
|
+
- **`AuthDeps.get_current_active_writer`** — the JWT writer dependency, built by the
|
|
71
|
+
single `build_auth_deps()` call alongside the existing authenticated/admin/superuser
|
|
72
|
+
members. Requires a minimum role of `WRITER`, so `WRITER`/`ADMIN`/`SUPERADMIN` pass
|
|
73
|
+
and `USER`/`READER` receive the unchanged `403 "The user doesn't have enough
|
|
74
|
+
privileges"` response.
|
|
75
|
+
- **`audit_api_key_routes(app, bare_dependency=..., exempt_paths=...)`** — walks a
|
|
76
|
+
built `FastAPI` app's routes and reports any that depend directly on the bare
|
|
77
|
+
`get_current_api_key_principal` instead of a role-capped dependency
|
|
78
|
+
(`require_api_key_role(...)`'s output). Depending on the bare dependency alone
|
|
79
|
+
grants no capability, but a route wired to it directly is a wiring mistake this
|
|
80
|
+
catches at test time (§3.3.1). Returns `BareApiKeyDependency(path, methods)`
|
|
81
|
+
findings; an explicit `exempt_paths` allowlist covers intentional read-only
|
|
82
|
+
routes (e.g. a `/verify`-style endpoint).
|
|
83
|
+
|
|
84
|
+
### Changed
|
|
85
|
+
|
|
86
|
+
- **All JWT role guards now authorize through the `auth-sdk-m8` policy helpers.**
|
|
87
|
+
`get_current_active_writer`/`_admin` delegate the hierarchy to
|
|
88
|
+
`has_minimum_role()` (replacing the local `RoleType.is_valid_role_auth()` call), so
|
|
89
|
+
the role ordering exists only in the SDK and the two guards cannot drift.
|
|
90
|
+
|
|
91
|
+
### Security
|
|
92
|
+
|
|
93
|
+
- **`get_current_active_writer`/`_admin`/`_superuser` never answer from the positive
|
|
94
|
+
revocation cache (`REV-CACHE-01`, 3.5.4).** They now resolve the token through a
|
|
95
|
+
dedicated fresh-only path (`RemoteRevocationClient.is_revoked(..., bypass_cache=True)`)
|
|
96
|
+
instead of the shared `get_current_user` dependency, so the first role-sensitive
|
|
97
|
+
request after a revocation commits always observes the new state. The short-TTL
|
|
98
|
+
positive cache (`REVOCATION_CACHE_TTL_SECONDS`) now serves only the general
|
|
99
|
+
authenticated tier (`CurrentUser`/`get_current_user`); a bypassed lookup still
|
|
100
|
+
refreshes the shared cache entry so the general tier benefits from it too.
|
|
101
|
+
- **`get_current_active_superuser` now requires canonical dual evidence.** It delegates
|
|
102
|
+
to the SDK's `has_superuser_privileges()`, which demands `role == SUPERADMIN` **and**
|
|
103
|
+
`is_superuser=True` together. Previously the guard checked the `is_superuser` flag and
|
|
104
|
+
the role in two separate steps; the flag is no longer sufficient evidence on its own at
|
|
105
|
+
any step. A token whose claims disagree (e.g. `is_superuser=true` with a lower role) is
|
|
106
|
+
already rejected at validation by the SDK 3.0.0 model invariant — this guard-level check
|
|
107
|
+
is defense in depth for a principal that reaches the dependency inconsistent.
|
|
108
|
+
- **No role guard consults `is_superuser` for a role threshold.** The writer and admin
|
|
109
|
+
dependencies are pure role checks, so a stray `is_superuser=true` can never bypass them.
|
|
110
|
+
- **An API key never grants administrative or superuser authority.** No
|
|
111
|
+
`get_current_api_key_admin`/`_superuser` dependency exists and no setting creates
|
|
112
|
+
one — `require_api_key_role()` raises `ApiKeyCapabilityCeilingError` at wiring time
|
|
113
|
+
for any role above `WRITER`. An owner's `ADMIN`/`SUPERADMIN` role grants an API-key
|
|
114
|
+
request nothing beyond writer-level capability, and `is_superuser` on the principal
|
|
115
|
+
is evidence about the owner's record, never authority. (Earlier drafts of this work
|
|
116
|
+
described a remote `_superuser` specialization disabled behind a per-deployment
|
|
117
|
+
opt-in; it is **removed**, not disabled-by-default.)
|
|
118
|
+
- **The API-key path is fail-closed with no configurable escape.** It never reads
|
|
119
|
+
`ACCESS_REVOCATION_FAILURE_MODE`, whose `fail_open` value must not exist here: an
|
|
120
|
+
issuer outage, timeout, open circuit, shed request, oversized/malformed response,
|
|
121
|
+
unknown schema version, or an audience mismatch always denies with `503`. There is
|
|
122
|
+
no fallback to a cached principal, a locally stored role, or bare key validity.
|
|
123
|
+
- **No positive principal caching.** Every capability-bearing request introspects the
|
|
124
|
+
issuer; only HTTP connection pooling is reused. `auth_generation` on the response is
|
|
125
|
+
evidence for the current decision, never authorization to reuse the principal later.
|
|
126
|
+
- **No post-transmission retries.** Introspection consumes the key's quota, so only a
|
|
127
|
+
failure known to precede transmission (connection establishment) is retried once. A
|
|
128
|
+
read timeout or `5xx` is never replayed, since that could double-charge the owner.
|
|
129
|
+
- **The raw key is confined to the outbound payload.** It is a `SecretStr` in every
|
|
130
|
+
model, and the request body is built explicitly at the transmission point rather
|
|
131
|
+
than serialized generically (which would send the mask). It never appears in the
|
|
132
|
+
URL, query, logs, traces, metrics, exception text, or model `repr`. Redirects are
|
|
133
|
+
disabled so the key cannot be replayed to an unauthenticated host.
|
|
134
|
+
- **Denials carry no owner-state oracle.** An unknown, revoked, or expired key and
|
|
135
|
+
every missing/inactive/inconsistent-owner cause share the single generic
|
|
136
|
+
`401 Invalid or expired API key`, identical to the response for no key at all.
|
|
9
137
|
|
|
10
138
|
---
|
|
11
139
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# fastapi-m8
|
|
2
|
+
|
|
3
|
+
## Layer
|
|
4
|
+
Platform (FastAPI application framework)
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Purpose
|
|
9
|
+
Application factory wiring `auth-sdk-m8` into FastAPI services using `fa-auth-m8`.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Rules
|
|
14
|
+
- No business logic
|
|
15
|
+
- Only reusable service scaffolding
|
|
16
|
+
- Must remain minimal and reusable across services
|
|
17
|
+
- No coupling to fa-auth-m8 or any domain service
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Workspace integration
|
|
22
|
+
|
|
23
|
+
When nested in a workspace, locate the nearest ancestor containing
|
|
24
|
+
`.workspace/policy.index.json` and apply its `python` policy.
|
|
25
|
+
|
|
26
|
+
If no workspace policy exists, use this file, `pyproject.toml`, repository
|
|
27
|
+
documentation and existing CI as the authoritative local context.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-m8
|
|
3
|
-
Version:
|
|
3
|
+
Version: 4.0.0
|
|
4
4
|
Summary: FastAPI application framework for m8 consumer microservices.
|
|
5
5
|
Author-email: Eli Serra <e.serra173@gmail.com>
|
|
6
6
|
License: Apache License
|
|
@@ -216,7 +216,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
216
216
|
Classifier: Topic :: Software Development :: Libraries
|
|
217
217
|
Requires-Python: >=3.11
|
|
218
218
|
Requires-Dist: anyio>=4.0
|
|
219
|
-
Requires-Dist: auth-sdk-m8[config,events,fastapi,observability,security]<
|
|
219
|
+
Requires-Dist: auth-sdk-m8[config,events,fastapi,observability,security]<4.0.0,>=3.0.0
|
|
220
220
|
Requires-Dist: fastapi>=0.136.3
|
|
221
221
|
Requires-Dist: httpx>=0.27.0
|
|
222
222
|
Requires-Dist: packaging>=24.0
|
|
@@ -235,7 +235,9 @@ Requires-Dist: alembic; extra == 'dev'
|
|
|
235
235
|
Requires-Dist: anyio[trio]>=4.0; extra == 'dev'
|
|
236
236
|
Requires-Dist: asgi-lifespan>=2.0; extra == 'dev'
|
|
237
237
|
Requires-Dist: bandit>=1.8.0; extra == 'dev'
|
|
238
|
+
Requires-Dist: build>=1.2.2; extra == 'dev'
|
|
238
239
|
Requires-Dist: coverage>=7.0; extra == 'dev'
|
|
240
|
+
Requires-Dist: hatchling>=1.27.0; extra == 'dev'
|
|
239
241
|
Requires-Dist: httpx; extra == 'dev'
|
|
240
242
|
Requires-Dist: mypy>=1.14.0; extra == 'dev'
|
|
241
243
|
Requires-Dist: pip-audit>=2.7.3; extra == 'dev'
|
|
@@ -287,6 +289,7 @@ boilerplate from every consumer service.
|
|
|
287
289
|
- [Token Modes](#token-modes)
|
|
288
290
|
- [Role System](#role-system)
|
|
289
291
|
- [Protecting Routes](#protecting-routes)
|
|
292
|
+
- [API-key routes (remote principal)](#api-key-routes-remote-principal)
|
|
290
293
|
8. [Health Endpoint](#health-endpoint)
|
|
291
294
|
9. [Database Integration](#database-integration)
|
|
292
295
|
10. [Pre-Start Script](#pre-start-script)
|
|
@@ -307,7 +310,8 @@ health checks; the framework wires the rest.
|
|
|
307
310
|
| Capability | How |
|
|
308
311
|
|---|---|
|
|
309
312
|
| JWT validation | `build_auth_deps()` + `auth-sdk-m8` validator |
|
|
310
|
-
| Role-based access control | `AuthDeps.
|
|
313
|
+
| Role-based access control | `AuthDeps.get_current_active_writer / _admin / _superuser` |
|
|
314
|
+
| API-key routes (optional) | `AuthDeps.get_current_api_key_reader / _writer` → live owner-role resolution via `fa-auth-m8` introspection; never admin/superuser |
|
|
311
315
|
| Token revocation (stateful mode) | `RemoteRevocationClient` → `fa-auth-m8` private API (optional short-TTL cache via `REVOCATION_CACHE_TTL_SECONDS`) |
|
|
312
316
|
| Auth event stream (optional) | `build_event_stream_client()` → fa-auth SSE bridge for best-effort cache eviction |
|
|
313
317
|
| CORS | Auto-wired from `settings.ALLOWED_ORIGINS` |
|
|
@@ -615,7 +619,7 @@ Required only when `TOKEN_MODE=stateful` and `AUTH_SERVICE_ROLE=consumer`.
|
|
|
615
619
|
| `INTROSPECTION_URL` | Yes | — | `POST` endpoint on auth service for JTI revocation checks, e.g. `http://auth_user_service:8000/user/private/v1/jti-status` |
|
|
616
620
|
| `PRIVATE_API_SECRET` | Yes | — | The credential for private calls. In **legacy** mode it is the single shared secret sent as `X-Internal-Token` (must match auth service); in **per-consumer** mode (see below) it carries *this* consumer's bootstrap secret. |
|
|
617
621
|
| `ACCESS_REVOCATION_FAILURE_MODE` | No | `fail_closed` | `fail_closed` (default, secure — reject tokens when the check is unverifiable, returning **503**) or `fail_open` (accept on network/HTTP error — the opt-out is logged loudly and counted as `revocation_check_failures_total{mode="fail_open"}`). |
|
|
618
|
-
| `REVOCATION_CACHE_TTL_SECONDS` | No | `0` | Short-TTL positive validation cache. `0` (default) disables it — every request calls fa-auth. Set to e.g. `30` to trust an `active=True` result for 30 s, skipping the HTTP round-trip; stream events (`session-revoked`/`user-deleted`) evict affected entries and an unresumable gap flushes all (requires the event-stream client). |
|
|
622
|
+
| `REVOCATION_CACHE_TTL_SECONDS` | No | `0` | Short-TTL positive validation cache. `0` (default) disables it — every request calls fa-auth. Set to e.g. `30` to trust an `active=True` result for 30 s, skipping the HTTP round-trip. Entries are tagged with the `auth_generation` fa-auth returned; stream events (`session-revoked`/`user-deleted`) evict affected entries and an unresumable gap flushes all (requires the event-stream client). This TTL is the staleness ceiling — the stream is an accelerator, not the guarantee. |
|
|
619
623
|
|
|
620
624
|
#### Per-consumer internal auth (item 9.1)
|
|
621
625
|
|
|
@@ -645,6 +649,53 @@ untouched.
|
|
|
645
649
|
| `SERVICE_TOKEN_SCOPES` | No | `["introspection"]` | Scopes requested when minting a service token; fa-auth narrows to the subset the bootstrap credential was granted. |
|
|
646
650
|
| `SERVICE_TOKEN_REFRESH_LEEWAY_SECONDS` | No | `30` | Refresh a cached service token this many seconds before its `exp` so a call never races expiry. |
|
|
647
651
|
|
|
652
|
+
### Remote API-Key Principal
|
|
653
|
+
|
|
654
|
+
Resolves a user API key presented by an external client to its owner's current
|
|
655
|
+
authority through the issuer's private introspection endpoint. Off by default;
|
|
656
|
+
see [API-key routes](#api-key-routes-remote-principal) for the route surface.
|
|
657
|
+
|
|
658
|
+
This group is **deliberately self-contained and has no fail-open option**. It
|
|
659
|
+
never consults `ACCESS_REVOCATION_FAILURE_MODE` — that knob's `fail_open` value
|
|
660
|
+
must not reach this path, because an unconfirmable API-key principal is always a
|
|
661
|
+
denial rather than a downgrade to bare key validity.
|
|
662
|
+
|
|
663
|
+
> **Startup fails, not the first request.** When `API_KEY_INTROSPECTION_ENABLED=true`,
|
|
664
|
+
> settings validation fails at construction if there is no endpoint (neither
|
|
665
|
+
> `API_KEY_INTROSPECTION_URL` nor `INTROSPECTION_URL` to derive one from), no
|
|
666
|
+
> `INTERNAL_CLIENT_ID`, no `PRIVATE_API_SECRET`, or a schema version this
|
|
667
|
+
> `auth-sdk-m8` release cannot speak. A half-configured guard never serves traffic.
|
|
668
|
+
|
|
669
|
+
The consumer's registered identity (`INTERNAL_CLIENT_ID`) is what fixes the
|
|
670
|
+
**audience** the issuer evaluates — it is derived from your credential, never from
|
|
671
|
+
the request — and the issuer echoes it back so the client can verify it. An active
|
|
672
|
+
principal carrying a different audience means the registry mapping or the
|
|
673
|
+
credential is wrong: that is a `503`, never a `401`.
|
|
674
|
+
|
|
675
|
+
| Variable | Required | Default | Description |
|
|
676
|
+
|---|---|---|---|
|
|
677
|
+
| `API_KEY_INTROSPECTION_ENABLED` | No | `false` | Enable the remote API-key principal dependencies. Requires `INTERNAL_CLIENT_ID`, `PRIVATE_API_SECRET`, and an endpoint. |
|
|
678
|
+
| `API_KEY_INTROSPECTION_URL` | No | derived | `POST` endpoint, e.g. `http://auth_user_service:8000/user/private/v1/api-keys/introspect`. Unset = derived from `INTROSPECTION_URL`. |
|
|
679
|
+
| `API_KEY_INTROSPECTION_SCHEMA_VERSION` | No | `"1"` | The contract version this consumer speaks. An unknown version fails at startup; a response declaring one fails closed (`503`). |
|
|
680
|
+
| `API_KEY_INTROSPECTION_CONNECT_TIMEOUT` | No | `2.0` | Connection-establishment timeout, seconds. |
|
|
681
|
+
| `API_KEY_INTROSPECTION_READ_TIMEOUT` | No | `3.0` | Response-read timeout, seconds. |
|
|
682
|
+
| `API_KEY_INTROSPECTION_POOL_TIMEOUT` | No | `2.0` | How long a request waits for concurrency capacity before being **shed** with a `503` instead of queueing onto a saturated issuer. |
|
|
683
|
+
| `API_KEY_INTROSPECTION_MAX_CONCURRENCY` | No | `20` | Ceiling on in-flight introspection calls. |
|
|
684
|
+
| `API_KEY_INTROSPECTION_MAX_RESPONSE_BYTES` | No | `8192` | Hard cap on the issuer response body; a larger reply is abandoned mid-read. |
|
|
685
|
+
| `API_KEY_INTROSPECTION_CIRCUIT_FAILURE_THRESHOLD` | No | `5` | Consecutive issuer failures that open the circuit. |
|
|
686
|
+
| `API_KEY_INTROSPECTION_CIRCUIT_RESET_SECONDS` | No | `30.0` | How long the circuit stays open before one trial call is admitted. |
|
|
687
|
+
|
|
688
|
+
The issuer must grant this consumer the dedicated `api-key-introspection` scope.
|
|
689
|
+
It is deliberately separate from `introspection`, so a consumer that checks JTI
|
|
690
|
+
status is not implicitly able to introspect user API keys — grant it explicitly.
|
|
691
|
+
|
|
692
|
+
**The key is never cached and never retried after transmission.** Every
|
|
693
|
+
capability-bearing request introspects the issuer (connection pooling is the only
|
|
694
|
+
reuse), so a downgrade lands immediately and a transport failure can never be
|
|
695
|
+
answered from a stale success. Because introspection consumes the key's quota,
|
|
696
|
+
only a failure known to have occurred *before* transmission (connection
|
|
697
|
+
establishment) is retried; a read timeout or `5xx` is never replayed.
|
|
698
|
+
|
|
648
699
|
### Auth Event Stream (fa-auth SSE bridge)
|
|
649
700
|
|
|
650
701
|
An **optional, best-effort accelerator** for cache eviction. `fa-auth-m8` bridges its
|
|
@@ -666,28 +717,28 @@ therefore **a single config knob** shared with JTI introspection: legacy
|
|
|
666
717
|
token, selected by `INTERNAL_CLIENT_ID` / `SERVICE_TOKEN_EXCHANGE_ENABLED` (see
|
|
667
718
|
*Per-consumer internal auth* above).
|
|
668
719
|
|
|
720
|
+
Pass `auth.handle_auth_event` as `on_event`: it applies each event to the validation
|
|
721
|
+
cache under the rules below, so your service never re-derives them.
|
|
722
|
+
|
|
669
723
|
```python
|
|
670
724
|
from contextlib import asynccontextmanager
|
|
671
725
|
|
|
672
726
|
from fastapi import FastAPI
|
|
673
|
-
from fastapi_m8 import build_event_stream_client
|
|
727
|
+
from fastapi_m8 import build_event_stream_client
|
|
674
728
|
|
|
675
|
-
|
|
676
|
-
async def on_auth_event(event: AuthStreamEvent) -> None:
|
|
677
|
-
# session-revoked / user-deleted → evict the affected entry from local caches.
|
|
678
|
-
...
|
|
729
|
+
from app.core.deps import auth # your single build_auth_deps(...) result
|
|
679
730
|
|
|
680
731
|
|
|
681
732
|
async def on_gap() -> None:
|
|
682
733
|
# Unresumable stream (fa-auth restarted / buffer evicted) → flush ALL caches.
|
|
683
|
-
|
|
734
|
+
auth.flush_cache()
|
|
684
735
|
|
|
685
736
|
|
|
686
737
|
@asynccontextmanager
|
|
687
738
|
async def lifespan(app: FastAPI):
|
|
688
739
|
client = build_event_stream_client(
|
|
689
740
|
settings,
|
|
690
|
-
on_event=
|
|
741
|
+
on_event=auth.handle_auth_event,
|
|
691
742
|
on_gap=on_gap,
|
|
692
743
|
)
|
|
693
744
|
client.start()
|
|
@@ -697,6 +748,24 @@ async def lifespan(app: FastAPI):
|
|
|
697
748
|
await client.stop()
|
|
698
749
|
```
|
|
699
750
|
|
|
751
|
+
`handle_auth_event` accepts **both v1 and v2** `session-revoked` events, so it keeps
|
|
752
|
+
working across the interval where the issuer already emits v2 and consumers have not
|
|
753
|
+
all upgraded. A v2 event carries the owner's `auth_generation` and a durable
|
|
754
|
+
`event_id`, which together make replay and reorder safe:
|
|
755
|
+
|
|
756
|
+
| Event generation vs. the user's watermark | Outcome |
|
|
757
|
+
|---|---|
|
|
758
|
+
| Lower | Already superseded — ignored. |
|
|
759
|
+
| Equal | Applied, unless that exact `event_id` was already applied at this generation. |
|
|
760
|
+
| Higher | Applied; the watermark advances. |
|
|
761
|
+
|
|
762
|
+
A user-wide v2 event (no `jti`) evicts only the entries older than the revoking
|
|
763
|
+
generation, so a session minted by a login that has already re-authenticated
|
|
764
|
+
survives. A v1 event carries no generation to compare against and therefore evicts
|
|
765
|
+
the whole user; an unusable payload flushes the cache. The durable `event_id` — not
|
|
766
|
+
the SSE transport id, which resets when fa-auth restarts — is the dedup key, so
|
|
767
|
+
several JTI events sharing one generation stay distinguishable.
|
|
768
|
+
|
|
700
769
|
The client verifies every payload's HMAC signature with `EVENT_SIGNING_KEY` (must match
|
|
701
770
|
fa-auth), auto-reconnects with jittered backoff, resumes via `Last-Event-ID`, and **never
|
|
702
771
|
raises into the host app**. Requires `TOKEN_MODE=stateful` so `INTROSPECTION_URL` is set.
|
|
@@ -956,9 +1025,18 @@ Returns a frozen dataclass with everything needed for route protection.
|
|
|
956
1025
|
|---|---|---|
|
|
957
1026
|
| `auth.CurrentUser` | `Annotated[UserModel, Depends(...)]` | Inject authenticated user into routes |
|
|
958
1027
|
| `auth.get_current_user` | `async Callable` | FastAPI dependency; validates JWT, checks revocation |
|
|
1028
|
+
| `auth.get_current_active_writer` | `Callable` | Raises 403 unless user has WRITER, ADMIN or SUPERADMIN role |
|
|
959
1029
|
| `auth.get_current_active_admin` | `Callable` | Raises 403 unless user has ADMIN or SUPERADMIN role |
|
|
960
|
-
| `auth.get_current_active_superuser` | `Callable` | Raises 403 unless user has SUPERADMIN role and `is_superuser=True` |
|
|
1030
|
+
| `auth.get_current_active_superuser` | `Callable` | Raises 403 unless user has SUPERADMIN role **and** `is_superuser=True` (both are required; neither claim grants privilege on its own) |
|
|
961
1031
|
| `auth.revocation_client` | `RemoteRevocationClient \| None` | Present only in stateful mode |
|
|
1032
|
+
| `auth.handle_auth_event` | `Callable` | Pass as `on_event` to `build_event_stream_client`; applies v1/v2 `session-revoked` and `user-deleted` events to the validation cache |
|
|
1033
|
+
| `auth.get_current_api_key_principal` | `async Callable \| None` | Resolves an `X-API-Key` to its owner's **current** authority via the issuer. Proves a live owner only — never writer capability |
|
|
1034
|
+
| `auth.require_api_key_role` | `Callable \| None` | Factory: `require_api_key_role(RoleType.WRITER)` builds a capability dependency. Rejects any role above `WRITER` |
|
|
1035
|
+
| `auth.get_current_api_key_reader` | `async Callable \| None` | Raises 403 unless the key's owner is at least READER |
|
|
1036
|
+
| `auth.get_current_api_key_writer` | `async Callable \| None` | Raises 403 unless the key's owner is at least WRITER **and** the key is `read_write` |
|
|
1037
|
+
| `auth.api_key_client` | `ApiKeyIntrospectionClient \| None` | Present only when `API_KEY_INTROSPECTION_ENABLED` |
|
|
1038
|
+
|
|
1039
|
+
The four API-key members are `None` unless `API_KEY_INTROSPECTION_ENABLED=true`.
|
|
962
1040
|
|
|
963
1041
|
**`UserModel` fields available in routes:**
|
|
964
1042
|
|
|
@@ -1121,6 +1199,13 @@ router = APIRouter()
|
|
|
1121
1199
|
async def get_profile(user: auth.CurrentUser):
|
|
1122
1200
|
return {"id": user.id, "email": user.email, "role": user.role}
|
|
1123
1201
|
|
|
1202
|
+
# WRITER, ADMIN or SUPERADMIN
|
|
1203
|
+
@router.post("/items")
|
|
1204
|
+
async def create_item(
|
|
1205
|
+
writer: Annotated[UserModel, Depends(auth.get_current_active_writer)],
|
|
1206
|
+
):
|
|
1207
|
+
...
|
|
1208
|
+
|
|
1124
1209
|
# ADMIN or SUPERADMIN
|
|
1125
1210
|
@router.delete("/users/{user_id}")
|
|
1126
1211
|
async def delete_user(
|
|
@@ -1144,6 +1229,67 @@ Unauthorized requests receive:
|
|
|
1144
1229
|
|
|
1145
1230
|
---
|
|
1146
1231
|
|
|
1232
|
+
### API-key routes (remote principal)
|
|
1233
|
+
|
|
1234
|
+
An API key is an opaque pointer to its **owner**, not a credential with
|
|
1235
|
+
capabilities of its own — it stores no role. A downstream service does not share
|
|
1236
|
+
the issuer's database, so it resolves the owner's **current** role by calling the
|
|
1237
|
+
issuer on every capability-bearing request. That is what makes a role downgrade
|
|
1238
|
+
take effect on the key's next request, in every token mode.
|
|
1239
|
+
|
|
1240
|
+
Enable it with `API_KEY_INTROSPECTION_ENABLED=true` (see
|
|
1241
|
+
[Remote API-Key Principal](#remote-api-key-principal)); the dependencies are
|
|
1242
|
+
`None` until you do. The key travels on the `X-API-Key` header — the same header
|
|
1243
|
+
the issuer reads, so a key behaves identically at either end.
|
|
1244
|
+
|
|
1245
|
+
```python
|
|
1246
|
+
# Reads only — the owner must be at least READER.
|
|
1247
|
+
@router.get("/items")
|
|
1248
|
+
async def list_items(
|
|
1249
|
+
principal: Annotated[ApiKeyPrincipal, Depends(auth.get_current_api_key_reader)],
|
|
1250
|
+
):
|
|
1251
|
+
...
|
|
1252
|
+
|
|
1253
|
+
# Writes — the owner must be at least WRITER *and* the key must be read_write.
|
|
1254
|
+
@router.post("/items")
|
|
1255
|
+
async def create_item(
|
|
1256
|
+
principal: Annotated[ApiKeyPrincipal, Depends(auth.get_current_api_key_writer)],
|
|
1257
|
+
):
|
|
1258
|
+
...
|
|
1259
|
+
```
|
|
1260
|
+
|
|
1261
|
+
Effective authority is an intersection, and every dimension can only narrow it:
|
|
1262
|
+
|
|
1263
|
+
```text
|
|
1264
|
+
owner's current role ∩ key access_mode ∩ matching audience ∩ route policy
|
|
1265
|
+
```
|
|
1266
|
+
|
|
1267
|
+
So a `read_only` key owned by a writer cannot write; a `read_write` key owned by
|
|
1268
|
+
a reader cannot write; and deactivating an owner denies every key they hold.
|
|
1269
|
+
|
|
1270
|
+
**API keys never carry administrative or superuser authority** — not from the
|
|
1271
|
+
owner's role, not from `is_superuser`, and not by configuration. There is no
|
|
1272
|
+
`get_current_api_key_admin`/`_superuser` dependency and no setting that creates
|
|
1273
|
+
one; `require_api_key_role()` raises `ApiKeyCapabilityCeilingError` at wiring
|
|
1274
|
+
time for anything above `WRITER`. Keep those operations on the JWT guards.
|
|
1275
|
+
|
|
1276
|
+
> **Depending on `get_current_api_key_principal` never implies write
|
|
1277
|
+
> capability.** It proves only that the key resolves to a live owner. Every
|
|
1278
|
+
> capability-bearing route must use a role dependency.
|
|
1279
|
+
|
|
1280
|
+
Responses:
|
|
1281
|
+
|
|
1282
|
+
- `401 Invalid or expired API key` — one generic denial covering an unknown,
|
|
1283
|
+
revoked, or expired key *and* every owner-state cause, so a caller cannot probe
|
|
1284
|
+
another account's state
|
|
1285
|
+
- `403 Forbidden` — the owner's role or the key's access mode is insufficient
|
|
1286
|
+
- `429` — the key's quota is exhausted; the issuer's `Retry-After` is relayed
|
|
1287
|
+
- `503` — the principal could not be confirmed (issuer outage, timeout, open
|
|
1288
|
+
circuit, shed load, or a response this release cannot interpret). **Always a
|
|
1289
|
+
denial** — there is no fallback to a cached principal or to bare key validity
|
|
1290
|
+
|
|
1291
|
+
---
|
|
1292
|
+
|
|
1147
1293
|
## Health Endpoint
|
|
1148
1294
|
|
|
1149
1295
|
Mounted automatically at `GET {API_PREFIX}/health/` (e.g. `/api/health/`).
|
|
@@ -1422,6 +1568,7 @@ async def test_health(client):
|
|
|
1422
1568
|
|
|
1423
1569
|
| `fastapi-m8` | `auth-sdk-m8` | Python |
|
|
1424
1570
|
|---|---|---|
|
|
1571
|
+
| `4.0.0` | `>=3.0.0, <4.0.0` | 3.11, 3.12, 3.13, 3.14 |
|
|
1425
1572
|
| `3.3.0` | `>=2.1.1, <3.0.0` | 3.11, 3.12, 3.13, 3.14 |
|
|
1426
1573
|
| `3.2.0` | `>=2.1.0, <3.0.0` | 3.11, 3.12, 3.13, 3.14 |
|
|
1427
1574
|
| `3.1.0` | `>=2.1.0, <3.0.0` | 3.11, 3.12, 3.13, 3.14 |
|