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.
- mic/__init__.py +9 -0
- mic/_lazy.py +80 -0
- mic/auth/__init__.py +43 -0
- mic/auth/revoke_blacklist.py +247 -0
- mic/auth/service_auth.py +317 -0
- mic/authz/__init__.py +60 -0
- mic/authz/base.py +44 -0
- mic/authz/errors.py +27 -0
- mic/authz/in_memory.py +91 -0
- mic/authz/opa.py +131 -0
- mic/authz/openfga.py +167 -0
- mic/cache/__init__.py +31 -0
- mic/cache/backends.py +185 -0
- mic/cache/cluster/__init__.py +35 -0
- mic/cache/cluster/consistent_hash.py +116 -0
- mic/cache/cluster/redis_cluster.py +191 -0
- mic/cli/__init__.py +24 -0
- mic/cli/builder.py +176 -0
- mic/cli/spec.py +68 -0
- mic/client/__init__.py +26 -0
- mic/client/async_service_client.py +210 -0
- mic/client/service_client.py +229 -0
- mic/contracts/__init__.py +15 -0
- mic/contracts/envelope.py +45 -0
- mic/contracts/strict_schema.py +48 -0
- mic/datastorage/__init__.py +98 -0
- mic/datastorage/base.py +316 -0
- mic/datastorage/document/__init__.py +14 -0
- mic/datastorage/document/engine.py +166 -0
- mic/datastorage/document/in_memory.py +104 -0
- mic/datastorage/graph/__init__.py +12 -0
- mic/datastorage/graph/engine.py +131 -0
- mic/datastorage/keyvalue/__init__.py +11 -0
- mic/datastorage/keyvalue/in_memory.py +65 -0
- mic/datastorage/sql/__init__.py +10 -0
- mic/datastorage/sql/engine.py +152 -0
- mic/eventbus/__init__.py +70 -0
- mic/eventbus/base.py +54 -0
- mic/eventbus/in_memory.py +81 -0
- mic/eventbus/redis_streams.py +168 -0
- mic/fastapi/__init__.py +79 -0
- mic/fastapi/_resolve.py +86 -0
- mic/fastapi/middlewares.py +673 -0
- mic/fastapi/router.py +186 -0
- mic/grpc/__init__.py +75 -0
- mic/grpc/client.py +175 -0
- mic/grpc/errors.py +110 -0
- mic/grpc/interceptors.py +183 -0
- mic/grpc/rpc_spec.py +125 -0
- mic/grpc/server.py +56 -0
- mic/grpc/spec.py +42 -0
- mic/healthcheck/__init__.py +40 -0
- mic/healthcheck/readiness.py +239 -0
- mic/http/__init__.py +40 -0
- mic/http/api_key.py +120 -0
- mic/http/auth_gate.py +335 -0
- mic/http/basic_auth.py +114 -0
- mic/http/bearer.py +67 -0
- mic/http/correlation.py +169 -0
- mic/http/csrf.py +173 -0
- mic/http/request_context.py +55 -0
- mic/http/route_spec.py +140 -0
- mic/idempotency/__init__.py +77 -0
- mic/idempotency/store.py +716 -0
- mic/litestar/__init__.py +64 -0
- mic/litestar/_resolve.py +86 -0
- mic/litestar/middlewares.py +503 -0
- mic/litestar/router.py +241 -0
- mic/locking/__init__.py +68 -0
- mic/locking/base.py +51 -0
- mic/locking/in_memory.py +64 -0
- mic/locking/lock_handle.py +26 -0
- mic/locking/redis_backend.py +116 -0
- mic/locking/scope.py +58 -0
- mic/model/__init__.py +21 -0
- mic/model/errors.py +130 -0
- mic/observability/__init__.py +208 -0
- mic/observability/caller.py +102 -0
- mic/observability/circuit_breaker_metrics.py +111 -0
- mic/observability/log_context_middleware.py +140 -0
- mic/observability/logging.py +442 -0
- mic/observability/metrics.py +105 -0
- mic/observability/metrics_backend.py +656 -0
- mic/observability/metrics_guard.py +185 -0
- mic/observability/redact_logs.py +101 -0
- mic/observability/redact_traces.py +115 -0
- mic/observability/sentry.py +98 -0
- mic/observability/tracing.py +207 -0
- mic/observability/tracing_middleware.py +142 -0
- mic/outbox/__init__.py +111 -0
- mic/outbox/base.py +161 -0
- mic/outbox/dispatcher.py +345 -0
- mic/outbox/event.py +117 -0
- mic/outbox/in_memory.py +225 -0
- mic/outbox/sanitize.py +130 -0
- mic/outbox/sql.py +412 -0
- mic/py.typed +0 -0
- mic/queue/__init__.py +71 -0
- mic/queue/base.py +99 -0
- mic/queue/in_memory.py +87 -0
- mic/queue/kafka.py +120 -0
- mic/queue/nats.py +161 -0
- mic/ratelimit/__init__.py +89 -0
- mic/ratelimit/base.py +64 -0
- mic/ratelimit/in_memory.py +97 -0
- mic/ratelimit/middleware.py +138 -0
- mic/ratelimit/redis_backend.py +240 -0
- mic/ratelimit/spec.py +385 -0
- mic/read_models/__init__.py +64 -0
- mic/read_models/counter.py +78 -0
- mic/read_models/leaderboard.py +92 -0
- mic/read_models/membership_set.py +86 -0
- mic/read_models/timeline.py +97 -0
- mic/read_models/unique_count.py +73 -0
- mic/realtime/__init__.py +134 -0
- mic/realtime/auth.py +103 -0
- mic/realtime/backend.py +129 -0
- mic/realtime/endpoint.py +140 -0
- mic/realtime/redis_streams.py +141 -0
- mic/realtime/room.py +64 -0
- mic/resilience/__init__.py +44 -0
- mic/resilience/circuit_breaker.py +301 -0
- mic/response_cache/__init__.py +100 -0
- mic/response_cache/_stampede.py +90 -0
- mic/response_cache/_storage.py +109 -0
- mic/response_cache/middleware.py +289 -0
- mic/response_cache/rule.py +106 -0
- mic/shard/__init__.py +63 -0
- mic/shard/selector.py +145 -0
- mic/shard/session_factory.py +99 -0
- mic_struct-0.0.1.dist-info/METADATA +435 -0
- mic_struct-0.0.1.dist-info/RECORD +134 -0
- mic_struct-0.0.1.dist-info/WHEEL +4 -0
- mic_struct-0.0.1.dist-info/licenses/LICENSE +21 -0
mic/auth/service_auth.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""ServiceAuthSigner + ServiceAuthVerifier — JWT HS256 audience-bound.
|
|
2
|
+
|
|
3
|
+
Convention (cf. ``service_auth_convention`` mémoire +
|
|
4
|
+
``runbooks/RUNBOOK_SECRETS_ROTATION.md`` sentinel) :
|
|
5
|
+
|
|
6
|
+
- Algorithme : HS256 uniquement (pas RS256 ; les services partagent
|
|
7
|
+
un secret commun gardé en OpenBao).
|
|
8
|
+
- Claims standard : ``iss`` (émetteur), ``aud`` (destinataire),
|
|
9
|
+
``iat``, ``exp``, ``sub`` optionnel.
|
|
10
|
+
- Double-key : ``ServiceAuthVerifier`` accepte une liste ordonnée
|
|
11
|
+
de secrets (``current`` puis ``previous``) pour permettre une
|
|
12
|
+
rotation sans downtime — cf. RUNBOOK_SECRETS_ROTATION.md procédure
|
|
13
|
+
générique étape 2-3.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import uuid
|
|
20
|
+
from datetime import UTC, datetime, timedelta
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import jwt
|
|
24
|
+
|
|
25
|
+
from mic.contracts import StrictSchema
|
|
26
|
+
from mic.model.errors import DomainError
|
|
27
|
+
|
|
28
|
+
_ALGORITHM = "HS256"
|
|
29
|
+
|
|
30
|
+
_logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
#: Claims standard que ``ServiceAuthSigner.sign`` met lui-même —
|
|
33
|
+
#: les overrider via ``extra_claims`` permettrait à un caller de forger
|
|
34
|
+
#: un ``aud`` arbitraire, faussement étendre ``exp``, falsifier ``iss``,
|
|
35
|
+
#: etc. On refuse explicitement (cf. ``sign(extra_claims=...)``).
|
|
36
|
+
_RESERVED_CLAIMS: frozenset[str] = frozenset({"iss", "aud", "iat", "exp", "sub", "jti", "nbf"})
|
|
37
|
+
|
|
38
|
+
# Message constant retourné dans tous les DomainError de verify. On NE
|
|
39
|
+
# leak PAS les détails PyJWT (str(last_error)) au caller — un attacker
|
|
40
|
+
# peut sinon distinguer "signature valide mais expirée" (= il a un
|
|
41
|
+
# secret valide juste périmé) vs "signature invalide" (= mauvais secret),
|
|
42
|
+
# oracle particulièrement utile pendant les fenêtres de rotation. Les
|
|
43
|
+
# détails (exc_info + issuer whitelist) sont loggés côté serveur via
|
|
44
|
+
# ``_logger.warning(..., exc_info=True)`` pour la diagnose ops.
|
|
45
|
+
_GENERIC_VERIFY_FAILURE_MESSAGE = "Token verification failed"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ServiceAuthClaims(StrictSchema):
|
|
49
|
+
"""Claims canoniques exposés par un service token.
|
|
50
|
+
|
|
51
|
+
``jti`` (RFC 7519 §4.1.7) est exposé optionnellement : il est
|
|
52
|
+
automatiquement injecté par :meth:`ServiceAuthSigner.sign` depuis
|
|
53
|
+
cette version, mais reste ``None`` quand on vérifie un token
|
|
54
|
+
émis par une version antérieure ou par un autre service qui ne
|
|
55
|
+
le propage pas. C'est OK : ``jti`` est une RECOMMENDED claim, pas
|
|
56
|
+
un REQUIRED claim — l'absence n'est pas une erreur de validation.
|
|
57
|
+
|
|
58
|
+
Pour les consommateurs qui font de la révocation per-token
|
|
59
|
+
(typiquement via :class:`mic.auth.RevokeBlacklist.revoke_jti`),
|
|
60
|
+
vérifier ``claims.jti is not None`` avant de l'utiliser : un
|
|
61
|
+
``None`` signale qu'on ne peut pas révoquer ce token
|
|
62
|
+
spécifiquement, seulement via la voie per-subject (revoke-epoch).
|
|
63
|
+
|
|
64
|
+
``extra`` porte les claims additionnels embedés par le signer via
|
|
65
|
+
``ServiceAuthSigner.sign(extra_claims=...)``. Use case : un service
|
|
66
|
+
qui propage ``profile_id`` aux côtés du ``sub`` standard pour
|
|
67
|
+
éviter un lookup DB aval. Dictionnaire vide quand le token n'embed
|
|
68
|
+
aucun claim non-standard. Les valeurs sont opaques pour mic — le
|
|
69
|
+
consumer décide leur sémantique.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
iss: str
|
|
73
|
+
aud: str
|
|
74
|
+
iat: int
|
|
75
|
+
exp: int
|
|
76
|
+
sub: str | None = None
|
|
77
|
+
jti: str | None = None
|
|
78
|
+
# Pydantic copie le default per-instance via Field — ``RUF012`` ne
|
|
79
|
+
# s'applique pas aux BaseModel. ``noqa`` cosmétique mais explicite.
|
|
80
|
+
extra: dict[str, Any] = {} # noqa: RUF012
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ServiceAuthSigner:
|
|
84
|
+
"""Signe des JWT HS256 audience-bound.
|
|
85
|
+
|
|
86
|
+
Une instance par couple (issuer, secret). L'audience est passée
|
|
87
|
+
à chaque appel ``sign()`` puisque le même service appelle souvent
|
|
88
|
+
plusieurs destinataires.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
*,
|
|
94
|
+
secret: str,
|
|
95
|
+
issuer: str,
|
|
96
|
+
token_ttl_seconds: int = 3600,
|
|
97
|
+
) -> None:
|
|
98
|
+
if not secret:
|
|
99
|
+
raise ValueError("ServiceAuthSigner.secret must be a non-empty string")
|
|
100
|
+
if not issuer:
|
|
101
|
+
raise ValueError("ServiceAuthSigner.issuer must be a non-empty string")
|
|
102
|
+
if token_ttl_seconds <= 0:
|
|
103
|
+
raise ValueError("ServiceAuthSigner.token_ttl_seconds must be > 0")
|
|
104
|
+
self._secret = secret
|
|
105
|
+
self._issuer = issuer
|
|
106
|
+
self._token_ttl = token_ttl_seconds
|
|
107
|
+
|
|
108
|
+
def sign(
|
|
109
|
+
self,
|
|
110
|
+
*,
|
|
111
|
+
audience: str,
|
|
112
|
+
subject: str | None = None,
|
|
113
|
+
jti: str | None = None,
|
|
114
|
+
extra_claims: dict[str, Any] | None = None,
|
|
115
|
+
) -> str:
|
|
116
|
+
"""Build a Bearer JWT signed with the configured secret.
|
|
117
|
+
|
|
118
|
+
Returns the encoded JWT string (no "Bearer " prefix — the
|
|
119
|
+
client adds it).
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
audience: ``aud`` claim — destinataire attendu (un service
|
|
123
|
+
par token).
|
|
124
|
+
subject: ``sub`` claim — identité propagée (typiquement
|
|
125
|
+
user_id ou email pour les tokens user-side).
|
|
126
|
+
jti: ``jti`` claim (RFC 7519 §4.1.7) — identifiant unique
|
|
127
|
+
du token. **Auto-généré** via ``uuid.uuid4().hex`` (128
|
|
128
|
+
bits d'entropie) si ``None``, ce qui est le cas standard.
|
|
129
|
+
Override uniquement pour les tests qui ont besoin de
|
|
130
|
+
jtis déterministes, ou pour les use cases avancés
|
|
131
|
+
(ex. propager un trace-id en jti). Si fourni, doit être
|
|
132
|
+
non-vide.
|
|
133
|
+
extra_claims: claims additionnels à embedder dans le JWT.
|
|
134
|
+
Use case typique : propager une identité applicative
|
|
135
|
+
secondaire (``profile_id``, ``tenant_id``, ``role``)
|
|
136
|
+
aux côtés du ``sub`` standard pour éviter un lookup DB
|
|
137
|
+
aval. Les claims standard (``iss``, ``aud``, ``iat``,
|
|
138
|
+
``exp``, ``sub``, ``jti``) ne peuvent PAS être
|
|
139
|
+
overridées via ce paramètre — un override silencieux
|
|
140
|
+
permettrait à un caller de forger un ``aud`` arbitraire
|
|
141
|
+
ou de faussement étendre l'``exp``. Une tentative lève
|
|
142
|
+
``ValueError``.
|
|
143
|
+
|
|
144
|
+
Le ``jti`` permet aux consumers de faire de la révocation
|
|
145
|
+
per-token via :class:`mic.auth.RevokeBlacklist` (``revoke_jti``,
|
|
146
|
+
``is_jti_revoked``) — usage typique : logout-single-device en
|
|
147
|
+
ajoutant l'ancien ``jti`` à la blacklist à TTL = lifetime
|
|
148
|
+
restant du token.
|
|
149
|
+
"""
|
|
150
|
+
if not audience:
|
|
151
|
+
raise ValueError("ServiceAuthSigner.sign(audience=...) must be non-empty")
|
|
152
|
+
if jti is not None and not jti:
|
|
153
|
+
raise ValueError("ServiceAuthSigner.sign(jti=...) must be non-empty when provided")
|
|
154
|
+
if extra_claims is not None:
|
|
155
|
+
reserved = _RESERVED_CLAIMS & extra_claims.keys()
|
|
156
|
+
if reserved:
|
|
157
|
+
raise ValueError(
|
|
158
|
+
f"ServiceAuthSigner.sign(extra_claims=...) cannot override "
|
|
159
|
+
f"reserved claim(s): {sorted(reserved)}"
|
|
160
|
+
)
|
|
161
|
+
now = datetime.now(UTC)
|
|
162
|
+
payload: dict[str, Any] = {
|
|
163
|
+
"iss": self._issuer,
|
|
164
|
+
"aud": audience,
|
|
165
|
+
"iat": int(now.timestamp()),
|
|
166
|
+
"exp": int((now + timedelta(seconds=self._token_ttl)).timestamp()),
|
|
167
|
+
"jti": jti if jti is not None else uuid.uuid4().hex,
|
|
168
|
+
}
|
|
169
|
+
if subject is not None:
|
|
170
|
+
payload["sub"] = subject
|
|
171
|
+
if extra_claims:
|
|
172
|
+
payload.update(extra_claims)
|
|
173
|
+
return jwt.encode(payload, self._secret, algorithm=_ALGORITHM)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class ServiceAuthVerifier:
|
|
177
|
+
"""Vérifie les JWT HS256 destinés à ce service.
|
|
178
|
+
|
|
179
|
+
Supporte la rotation à zéro-downtime via une **liste ordonnée**
|
|
180
|
+
de secrets : le verifier essaye le ``current`` d'abord, puis les
|
|
181
|
+
``previous`` un par un. Tant qu'au moins un secret valide
|
|
182
|
+
correspond ET que les claims passent (audience, issuer, expiration),
|
|
183
|
+
le token est accepté.
|
|
184
|
+
|
|
185
|
+
L'``audience`` attendue est fixe par instance (= app name de ce
|
|
186
|
+
service). Les ``allowed_issuers`` est la liste des émetteurs
|
|
187
|
+
légitimes (whitelist).
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
def __init__(
|
|
191
|
+
self,
|
|
192
|
+
*,
|
|
193
|
+
secrets: tuple[str, ...],
|
|
194
|
+
audience: str,
|
|
195
|
+
allowed_issuers: tuple[str, ...],
|
|
196
|
+
leeway_seconds: int = 30,
|
|
197
|
+
) -> None:
|
|
198
|
+
# leeway 30s par défaut = tolérance NTP-skew typique entre 2
|
|
199
|
+
# services. Sans ça, une dérive d'1s entre signer et verifier
|
|
200
|
+
# rejette le token (``iat`` dans le futur OU ``exp`` expirée).
|
|
201
|
+
# 30s = compromis : couvre 99 % des skews observés en cluster
|
|
202
|
+
# sans ouvrir une fenêtre de replay critique.
|
|
203
|
+
if not secrets:
|
|
204
|
+
raise ValueError("ServiceAuthVerifier.secrets must be non-empty tuple")
|
|
205
|
+
if any(not s for s in secrets):
|
|
206
|
+
raise ValueError("ServiceAuthVerifier.secrets must not contain empty strings")
|
|
207
|
+
if not audience:
|
|
208
|
+
raise ValueError("ServiceAuthVerifier.audience must be non-empty")
|
|
209
|
+
if not allowed_issuers:
|
|
210
|
+
raise ValueError("ServiceAuthVerifier.allowed_issuers must be non-empty tuple")
|
|
211
|
+
if leeway_seconds < 0:
|
|
212
|
+
raise ValueError("ServiceAuthVerifier.leeway_seconds must be >= 0")
|
|
213
|
+
self._secrets = secrets
|
|
214
|
+
self._audience = audience
|
|
215
|
+
self._allowed_issuers = set(allowed_issuers)
|
|
216
|
+
self._leeway = leeway_seconds
|
|
217
|
+
|
|
218
|
+
def verify(self, token: str) -> ServiceAuthClaims:
|
|
219
|
+
"""Vérifie le token et retourne les claims parsés.
|
|
220
|
+
|
|
221
|
+
Raises ``DomainError`` (code ``service_auth.invalid_*``) si :
|
|
222
|
+
|
|
223
|
+
- signature ne match aucun secret (current ou previous)
|
|
224
|
+
- exp dépassée (au-delà du leeway)
|
|
225
|
+
- aud != self.audience
|
|
226
|
+
- iss not in allowed_issuers
|
|
227
|
+
- structure du token invalide
|
|
228
|
+
|
|
229
|
+
**Timing-safe iteration** : tous les secrets de la
|
|
230
|
+
liste sont essayés systématiquement, sans early break sur le
|
|
231
|
+
premier match. Sans ça, un attacker qui mesure la latence à la
|
|
232
|
+
μs peut deviner la **position** du secret valide (``current``
|
|
233
|
+
vs ``previous``) → side-channel mineur mais réel pendant la
|
|
234
|
+
fenêtre de rotation. Coût : 1 HMAC supplémentaire par requête
|
|
235
|
+
(≪ 1 μs) — négligeable.
|
|
236
|
+
"""
|
|
237
|
+
if not token:
|
|
238
|
+
raise DomainError(
|
|
239
|
+
code="service_auth.invalid_token",
|
|
240
|
+
message="Empty token",
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
payload: dict[str, Any] | None = None
|
|
244
|
+
last_error: jwt.PyJWTError | None = None
|
|
245
|
+
for secret in self._secrets:
|
|
246
|
+
try:
|
|
247
|
+
candidate = jwt.decode(
|
|
248
|
+
token,
|
|
249
|
+
secret,
|
|
250
|
+
algorithms=[_ALGORITHM],
|
|
251
|
+
audience=self._audience,
|
|
252
|
+
leeway=self._leeway,
|
|
253
|
+
options={"require": ["iss", "aud", "iat", "exp"]},
|
|
254
|
+
)
|
|
255
|
+
except jwt.PyJWTError as exc:
|
|
256
|
+
last_error = exc
|
|
257
|
+
continue
|
|
258
|
+
# Garde le PREMIER payload valide trouvé, mais on continue
|
|
259
|
+
# d'itérer pour ne pas leak la position via timing.
|
|
260
|
+
if payload is None:
|
|
261
|
+
payload = candidate
|
|
262
|
+
|
|
263
|
+
if payload is None:
|
|
264
|
+
# Toutes les secrets ont échoué. On retourne au caller un
|
|
265
|
+
# ``code`` catégorisé (contrat public — utile pour l'UX :
|
|
266
|
+
# ``expired`` vs ``invalid_signature``) mais **aucun message
|
|
267
|
+
# détaillé ni stacktrace** : ce serait un oracle pour
|
|
268
|
+
# l'attacker (cf. constante ``_GENERIC_VERIFY_FAILURE_MESSAGE``).
|
|
269
|
+
# La cause précise (str(last_error)) est loggée côté serveur
|
|
270
|
+
# pour la diagnose ops.
|
|
271
|
+
code = _classify_error(last_error)
|
|
272
|
+
_logger.warning(
|
|
273
|
+
"ServiceAuthVerifier: token verification failed (code=%s)",
|
|
274
|
+
code,
|
|
275
|
+
exc_info=last_error,
|
|
276
|
+
)
|
|
277
|
+
raise DomainError(code=code, message=_GENERIC_VERIFY_FAILURE_MESSAGE)
|
|
278
|
+
|
|
279
|
+
issuer = payload.get("iss")
|
|
280
|
+
if issuer not in self._allowed_issuers:
|
|
281
|
+
# Ne pas leaker ``self._allowed_issuers`` au caller — c'est
|
|
282
|
+
# de la recon précieuse (liste des services internes connus).
|
|
283
|
+
# On garde le ``code`` précis pour le caller légitime, et
|
|
284
|
+
# on log la valeur reçue + la whitelist server-side.
|
|
285
|
+
_logger.warning(
|
|
286
|
+
"ServiceAuthVerifier: issuer rejected (received=%r, allowed=%s)",
|
|
287
|
+
issuer,
|
|
288
|
+
sorted(self._allowed_issuers),
|
|
289
|
+
)
|
|
290
|
+
raise DomainError(
|
|
291
|
+
code="service_auth.invalid_issuer",
|
|
292
|
+
message=_GENERIC_VERIFY_FAILURE_MESSAGE,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
# Sépare les claims standard (typés sur ServiceAuthClaims) des
|
|
296
|
+
# claims additionnels embedés via ``ServiceAuthSigner.sign(
|
|
297
|
+
# extra_claims=...)``. Les non-standard atterrissent dans
|
|
298
|
+
# ``extra``, opaques pour mic mais accessibles au consumer.
|
|
299
|
+
standard_claims = {"iss", "aud", "iat", "exp", "sub", "jti"}
|
|
300
|
+
normalized: dict[str, Any] = {k: v for k, v in payload.items() if k in standard_claims}
|
|
301
|
+
normalized["extra"] = {k: v for k, v in payload.items() if k not in standard_claims}
|
|
302
|
+
return ServiceAuthClaims.model_validate(normalized)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _classify_error(error: jwt.PyJWTError | None) -> str:
|
|
306
|
+
"""Mapping fin des erreurs PyJWT vers code DomainError."""
|
|
307
|
+
if isinstance(error, jwt.ExpiredSignatureError):
|
|
308
|
+
return "service_auth.expired"
|
|
309
|
+
if isinstance(error, jwt.InvalidAudienceError):
|
|
310
|
+
return "service_auth.invalid_audience"
|
|
311
|
+
if isinstance(error, jwt.InvalidSignatureError):
|
|
312
|
+
return "service_auth.invalid_signature"
|
|
313
|
+
if isinstance(error, jwt.MissingRequiredClaimError):
|
|
314
|
+
return "service_auth.missing_claim"
|
|
315
|
+
if isinstance(error, jwt.DecodeError):
|
|
316
|
+
return "service_auth.malformed"
|
|
317
|
+
return "service_auth.invalid_token"
|
mic/authz/__init__.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Authorization (authz) — qui peut faire quoi sur quoi.
|
|
2
|
+
|
|
3
|
+
Distinct de :mod:`mic.auth` (authn = qui es-tu, JWT verification).
|
|
4
|
+
``mic.authz`` répond à la question : **étant donné que tu es X, peux-tu
|
|
5
|
+
faire Y sur Z ?**
|
|
6
|
+
|
|
7
|
+
Architecture (Protocol-based, comme :mod:`mic.datastorage`) :
|
|
8
|
+
|
|
9
|
+
- :class:`AuthorizationEngine` (ABC) : ``check(subject, action,
|
|
10
|
+
resource)`` retourne ``bool``. Tout backend authz le satisfait
|
|
11
|
+
structurellement.
|
|
12
|
+
- :class:`AuthorizationDeniedError` : sous-classe de ``DomainError``
|
|
13
|
+
mappée HTTP **403** (cf. ``http_status_code``). À lever quand
|
|
14
|
+
``check()`` retourne ``False``.
|
|
15
|
+
|
|
16
|
+
Backends fournis :
|
|
17
|
+
|
|
18
|
+
- :class:`InMemoryAuthorizationEngine` — RBAC simple (subject → roles
|
|
19
|
+
→ permissions). Pour dev / tests. Aucune dépendance externe.
|
|
20
|
+
- :class:`OpaAuthorizationEngine` (extra ``[opa]``) — Open Policy
|
|
21
|
+
Agent via REST API. Pour des politiques Rego complexes.
|
|
22
|
+
- :class:`OpenFgaAuthorizationEngine` (extra ``[openfga]``) — OpenFGA
|
|
23
|
+
via REST API (modèle Google Zanzibar / ReBAC fine-grain). Pour
|
|
24
|
+
des relations transitives (groupes, parents, héritage).
|
|
25
|
+
|
|
26
|
+
Usage typique :
|
|
27
|
+
|
|
28
|
+
from mic.authz import (
|
|
29
|
+
AuthorizationEngine, AuthorizationDeniedError,
|
|
30
|
+
InMemoryAuthorizationEngine,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
engine: AuthorizationEngine = InMemoryAuthorizationEngine()
|
|
34
|
+
engine.grant_role("user:42", role="admin")
|
|
35
|
+
engine.bind_role_to_permission("admin", action="users.delete", resource="*")
|
|
36
|
+
|
|
37
|
+
if not engine.check(subject="user:42", action="users.delete", resource="user:99"):
|
|
38
|
+
raise AuthorizationDeniedError(
|
|
39
|
+
code="authz.users_delete_forbidden",
|
|
40
|
+
message="cannot delete users",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
Pour ajouter un nouveau backend (Casbin, Permify, Cerbos, AWS IAM,
|
|
44
|
+
Keto, ...) : implémenter ``check()`` (signature unique du Protocol).
|
|
45
|
+
Pas d'héritage forcé.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from mic.authz.base import AuthorizationEngine
|
|
49
|
+
from mic.authz.errors import AuthorizationDeniedError
|
|
50
|
+
from mic.authz.in_memory import InMemoryAuthorizationEngine
|
|
51
|
+
from mic.authz.opa import OpaAuthorizationEngine
|
|
52
|
+
from mic.authz.openfga import OpenFgaAuthorizationEngine
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"AuthorizationDeniedError",
|
|
56
|
+
"AuthorizationEngine",
|
|
57
|
+
"InMemoryAuthorizationEngine",
|
|
58
|
+
"OpaAuthorizationEngine",
|
|
59
|
+
"OpenFgaAuthorizationEngine",
|
|
60
|
+
]
|
mic/authz/base.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""``AuthorizationEngine`` ABC — abstraction d'un backend authz.
|
|
2
|
+
|
|
3
|
+
Méthode unique : ``check(subject, action, resource)`` retourne True si
|
|
4
|
+
le sujet a le droit d'exécuter l'action sur la ressource.
|
|
5
|
+
|
|
6
|
+
Convention sur les paramètres :
|
|
7
|
+
|
|
8
|
+
- ``subject`` : identifiant stable du sujet — typiquement
|
|
9
|
+
``user:<uuid>`` ou ``service:<name>``. Préfixe libre, c'est le
|
|
10
|
+
consumer qui définit son schéma.
|
|
11
|
+
- ``action`` : verbe métier — ``users.delete``, ``mail.send``,
|
|
12
|
+
``profile.read``. Convention ``<domain>.<verb>`` cohérente avec
|
|
13
|
+
les codes ``DomainError``.
|
|
14
|
+
- ``resource`` : identifiant de la ressource — ``user:99``,
|
|
15
|
+
``profile:42``, ``*`` (wildcard). Le format est libre et propre
|
|
16
|
+
au backend (ex: OpenFGA utilise ``type:id``, Cerbos utilise des
|
|
17
|
+
ressources structurées, etc.).
|
|
18
|
+
|
|
19
|
+
Le retour est binaire (True/False) — pas de "raisons partielles", pas
|
|
20
|
+
de "obligations" (= conditions à appliquer post-décision). Si vous
|
|
21
|
+
avez besoin de ces niveaux de richesse, le backend expose ses
|
|
22
|
+
primitives natives en plus (ex: ``opa.evaluate(input)`` complet).
|
|
23
|
+
|
|
24
|
+
Cf. CONTRIBUTING.md "ABC vs Protocol" pour le rationnel du choix ABC.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from abc import ABC, abstractmethod
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AuthorizationEngine(ABC):
|
|
33
|
+
"""Backend d'authorisation — ``check(subject, action, resource)``.
|
|
34
|
+
|
|
35
|
+
Doit retourner ``True`` si le sujet a le droit, ``False`` sinon.
|
|
36
|
+
Ne lève **jamais** sur une décision négative (= c'est au consumer
|
|
37
|
+
de lever ``AuthorizationDeniedError`` selon son besoin). Peut
|
|
38
|
+
lever sur erreur d'infrastructure (backend OPA injoignable,
|
|
39
|
+
config malformée, etc.) — le consumer décide alors si ça doit
|
|
40
|
+
être un fail-open ou fail-close.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def check(self, *, subject: str, action: str, resource: str) -> bool: ...
|
mic/authz/errors.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""``AuthorizationDeniedError`` — DomainError sous-typée mappée HTTP 403.
|
|
2
|
+
|
|
3
|
+
Convention :
|
|
4
|
+
- ``DomainError`` (default) → 400 (erreur métier client).
|
|
5
|
+
- ``TransientError`` → 503 (panne temporaire upstream).
|
|
6
|
+
- ``AuthorizationDeniedError`` → 403 (subject authentifié mais sans
|
|
7
|
+
droit sur la ressource).
|
|
8
|
+
|
|
9
|
+
À distinguer de :
|
|
10
|
+
- 401 Unauthorized = le subject n'est PAS authentifié (token absent /
|
|
11
|
+
invalide). Géré par ``ServiceAuthVerifier`` côté middleware.
|
|
12
|
+
- 403 Forbidden = le subject EST authentifié mais le contrôle authz
|
|
13
|
+
refuse l'action. C'est ``AuthorizationDeniedError``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from mic.model.errors import DomainError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AuthorizationDeniedError(DomainError):
|
|
22
|
+
"""Authorization denied — subject autorisé mais sans droit suffisant.
|
|
23
|
+
|
|
24
|
+
Mappée HTTP **403** par les adapters via ``http_status_code``.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
http_status_code: int = 403
|
mic/authz/in_memory.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""``InMemoryAuthorizationEngine`` — RBAC simple en RAM.
|
|
2
|
+
|
|
3
|
+
Implémentation de référence pédagogique qui satisfait
|
|
4
|
+
:class:`AuthorizationEngine`. Pour dev / tests.
|
|
5
|
+
|
|
6
|
+
Modèle RBAC (3 niveaux, classique) :
|
|
7
|
+
|
|
8
|
+
- subject → roles (un subject peut avoir plusieurs roles)
|
|
9
|
+
- role → permissions (action, resource_pattern)
|
|
10
|
+
- permission match : action == p.action ET (resource == p.resource OU
|
|
11
|
+
p.resource == "*")
|
|
12
|
+
|
|
13
|
+
Pas de hiérarchie de roles, pas de scope, pas de conditions. Pour des
|
|
14
|
+
politiques riches, voir :class:`OpaAuthorizationEngine` ou
|
|
15
|
+
``OpenFgaAuthorizationEngine`` (v1.8).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
from mic.authz.base import AuthorizationEngine
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class _Permission:
|
|
27
|
+
action: str
|
|
28
|
+
resource: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class InMemoryAuthorizationEngine(AuthorizationEngine):
|
|
32
|
+
"""RBAC simple en RAM, implémente :class:`AuthorizationEngine`."""
|
|
33
|
+
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
self._subject_roles: dict[str, set[str]] = {}
|
|
36
|
+
self._role_permissions: dict[str, set[_Permission]] = {}
|
|
37
|
+
|
|
38
|
+
# ------------------------------------------------------------------
|
|
39
|
+
# Mutation API (admin)
|
|
40
|
+
# ------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
def grant_role(self, subject: str, *, role: str) -> None:
|
|
43
|
+
"""Attribue un rôle à un subject."""
|
|
44
|
+
self._subject_roles.setdefault(subject, set()).add(role)
|
|
45
|
+
|
|
46
|
+
def revoke_role(self, subject: str, *, role: str) -> None:
|
|
47
|
+
"""Retire un rôle d'un subject. No-op si déjà absent."""
|
|
48
|
+
roles = self._subject_roles.get(subject)
|
|
49
|
+
if roles is not None:
|
|
50
|
+
roles.discard(role)
|
|
51
|
+
|
|
52
|
+
def bind_role_to_permission(self, role: str, *, action: str, resource: str) -> None:
|
|
53
|
+
"""Associe une permission (action, resource) à un rôle.
|
|
54
|
+
|
|
55
|
+
``resource="*"`` = wildcard (la permission match toute resource
|
|
56
|
+
pour cette action).
|
|
57
|
+
"""
|
|
58
|
+
self._role_permissions.setdefault(role, set()).add(
|
|
59
|
+
_Permission(action=action, resource=resource)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def unbind_role_from_permission(self, role: str, *, action: str, resource: str) -> None:
|
|
63
|
+
"""Retire une permission d'un rôle. No-op si déjà absente."""
|
|
64
|
+
perms = self._role_permissions.get(role)
|
|
65
|
+
if perms is not None:
|
|
66
|
+
perms.discard(_Permission(action=action, resource=resource))
|
|
67
|
+
|
|
68
|
+
# ------------------------------------------------------------------
|
|
69
|
+
# AuthorizationEngine Protocol
|
|
70
|
+
# ------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def check(self, *, subject: str, action: str, resource: str) -> bool:
|
|
73
|
+
"""True si le subject a au moins un rôle qui couvre l'(action, resource)."""
|
|
74
|
+
roles = self._subject_roles.get(subject, set())
|
|
75
|
+
for role in roles:
|
|
76
|
+
for perm in self._role_permissions.get(role, set()):
|
|
77
|
+
if perm.action == action and perm.resource in (resource, "*"):
|
|
78
|
+
return True
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
# ------------------------------------------------------------------
|
|
82
|
+
# Introspection (debug)
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def roles_of(self, subject: str) -> frozenset[str]:
|
|
86
|
+
"""Retourne les rôles d'un subject (frozen, pour ne pas leaker la mutation)."""
|
|
87
|
+
return frozenset(self._subject_roles.get(subject, set()))
|
|
88
|
+
|
|
89
|
+
def permissions_of(self, role: str) -> frozenset[tuple[str, str]]:
|
|
90
|
+
"""Retourne les permissions d'un rôle sous forme de tuples (action, resource)."""
|
|
91
|
+
return frozenset((p.action, p.resource) for p in self._role_permissions.get(role, set()))
|
mic/authz/opa.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""``OpaAuthorizationEngine`` — Open Policy Agent backend via REST.
|
|
2
|
+
|
|
3
|
+
Démontre comment satisfaire :class:`AuthorizationEngine` sur un
|
|
4
|
+
backend externe (sidecar OPA HTTP). Lisez ce fichier si vous voulez
|
|
5
|
+
écrire votre propre backend (Casbin, Permify, Cerbos, Keto, ...).
|
|
6
|
+
|
|
7
|
+
Convention :
|
|
8
|
+
- ``httpx`` est tiré par l'extra ``[client]`` (déjà présent pour
|
|
9
|
+
``ServiceHttpClient``). L'extra dédié ``[opa]`` ne tire **rien**
|
|
10
|
+
de plus — c'est un alias documentaire.
|
|
11
|
+
- L'OPA endpoint attendu : ``POST {policy_url}`` avec body
|
|
12
|
+
``{"input": {"subject": ..., "action": ..., "resource": ...}}``.
|
|
13
|
+
Réponse attendue : ``{"result": {"allow": true|false}}``.
|
|
14
|
+
- ``fail_close=True`` (default) : si OPA injoignable / réponse
|
|
15
|
+
malformée → ``check()`` retourne ``False`` (= refuser par
|
|
16
|
+
défaut, principe de moindre privilège).
|
|
17
|
+
|
|
18
|
+
Politique Rego type :
|
|
19
|
+
|
|
20
|
+
package mic.authz
|
|
21
|
+
|
|
22
|
+
default allow := false
|
|
23
|
+
|
|
24
|
+
allow if {
|
|
25
|
+
input.action == "users.read"
|
|
26
|
+
startswith(input.subject, "user:")
|
|
27
|
+
}
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import logging
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from mic.authz.base import AuthorizationEngine
|
|
36
|
+
|
|
37
|
+
_logger = logging.getLogger("mic.authz.opa")
|
|
38
|
+
|
|
39
|
+
_HTTP_OK = 200
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class OpaAuthorizationEngine(AuthorizationEngine):
|
|
43
|
+
"""Backend OPA via REST API. Implémente :class:`AuthorizationEngine`.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
policy_url: URL complète de l'endpoint policy OPA. Format
|
|
47
|
+
typique : ``http://opa-sidecar:8181/v1/data/<package>/allow``.
|
|
48
|
+
timeout_seconds: timeout réseau HTTP. Default 1s — OPA en
|
|
49
|
+
sidecar est généralement < 10ms en latence locale.
|
|
50
|
+
client: ``httpx.Client`` pré-construit (utile en tests).
|
|
51
|
+
fail_close: si True (default), un OPA injoignable / réponse
|
|
52
|
+
malformée → ``check()`` retourne ``False``. Si False,
|
|
53
|
+
retourne ``True`` (fail-open : utile en dev local sans
|
|
54
|
+
OPA, dangereux en prod).
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
*,
|
|
60
|
+
policy_url: str,
|
|
61
|
+
timeout_seconds: float = 1.0,
|
|
62
|
+
client: Any = None,
|
|
63
|
+
fail_close: bool = True,
|
|
64
|
+
) -> None:
|
|
65
|
+
if not policy_url:
|
|
66
|
+
raise ValueError("OpaAuthorizationEngine.policy_url must be non-empty")
|
|
67
|
+
self._policy_url = policy_url
|
|
68
|
+
self._timeout_seconds = timeout_seconds
|
|
69
|
+
self._fail_close = fail_close
|
|
70
|
+
if client is not None:
|
|
71
|
+
self._client = client
|
|
72
|
+
else:
|
|
73
|
+
try:
|
|
74
|
+
import httpx # noqa: PLC0415 — optional dep guard
|
|
75
|
+
except ImportError as exc:
|
|
76
|
+
raise ImportError(
|
|
77
|
+
"httpx not installed. Install with: pip install 'mic-struct[client]'"
|
|
78
|
+
) from exc
|
|
79
|
+
self._client = httpx.Client(timeout=timeout_seconds)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def native(self) -> Any:
|
|
83
|
+
"""Le ``httpx.Client`` natif — escape-hatch pour les requêtes
|
|
84
|
+
OPA non couvertes par ``check`` (data API, query API, batch
|
|
85
|
+
evaluations). Aligné avec ``.native`` MIC.
|
|
86
|
+
"""
|
|
87
|
+
return self._client
|
|
88
|
+
|
|
89
|
+
def check(self, *, subject: str, action: str, resource: str) -> bool:
|
|
90
|
+
"""POST l'input à OPA et retourne la décision.
|
|
91
|
+
|
|
92
|
+
En cas d'erreur réseau / format de réponse inattendu, retourne
|
|
93
|
+
``not self._fail_close`` (= False par default = refuser).
|
|
94
|
+
"""
|
|
95
|
+
payload = {
|
|
96
|
+
"input": {
|
|
97
|
+
"subject": subject,
|
|
98
|
+
"action": action,
|
|
99
|
+
"resource": resource,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
try:
|
|
103
|
+
response = self._client.post(self._policy_url, json=payload)
|
|
104
|
+
except Exception as exc:
|
|
105
|
+
_logger.warning("OPA check failed (network): %s", exc)
|
|
106
|
+
return not self._fail_close
|
|
107
|
+
|
|
108
|
+
if response.status_code != _HTTP_OK:
|
|
109
|
+
_logger.warning(
|
|
110
|
+
"OPA check returned HTTP %s : %s",
|
|
111
|
+
response.status_code,
|
|
112
|
+
response.text[:200],
|
|
113
|
+
)
|
|
114
|
+
return not self._fail_close
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
body = response.json()
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
_logger.warning("OPA check : invalid JSON response: %s", exc)
|
|
120
|
+
return not self._fail_close
|
|
121
|
+
|
|
122
|
+
result = body.get("result")
|
|
123
|
+
if isinstance(result, bool):
|
|
124
|
+
# OPA peut retourner directement {"result": true|false}
|
|
125
|
+
return result
|
|
126
|
+
if isinstance(result, dict):
|
|
127
|
+
allow = result.get("allow")
|
|
128
|
+
if isinstance(allow, bool):
|
|
129
|
+
return allow
|
|
130
|
+
_logger.warning("OPA check : unexpected result shape: %r", result)
|
|
131
|
+
return not self._fail_close
|