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.
Files changed (64) hide show
  1. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/.codacy.yml +2 -0
  2. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/.dockerignore +0 -0
  3. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/.gitignore +1 -4
  4. fastapi_m8-4.0.0/.gitleaks.toml +15 -0
  5. fastapi_m8-4.0.0/AGENTS.md +27 -0
  6. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/CHANGELOG.md +129 -1
  7. fastapi_m8-4.0.0/CLAUDE.md +27 -0
  8. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/PKG-INFO +159 -12
  9. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/README.md +155 -10
  10. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/fastapi_m8/__init__.py +22 -1
  11. fastapi_m8-4.0.0/fastapi_m8/_api_key.py +415 -0
  12. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/fastapi_m8/_compat.py +7 -0
  13. fastapi_m8-4.0.0/fastapi_m8/_deps.py +502 -0
  14. fastapi_m8-4.0.0/fastapi_m8/_revocation.py +567 -0
  15. fastapi_m8-4.0.0/fastapi_m8/_route_audit.py +77 -0
  16. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/fastapi_m8/_version.py +1 -1
  17. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/fastapi_m8/config.py +110 -1
  18. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/fastapi_m8/scripts/docker_start.sh +0 -0
  19. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/pyproject.toml +8 -3
  20. fastapi_m8-4.0.0/tests/test_api_key.py +618 -0
  21. fastapi_m8-4.0.0/tests/test_api_key_deps.py +438 -0
  22. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_compat.py +30 -0
  23. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_deps.py +341 -35
  24. fastapi_m8-4.0.0/tests/test_packaging.py +164 -0
  25. fastapi_m8-4.0.0/tests/test_public_typing.py +118 -0
  26. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_revocation.py +389 -38
  27. fastapi_m8-4.0.0/tests/test_route_audit.py +151 -0
  28. fastapi_m8-3.3.0/fastapi_m8/_deps.py +0 -231
  29. fastapi_m8-3.3.0/fastapi_m8/_revocation.py +0 -321
  30. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/.env.example +0 -0
  31. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/.gitattributes +0 -0
  32. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/.github/FUNDING.yml +0 -0
  33. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/.github/dependabot.yml +0 -0
  34. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/.github/workflows/CI.yaml +0 -0
  35. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/.github/workflows/PiPy.yml +0 -0
  36. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/.pydocstyle +0 -0
  37. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/LICENSE +0 -0
  38. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/SECURITY.md +0 -0
  39. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/constraints-all.txt +0 -0
  40. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/constraints.txt +0 -0
  41. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/fastapi_m8/_app.py +0 -0
  42. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/fastapi_m8/_async_stub.py +0 -0
  43. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/fastapi_m8/_engine.py +0 -0
  44. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/fastapi_m8/_events.py +0 -0
  45. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/fastapi_m8/_health.py +0 -0
  46. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/fastapi_m8/_internal_auth.py +0 -0
  47. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/fastapi_m8/scripts/__init__.py +0 -0
  48. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/fastapi_m8/scripts/pre_start.py +0 -0
  49. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/__init__.py +0 -0
  50. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/conftest.py +0 -0
  51. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_app.py +0 -0
  52. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_app_extra.py +0 -0
  53. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_async_stub.py +0 -0
  54. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_ci_policy.py +0 -0
  55. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_config.py +0 -0
  56. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_config_file_secrets.py +0 -0
  57. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_engine.py +0 -0
  58. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_event_signing_gate.py +0 -0
  59. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_events.py +0 -0
  60. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_health.py +0 -0
  61. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_host_header_routing.py +0 -0
  62. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_internal_auth.py +0 -0
  63. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_meta.py +0 -0
  64. {fastapi_m8-3.3.0 → fastapi_m8-4.0.0}/tests/test_pre_start.py +0 -0
@@ -27,3 +27,5 @@ exclude_paths:
27
27
  # Codacy's prose analyzer misclassifies a security policy doc (flags standard
28
28
  # security acronyms such as CA/TTL/JTI as "undefined" on every revision).
29
29
  - "SECURITY.md"
30
+ - "AGENTS.md"
31
+ - "CLAUDE.md"
File without changes
@@ -215,7 +215,4 @@ marimo/_lsp/
215
215
  __marimo__/
216
216
 
217
217
  # Streamlit
218
- .streamlit/secrets.toml
219
-
220
- Claude.md
221
- Agents.md
218
+ .streamlit/secrets.toml
@@ -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
- ## [Unreleased]
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.3.0
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]<3.0.0,>=2.1.1
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.get_current_active_admin / _superuser` |
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, AuthStreamEvent
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=on_auth_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 |