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
@@ -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