mic-struct 0.0.1__py3-none-any.whl

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 (134) hide show
  1. mic/__init__.py +9 -0
  2. mic/_lazy.py +80 -0
  3. mic/auth/__init__.py +43 -0
  4. mic/auth/revoke_blacklist.py +247 -0
  5. mic/auth/service_auth.py +317 -0
  6. mic/authz/__init__.py +60 -0
  7. mic/authz/base.py +44 -0
  8. mic/authz/errors.py +27 -0
  9. mic/authz/in_memory.py +91 -0
  10. mic/authz/opa.py +131 -0
  11. mic/authz/openfga.py +167 -0
  12. mic/cache/__init__.py +31 -0
  13. mic/cache/backends.py +185 -0
  14. mic/cache/cluster/__init__.py +35 -0
  15. mic/cache/cluster/consistent_hash.py +116 -0
  16. mic/cache/cluster/redis_cluster.py +191 -0
  17. mic/cli/__init__.py +24 -0
  18. mic/cli/builder.py +176 -0
  19. mic/cli/spec.py +68 -0
  20. mic/client/__init__.py +26 -0
  21. mic/client/async_service_client.py +210 -0
  22. mic/client/service_client.py +229 -0
  23. mic/contracts/__init__.py +15 -0
  24. mic/contracts/envelope.py +45 -0
  25. mic/contracts/strict_schema.py +48 -0
  26. mic/datastorage/__init__.py +98 -0
  27. mic/datastorage/base.py +316 -0
  28. mic/datastorage/document/__init__.py +14 -0
  29. mic/datastorage/document/engine.py +166 -0
  30. mic/datastorage/document/in_memory.py +104 -0
  31. mic/datastorage/graph/__init__.py +12 -0
  32. mic/datastorage/graph/engine.py +131 -0
  33. mic/datastorage/keyvalue/__init__.py +11 -0
  34. mic/datastorage/keyvalue/in_memory.py +65 -0
  35. mic/datastorage/sql/__init__.py +10 -0
  36. mic/datastorage/sql/engine.py +152 -0
  37. mic/eventbus/__init__.py +70 -0
  38. mic/eventbus/base.py +54 -0
  39. mic/eventbus/in_memory.py +81 -0
  40. mic/eventbus/redis_streams.py +168 -0
  41. mic/fastapi/__init__.py +79 -0
  42. mic/fastapi/_resolve.py +86 -0
  43. mic/fastapi/middlewares.py +673 -0
  44. mic/fastapi/router.py +186 -0
  45. mic/grpc/__init__.py +75 -0
  46. mic/grpc/client.py +175 -0
  47. mic/grpc/errors.py +110 -0
  48. mic/grpc/interceptors.py +183 -0
  49. mic/grpc/rpc_spec.py +125 -0
  50. mic/grpc/server.py +56 -0
  51. mic/grpc/spec.py +42 -0
  52. mic/healthcheck/__init__.py +40 -0
  53. mic/healthcheck/readiness.py +239 -0
  54. mic/http/__init__.py +40 -0
  55. mic/http/api_key.py +120 -0
  56. mic/http/auth_gate.py +335 -0
  57. mic/http/basic_auth.py +114 -0
  58. mic/http/bearer.py +67 -0
  59. mic/http/correlation.py +169 -0
  60. mic/http/csrf.py +173 -0
  61. mic/http/request_context.py +55 -0
  62. mic/http/route_spec.py +140 -0
  63. mic/idempotency/__init__.py +77 -0
  64. mic/idempotency/store.py +716 -0
  65. mic/litestar/__init__.py +64 -0
  66. mic/litestar/_resolve.py +86 -0
  67. mic/litestar/middlewares.py +503 -0
  68. mic/litestar/router.py +241 -0
  69. mic/locking/__init__.py +68 -0
  70. mic/locking/base.py +51 -0
  71. mic/locking/in_memory.py +64 -0
  72. mic/locking/lock_handle.py +26 -0
  73. mic/locking/redis_backend.py +116 -0
  74. mic/locking/scope.py +58 -0
  75. mic/model/__init__.py +21 -0
  76. mic/model/errors.py +130 -0
  77. mic/observability/__init__.py +208 -0
  78. mic/observability/caller.py +102 -0
  79. mic/observability/circuit_breaker_metrics.py +111 -0
  80. mic/observability/log_context_middleware.py +140 -0
  81. mic/observability/logging.py +442 -0
  82. mic/observability/metrics.py +105 -0
  83. mic/observability/metrics_backend.py +656 -0
  84. mic/observability/metrics_guard.py +185 -0
  85. mic/observability/redact_logs.py +101 -0
  86. mic/observability/redact_traces.py +115 -0
  87. mic/observability/sentry.py +98 -0
  88. mic/observability/tracing.py +207 -0
  89. mic/observability/tracing_middleware.py +142 -0
  90. mic/outbox/__init__.py +111 -0
  91. mic/outbox/base.py +161 -0
  92. mic/outbox/dispatcher.py +345 -0
  93. mic/outbox/event.py +117 -0
  94. mic/outbox/in_memory.py +225 -0
  95. mic/outbox/sanitize.py +130 -0
  96. mic/outbox/sql.py +412 -0
  97. mic/py.typed +0 -0
  98. mic/queue/__init__.py +71 -0
  99. mic/queue/base.py +99 -0
  100. mic/queue/in_memory.py +87 -0
  101. mic/queue/kafka.py +120 -0
  102. mic/queue/nats.py +161 -0
  103. mic/ratelimit/__init__.py +89 -0
  104. mic/ratelimit/base.py +64 -0
  105. mic/ratelimit/in_memory.py +97 -0
  106. mic/ratelimit/middleware.py +138 -0
  107. mic/ratelimit/redis_backend.py +240 -0
  108. mic/ratelimit/spec.py +385 -0
  109. mic/read_models/__init__.py +64 -0
  110. mic/read_models/counter.py +78 -0
  111. mic/read_models/leaderboard.py +92 -0
  112. mic/read_models/membership_set.py +86 -0
  113. mic/read_models/timeline.py +97 -0
  114. mic/read_models/unique_count.py +73 -0
  115. mic/realtime/__init__.py +134 -0
  116. mic/realtime/auth.py +103 -0
  117. mic/realtime/backend.py +129 -0
  118. mic/realtime/endpoint.py +140 -0
  119. mic/realtime/redis_streams.py +141 -0
  120. mic/realtime/room.py +64 -0
  121. mic/resilience/__init__.py +44 -0
  122. mic/resilience/circuit_breaker.py +301 -0
  123. mic/response_cache/__init__.py +100 -0
  124. mic/response_cache/_stampede.py +90 -0
  125. mic/response_cache/_storage.py +109 -0
  126. mic/response_cache/middleware.py +289 -0
  127. mic/response_cache/rule.py +106 -0
  128. mic/shard/__init__.py +63 -0
  129. mic/shard/selector.py +145 -0
  130. mic/shard/session_factory.py +99 -0
  131. mic_struct-0.0.1.dist-info/METADATA +435 -0
  132. mic_struct-0.0.1.dist-info/RECORD +134 -0
  133. mic_struct-0.0.1.dist-info/WHEEL +4 -0
  134. mic_struct-0.0.1.dist-info/licenses/LICENSE +21 -0
mic/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """mic-struct — boilerplate / framework MIC pour l'écosystème microservices.
2
+
3
+ Cf. README.md et CONTRIBUTING.md.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ __version__ = "0.0.1"
9
+ __all__ = ["__version__"]
mic/_lazy.py ADDED
@@ -0,0 +1,80 @@
1
+ """Lazy package re-exports (PEP 562) — keeps ``import mic.X`` cheap and
2
+ import-safe even when the optional extra for X isn't installed.
3
+
4
+ Problem
5
+ -------
6
+ A package ``__init__.py`` that eagerly does
7
+ ``from mic.X.submodule import Symbol`` forces ``import mic.X`` to import
8
+ ``submodule`` at package-load time. If that submodule imports an optional
9
+ dependency at module top-level (``import prometheus_client``,
10
+ ``import httpx``, ``import grpc``, …), then ``import mic.X`` **crashes**
11
+ for anyone who ran ``pip install mic-struct`` without the ``[X]`` extra —
12
+ even if they only wanted a stdlib-only symbol from another submodule.
13
+
14
+ Fix
15
+ ---
16
+ Defer the submodule import to first attribute access via PEP 562
17
+ ``__getattr__``. ``import mic.X`` only runs ``__init__.py`` itself; the
18
+ heavy submodule loads when (and only when) a symbol from it is used.
19
+ If the optional dependency is missing at that point, the ``ImportError``
20
+ is re-raised with a clear ``pip install 'mic-struct[X]'`` hint.
21
+
22
+ This keeps the established "Philosophy B" of the codebase consistent:
23
+ *importing a package never fails for a missing optional dependency —
24
+ only actually using the dependency-backed symbol does.*
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import importlib
30
+ from collections.abc import Callable
31
+ from typing import Any
32
+
33
+ #: Une entrée d'export lazy : ``(chemin du sous-module, nom de l'extra | None)``.
34
+ #: ``extra=None`` = sous-module sans dépendance optionnelle (stdlib only) —
35
+ #: le lazy reste utile pour ne pas charger inutilement, mais pas de
36
+ #: message "install extra" à émettre.
37
+ LazyExport = tuple[str, str | None]
38
+
39
+
40
+ def make_lazy_getattr(
41
+ *,
42
+ package: str,
43
+ exports: dict[str, LazyExport],
44
+ fallback: Callable[[str], Any] | None = None,
45
+ ) -> Callable[[str], Any]:
46
+ """Construit un ``__getattr__`` PEP 562 pour un ``__init__.py`` de package.
47
+
48
+ Args:
49
+ package: le ``__name__`` du package (pour les messages d'erreur).
50
+ exports: ``{symbole: (sous-module, extra | None)}``. Le sous-module
51
+ est importé à la demande au premier accès du symbole.
52
+ fallback: handler optionnel essayé quand le nom n'est **pas** dans
53
+ ``exports`` (ex: alias deprecated). Doit lever ``AttributeError``
54
+ s'il ne sait pas résoudre — sinon la vraie ``AttributeError``
55
+ Python serait masquée.
56
+
57
+ Returns:
58
+ Une fonction ``__getattr__(name) -> Any`` à assigner au niveau
59
+ module dans le ``__init__.py`` du package.
60
+ """
61
+
62
+ def __getattr__(name: str) -> Any:
63
+ entry = exports.get(name)
64
+ if entry is None:
65
+ if fallback is not None:
66
+ return fallback(name)
67
+ raise AttributeError(f"module {package!r} has no attribute {name!r}")
68
+ module_path, extra = entry
69
+ try:
70
+ module = importlib.import_module(module_path)
71
+ except ImportError as exc:
72
+ if extra is not None:
73
+ raise ImportError(
74
+ f"{package}.{name} requires the optional [{extra}] extra. "
75
+ f"Install it with: pip install 'mic-struct[{extra}]'"
76
+ ) from exc
77
+ raise
78
+ return getattr(module, name)
79
+
80
+ return __getattr__
mic/auth/__init__.py ADDED
@@ -0,0 +1,43 @@
1
+ """Service-to-service auth — JWT HS256 audience-bound.
2
+
3
+ - Émetteur signe avec un secret partagé (HS256)
4
+ - Audience-bound (chaque token est destiné à UN service)
5
+ - Double-key support pour rotation zéro-downtime : le verifier accepte
6
+ N et N-1 pendant la fenêtre de transition.
7
+
8
+ Import lazy (PEP 562) : ``import mic.auth`` ne charge PAS ``pyjwt``.
9
+ Les symboles tirent ``service_auth`` (donc ``jwt``) au premier accès —
10
+ si l'extra ``[auth]`` n'est pas installé, l'erreur est claire. Cf.
11
+ :mod:`mic._lazy`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import TYPE_CHECKING
17
+
18
+ from mic._lazy import LazyExport, make_lazy_getattr
19
+
20
+ if TYPE_CHECKING: # pragma: no cover — résolution de types only, pas de coût runtime
21
+ from mic.auth.revoke_blacklist import RevokeBlacklist
22
+ from mic.auth.service_auth import (
23
+ ServiceAuthClaims,
24
+ ServiceAuthSigner,
25
+ ServiceAuthVerifier,
26
+ )
27
+
28
+ _EXPORTS: dict[str, LazyExport] = {
29
+ "ServiceAuthClaims": ("mic.auth.service_auth", "auth"),
30
+ "ServiceAuthSigner": ("mic.auth.service_auth", "auth"),
31
+ "ServiceAuthVerifier": ("mic.auth.service_auth", "auth"),
32
+ # RevokeBlacklist ne tire que mic.cache (core) — pas d'extra requis.
33
+ "RevokeBlacklist": ("mic.auth.revoke_blacklist", None),
34
+ }
35
+
36
+ __getattr__ = make_lazy_getattr(package=__name__, exports=_EXPORTS)
37
+
38
+ __all__ = [
39
+ "RevokeBlacklist",
40
+ "ServiceAuthClaims",
41
+ "ServiceAuthSigner",
42
+ "ServiceAuthVerifier",
43
+ ]
@@ -0,0 +1,247 @@
1
+ """``RevokeBlacklist`` — révocation de JWT court-TTL via :class:`CacheBackend`.
2
+
3
+ Le pattern *JWT stateless* a une faiblesse intrinsèque : un token signé
4
+ reste valide jusqu'à ``exp``, on ne peut pas l'invalider unilatéralement.
5
+ Pour les use cases qui ont besoin de révocation immédiate (logout, vol
6
+ de token, "logout everywhere"), ce module expose deux mécanismes
7
+ complémentaires backed par n'importe quel :class:`CacheBackend` :
8
+
9
+ 1. **Per-jti** — :meth:`revoke_jti` / :meth:`is_jti_revoked`. Ajoute un
10
+ ``jti`` spécifique à une blacklist. TTL = lifetime restant du token
11
+ au moment de la révocation (auto-cleanup à ``exp``). Usage : logout
12
+ d'un device, rotation refresh token (l'ancien ``jti`` est révoqué).
13
+
14
+ 2. **Per-subject (revoke-epoch)** — :meth:`revoke_all_for_subject` /
15
+ :meth:`is_subject_revoked_since`. Stocke un timestamp "tous les
16
+ tokens de ce sujet émis AVANT ce moment sont révoqués". Usage :
17
+ "logout me everywhere", changement de mot de passe, compromission
18
+ compte. TTL = max lifetime des tokens longue durée du sujet
19
+ (typiquement refresh TTL).
20
+
21
+ L'epoch-pattern bat la SET-per-user d'``jti`` actifs :
22
+
23
+ - O(1) clé Redis par sujet (vs N entrées à maintenir et nettoyer).
24
+ - Auto-cleanup natif via TTL (vs scan périodique pour purger les
25
+ jtis expirés d'une SET, Redis ne fait pas TTL par élément).
26
+ - Couvre access + refresh + tokens futurs émis avant le epoch — pas
27
+ besoin d'énumérer.
28
+
29
+ L'API reste sync (cohérent avec :class:`CacheBackend` et
30
+ :class:`IdempotencyStore`). Un consumer async wrap via
31
+ ``asyncio.to_thread`` si besoin.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import time
37
+ from collections.abc import Callable
38
+
39
+ from mic.cache.backends import CacheBackend, CacheError
40
+
41
+ #: Préfixe par défaut pour les clés ``jti`` révoqués individuellement.
42
+ #: Namespace distinct du subject-epoch pour éviter toute collision
43
+ #: si le caller utilise des sub == jti par accident.
44
+ _DEFAULT_JTI_NAMESPACE = "revoke:jti:"
45
+
46
+ #: Préfixe par défaut pour le timestamp d'epoch de révocation par sujet.
47
+ _DEFAULT_SUBJECT_NAMESPACE = "revoke:sub:"
48
+
49
+ #: Valeur sentinel stockée pour les entrées per-jti — seule l'existence
50
+ #: de la clé compte, le payload est ignoré. ``b"1"`` minimise la mémoire
51
+ #: Redis (1 byte vs un timestamp).
52
+ _JTI_PRESENT = b"1"
53
+
54
+
55
+ class RevokeBlacklist:
56
+ """Tracker de révocation pour JWT court-TTL.
57
+
58
+ Args:
59
+ cache: backend cache (typiquement :class:`RedisCache` en prod,
60
+ :class:`InMemoryCache` en tests). Le backend est partagé
61
+ avec d'éventuels autres consommateurs — préfixe-isolé via
62
+ ``jti_namespace`` et ``subject_namespace``.
63
+ jti_namespace: préfixe des clés per-jti (default ``"revoke:jti:"``).
64
+ subject_namespace: préfixe des clés per-subject (default
65
+ ``"revoke:sub:"``).
66
+ time_fn: source d'horloge en secondes Unix. Injectable pour tests.
67
+ Default ``lambda: int(time.time())`` (wall-clock — l'epoch
68
+ DOIT être comparable à ``iat`` du JWT qui est aussi wall-clock).
69
+
70
+ ## Pourquoi pas ``time.monotonic`` ?
71
+
72
+ L'epoch stocké doit être comparable au claim ``iat`` du JWT, qui est
73
+ un timestamp Unix wall-clock (cf. RFC 7519 §4.1.6). Utiliser
74
+ ``time.monotonic`` cassserait la comparaison ``iat < epoch`` : les
75
+ deux horloges n'ont pas la même origine. Le coût (clock-skew entre
76
+ replicas) est négligeable ici : un skew de quelques secondes ne change
77
+ pas la sémantique de "logout everywhere".
78
+
79
+ ## Cohérence avec :class:`ServiceAuthVerifier`
80
+
81
+ Le verifier `ServiceAuthVerifier` lui-même n'appelle PAS
82
+ ``RevokeBlacklist`` — c'est au caller (typiquement un middleware
83
+ d'auth applicatif) de combiner les deux : vérifier la signature
84
+ via le verifier PUIS interroger la blacklist. Garder les
85
+ responsabilités séparées permet :
86
+
87
+ - Au verifier de rester stateless (pas de dep Redis).
88
+ - À la blacklist d'être optionnelle (services qui n'ont pas besoin
89
+ de révocation immédiate).
90
+ """
91
+
92
+ def __init__(
93
+ self,
94
+ *,
95
+ cache: CacheBackend,
96
+ jti_namespace: str = _DEFAULT_JTI_NAMESPACE,
97
+ subject_namespace: str = _DEFAULT_SUBJECT_NAMESPACE,
98
+ time_fn: Callable[[], int] = lambda: int(time.time()),
99
+ ) -> None:
100
+ self._cache = cache
101
+ self._jti_namespace = jti_namespace
102
+ self._subject_namespace = subject_namespace
103
+ self._time_fn = time_fn
104
+
105
+ # ------------------------------------------------------------------
106
+ # Per-jti revocation
107
+ # ------------------------------------------------------------------
108
+
109
+ def revoke_jti(self, jti: str, *, ttl_seconds: int) -> None:
110
+ """Marque un ``jti`` comme révoqué pour ``ttl_seconds`` secondes.
111
+
112
+ ``ttl_seconds`` doit être le **lifetime restant** du token au
113
+ moment de la révocation. Une fois le TTL écoulé, la clé est
114
+ nettoyée par le backend (Redis EXPIRE) et le ``jti`` est
115
+ considéré comme à nouveau "non révoqué" — mais c'est OK : à ce
116
+ moment-là le token lui-même est expiré (``exp`` passée), donc le
117
+ verifier le rejette de toute façon. La blacklist ne sert qu'à
118
+ couvrir l'écart entre "révocation" et "expiration naturelle".
119
+
120
+ Args:
121
+ jti: identifiant unique du token (claim JWT ``jti``).
122
+ ttl_seconds: durée de vie restante du token (≥1).
123
+
124
+ Raises:
125
+ ValueError: si ``jti`` est vide.
126
+ CacheError: si ``ttl_seconds < 1`` (propagé du backend) ou
127
+ si le backend est indisponible.
128
+ """
129
+ if not jti:
130
+ raise ValueError("RevokeBlacklist.revoke_jti(jti=...) must be non-empty")
131
+ # ttl_seconds est validé par le backend (>= 1). On laisse le
132
+ # CacheError remonter pour cohérence avec les autres consumers.
133
+ self._cache.set(self._jti_key(jti), _JTI_PRESENT, ttl_seconds=ttl_seconds)
134
+
135
+ def is_jti_revoked(self, jti: str) -> bool:
136
+ """``True`` si le ``jti`` est dans la blacklist active.
137
+
138
+ Lookup O(1) en Redis. Le caller (middleware d'auth) appelle
139
+ cette méthode APRÈS la vérification de signature du
140
+ :class:`ServiceAuthVerifier` — pas la peine d'interroger Redis
141
+ sur un token invalide cryptographiquement.
142
+
143
+ Args:
144
+ jti: identifiant du token à tester.
145
+
146
+ Returns:
147
+ ``True`` si révoqué (caller rejette → 401), ``False`` sinon
148
+ (caller accepte).
149
+
150
+ Raises:
151
+ ValueError: si ``jti`` est vide.
152
+ CacheError: si le backend est indisponible. Le caller décide
153
+ de la politique : fail-closed (rejeter, sécurité d'abord)
154
+ ou fail-open (accepter, disponibilité d'abord). Recommandé
155
+ fail-closed pour les use cases sécurité-critiques.
156
+ """
157
+ if not jti:
158
+ raise ValueError("RevokeBlacklist.is_jti_revoked(jti=...) must be non-empty")
159
+ return self._cache.get(self._jti_key(jti)) is not None
160
+
161
+ # ------------------------------------------------------------------
162
+ # Per-subject mass revocation (revoke-epoch)
163
+ # ------------------------------------------------------------------
164
+
165
+ def revoke_all_for_subject(self, subject: str, *, ttl_seconds: int) -> None:
166
+ """Stocke un epoch "tous les tokens du sujet émis AVANT now sont révoqués".
167
+
168
+ Sémantique "logout everywhere" : invalide TOUS les tokens (access
169
+ + refresh) du sujet émis avant l'instant courant. Les tokens
170
+ émis APRÈS (typiquement après un nouveau login) sont valides.
171
+
172
+ ``ttl_seconds`` doit être >= au TTL max des tokens longue durée
173
+ du sujet (typiquement refresh token TTL = ~30j). Au-delà, l'epoch
174
+ est garbage-collected mais c'est OK : à ce moment-là, le token
175
+ le plus ancien encore en circulation est expiré naturellement.
176
+
177
+ Args:
178
+ subject: identifiant du sujet (``sub`` claim JWT, typiquement
179
+ ``user_id``). Doit être non-vide.
180
+ ttl_seconds: durée de rétention de l'epoch (≥1, typiquement
181
+ = refresh token TTL).
182
+
183
+ Raises:
184
+ ValueError: si ``subject`` est vide.
185
+ CacheError: si le backend est indisponible.
186
+
187
+ Idempotent : appel répété écrase l'epoch avec ``now`` courant
188
+ (re-clic "logout everywhere" → re-révoque tout token émis
189
+ entre les deux clics).
190
+ """
191
+ if not subject:
192
+ raise ValueError(
193
+ "RevokeBlacklist.revoke_all_for_subject(subject=...) must be non-empty"
194
+ )
195
+ epoch = self._time_fn()
196
+ payload = str(epoch).encode("ascii")
197
+ self._cache.set(self._subject_key(subject), payload, ttl_seconds=ttl_seconds)
198
+
199
+ def is_subject_revoked_since(self, subject: str, *, issued_at: int) -> bool:
200
+ """``True`` si le sujet a un epoch de révocation et ``issued_at < epoch``.
201
+
202
+ Le caller (middleware d'auth) appelle cette méthode avec le
203
+ claim ``iat`` du token vérifié. Si l'epoch existe et que
204
+ ``iat < epoch`` → token émis avant la révocation → rejeté.
205
+ Si pas d'epoch (clé absente ou expirée TTL) → ``False`` (jamais
206
+ révoqué pour ce sujet, ou révocation trop vieille pour matter).
207
+
208
+ Args:
209
+ subject: identifiant du sujet (``sub`` claim).
210
+ issued_at: timestamp Unix du claim ``iat`` du token.
211
+
212
+ Returns:
213
+ ``True`` si le token doit être rejeté (caller → 401).
214
+
215
+ Raises:
216
+ ValueError: si ``subject`` est vide.
217
+ CacheError: si le backend est indisponible (cf. politique
218
+ fail-closed/fail-open documentée sur :meth:`is_jti_revoked`).
219
+ """
220
+ if not subject:
221
+ raise ValueError(
222
+ "RevokeBlacklist.is_subject_revoked_since(subject=...) must be non-empty"
223
+ )
224
+ raw = self._cache.get(self._subject_key(subject))
225
+ if raw is None:
226
+ return False
227
+ try:
228
+ epoch = int(raw.decode("ascii"))
229
+ except (UnicodeDecodeError, ValueError) as exc:
230
+ # Payload corrompu (cache empoisonné, collision de
231
+ # namespace). Fail-closed : on traite comme révoqué. La
232
+ # corruption est suffisamment anormale pour qu'on préfère
233
+ # forcer un re-login plutôt que d'accepter aveuglément.
234
+ raise CacheError(
235
+ f"RevokeBlacklist: corrupted subject epoch payload for {subject!r}: {raw!r}"
236
+ ) from exc
237
+ return issued_at < epoch
238
+
239
+ # ------------------------------------------------------------------
240
+ # Helpers internes
241
+ # ------------------------------------------------------------------
242
+
243
+ def _jti_key(self, jti: str) -> str:
244
+ return f"{self._jti_namespace}{jti}"
245
+
246
+ def _subject_key(self, subject: str) -> str:
247
+ return f"{self._subject_namespace}{subject}"