mic-core 0.1.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 (60) hide show
  1. mic_core-0.1.0/.gitignore +28 -0
  2. mic_core-0.1.0/PKG-INFO +100 -0
  3. mic_core-0.1.0/README.md +61 -0
  4. mic_core-0.1.0/mic/_lazy.py +80 -0
  5. mic_core-0.1.0/mic/auth/__init__.py +39 -0
  6. mic_core-0.1.0/mic/auth/service_auth.py +317 -0
  7. mic_core-0.1.0/mic/cli/__init__.py +24 -0
  8. mic_core-0.1.0/mic/cli/builder.py +176 -0
  9. mic_core-0.1.0/mic/cli/spec.py +68 -0
  10. mic_core-0.1.0/mic/client/__init__.py +26 -0
  11. mic_core-0.1.0/mic/client/async_service_client.py +210 -0
  12. mic_core-0.1.0/mic/client/service_client.py +229 -0
  13. mic_core-0.1.0/mic/contracts/__init__.py +15 -0
  14. mic_core-0.1.0/mic/contracts/envelope.py +45 -0
  15. mic_core-0.1.0/mic/contracts/strict_schema.py +48 -0
  16. mic_core-0.1.0/mic/fastapi/__init__.py +79 -0
  17. mic_core-0.1.0/mic/fastapi/_resolve.py +86 -0
  18. mic_core-0.1.0/mic/fastapi/middlewares.py +680 -0
  19. mic_core-0.1.0/mic/fastapi/router.py +186 -0
  20. mic_core-0.1.0/mic/grpc/__init__.py +75 -0
  21. mic_core-0.1.0/mic/grpc/client.py +175 -0
  22. mic_core-0.1.0/mic/grpc/errors.py +110 -0
  23. mic_core-0.1.0/mic/grpc/interceptors.py +183 -0
  24. mic_core-0.1.0/mic/grpc/rpc_spec.py +125 -0
  25. mic_core-0.1.0/mic/grpc/server.py +56 -0
  26. mic_core-0.1.0/mic/grpc/spec.py +42 -0
  27. mic_core-0.1.0/mic/healthcheck/__init__.py +40 -0
  28. mic_core-0.1.0/mic/healthcheck/readiness.py +268 -0
  29. mic_core-0.1.0/mic/http/__init__.py +40 -0
  30. mic_core-0.1.0/mic/http/api_key.py +120 -0
  31. mic_core-0.1.0/mic/http/auth_gate.py +335 -0
  32. mic_core-0.1.0/mic/http/basic_auth.py +114 -0
  33. mic_core-0.1.0/mic/http/bearer.py +67 -0
  34. mic_core-0.1.0/mic/http/correlation.py +169 -0
  35. mic_core-0.1.0/mic/http/csrf.py +173 -0
  36. mic_core-0.1.0/mic/http/request_context.py +55 -0
  37. mic_core-0.1.0/mic/http/route_spec.py +140 -0
  38. mic_core-0.1.0/mic/litestar/__init__.py +64 -0
  39. mic_core-0.1.0/mic/litestar/_resolve.py +86 -0
  40. mic_core-0.1.0/mic/litestar/middlewares.py +503 -0
  41. mic_core-0.1.0/mic/litestar/router.py +241 -0
  42. mic_core-0.1.0/mic/model/__init__.py +21 -0
  43. mic_core-0.1.0/mic/model/errors.py +130 -0
  44. mic_core-0.1.0/mic/observability/__init__.py +208 -0
  45. mic_core-0.1.0/mic/observability/caller.py +102 -0
  46. mic_core-0.1.0/mic/observability/circuit_breaker_metrics.py +111 -0
  47. mic_core-0.1.0/mic/observability/log_context_middleware.py +140 -0
  48. mic_core-0.1.0/mic/observability/logging.py +442 -0
  49. mic_core-0.1.0/mic/observability/metrics.py +105 -0
  50. mic_core-0.1.0/mic/observability/metrics_backend.py +656 -0
  51. mic_core-0.1.0/mic/observability/metrics_guard.py +185 -0
  52. mic_core-0.1.0/mic/observability/redact_logs.py +101 -0
  53. mic_core-0.1.0/mic/observability/redact_traces.py +115 -0
  54. mic_core-0.1.0/mic/observability/sentry.py +98 -0
  55. mic_core-0.1.0/mic/observability/tracing.py +207 -0
  56. mic_core-0.1.0/mic/observability/tracing_middleware.py +142 -0
  57. mic_core-0.1.0/mic/py.typed +0 -0
  58. mic_core-0.1.0/mic/resilience/__init__.py +44 -0
  59. mic_core-0.1.0/mic/resilience/circuit_breaker.py +301 -0
  60. mic_core-0.1.0/pyproject.toml +60 -0
@@ -0,0 +1,28 @@
1
+ # Python
2
+ __pycache__/
3
+ *.pyc
4
+ .venv/
5
+ .mypy_cache/
6
+ .pytest_cache/
7
+ .ruff_cache/
8
+
9
+ # Build / packaging
10
+ dist/
11
+ build/
12
+ *.egg-info/
13
+ .coverage
14
+ coverage.xml
15
+
16
+ # OS / Editor
17
+ .DS_Store
18
+ Thumbs.db
19
+ .idea/
20
+ .vscode/
21
+ *.swp
22
+ *.bak
23
+
24
+ # Reports / temp
25
+ reports/
26
+ .benchmarks/
27
+ .mutmut-cache/
28
+ .wily/
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.4
2
+ Name: mic-core
3
+ Version: 0.1.0
4
+ Summary: MIC — fondations agnostiques (contracts, model, http, auth, client, observability, healthcheck, resilience, cli, grpc).
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.14
7
+ Requires-Dist: pydantic>=2.6
8
+ Provides-Extra: auth
9
+ Requires-Dist: pyjwt<3,>=2.8; extra == 'auth'
10
+ Provides-Extra: cli
11
+ Provides-Extra: client
12
+ Requires-Dist: httpx<1,>=0.27; extra == 'client'
13
+ Provides-Extra: fastapi
14
+ Requires-Dist: fastapi<1,>=0.115; extra == 'fastapi'
15
+ Requires-Dist: starlette<2,>=1.0.1; extra == 'fastapi'
16
+ Requires-Dist: uvicorn[standard]<1,>=0.30; extra == 'fastapi'
17
+ Provides-Extra: full-observability
18
+ Requires-Dist: opentelemetry-api<2,>=1.27; extra == 'full-observability'
19
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http<2,>=1.27; extra == 'full-observability'
20
+ Requires-Dist: opentelemetry-sdk<2,>=1.27; extra == 'full-observability'
21
+ Requires-Dist: prometheus-client<1,>=0.20; extra == 'full-observability'
22
+ Requires-Dist: sentry-sdk<3,>=2.20; extra == 'full-observability'
23
+ Requires-Dist: urllib3<3,>=2.7; extra == 'full-observability'
24
+ Provides-Extra: grpc
25
+ Requires-Dist: grpcio-health-checking<2,>=1.66; extra == 'grpc'
26
+ Requires-Dist: grpcio<2,>=1.66; extra == 'grpc'
27
+ Provides-Extra: litestar
28
+ Requires-Dist: litestar<3,>=2.13; extra == 'litestar'
29
+ Provides-Extra: observability
30
+ Requires-Dist: opentelemetry-api<2,>=1.27; extra == 'observability'
31
+ Requires-Dist: prometheus-client<1,>=0.20; extra == 'observability'
32
+ Provides-Extra: sentry
33
+ Requires-Dist: sentry-sdk<3,>=2.20; extra == 'sentry'
34
+ Requires-Dist: urllib3<3,>=2.7; extra == 'sentry'
35
+ Provides-Extra: tracing
36
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http<2,>=1.27; extra == 'tracing'
37
+ Requires-Dist: opentelemetry-sdk<2,>=1.27; extra == 'tracing'
38
+ Description-Content-Type: text/markdown
39
+
40
+ # mic-core
41
+
42
+ Fondations agnostiques du framework **MIC** (Model / Interface / Controller).
43
+ `mic-core` est le socle : il ne dépend d'aucune brique de persistance ni de
44
+ plateforme (voir le DAG `platform → data → core` dans
45
+ [`../README.md`](../README.md)).
46
+
47
+ ## Modules
48
+
49
+ | Module | Rôle |
50
+ | -- | -- |
51
+ | `mic.contracts` | Contrats / DTO partagés inter-couches |
52
+ | `mic.model` | Erreurs de domaine (`DomainError`, `TransientError`), value objects |
53
+ | `mic.http` | Spécifications HTTP **agnostiques** (`HttpRouteSpec`, `HttpResponseSpec`, `HttpCookieSpec`, `HttpRequestContext`) |
54
+ | `mic.fastapi` | Adaptateur FastAPI (router builder, middlewares) — extra `[fastapi]` |
55
+ | `mic.litestar` | Adaptateur Litestar — extra `[litestar]` |
56
+ | `mic.auth` | JWT service-to-service (signer/verifier) — extra `[auth]` |
57
+ | `mic.client` | Client HTTP inter-services (httpx) — extra `[client]` |
58
+ | `mic.observability` | Logs JSON, métriques Prometheus, traces OTel — extra `[observability]` / `[full-observability]` |
59
+ | `mic.healthcheck` | Sondes liveness/readiness |
60
+ | `mic.resilience` | Circuit breaker, retry, timeouts |
61
+ | `mic.cli` | Squelette CLI (argparse stdlib) — extra `[cli]` |
62
+ | `mic.grpc` | Transport gRPC — extra `[grpc]` |
63
+
64
+ Le paquet est un **namespace PEP 420** (pas de `mic/__init__.py`) : `import mic.*`
65
+ reste identique côté consommateur, qu'on installe `mic-core` seul ou avec
66
+ `mic-datastore` / `mic-platform`.
67
+
68
+ ## Installation
69
+
70
+ ```bash
71
+ pip install "mic-core[fastapi,auth,client,observability]"
72
+ ```
73
+
74
+ ## Extras
75
+
76
+ | Extra | Tire |
77
+ | -- | -- |
78
+ | `fastapi` | fastapi + uvicorn[standard] + starlette |
79
+ | `litestar` | litestar |
80
+ | `auth` | pyjwt |
81
+ | `client` | httpx |
82
+ | `observability` | prometheus-client + opentelemetry-api |
83
+ | `sentry` | sentry-sdk (+ urllib3 patché) |
84
+ | `tracing` | opentelemetry-sdk + exporter OTLP |
85
+ | `full-observability` | `observability` + `sentry` + `tracing` |
86
+ | `grpc` | grpcio + grpcio-health-checking |
87
+ | `cli` | (rien — argparse stdlib) |
88
+
89
+ ## Versionnage
90
+
91
+ `mic-core` démarre en `0.x` (pré-releases `dev`/`rc` sur TestPyPI). Il **graduera
92
+ en `1.0`** une fois les frontières prouvées et les services migrés (fin du split,
93
+ cf. [ADR-0001](../../docs/decisions/ADR-0001-split-mic-struct-into-three-packages.md)).
94
+ En `0.x`, un bump *minor* peut porter un changement cassant (SemVer 0.x) —
95
+ contraindre avec `>=0.1,<1`.
96
+
97
+ ## Release
98
+
99
+ Voir [`../../RELEASING.md`](../../RELEASING.md) § « Release par paquet »
100
+ (`make release-pkg PKG=core VERSION=x.y.z` → tag `core-vX.Y.Z`).
@@ -0,0 +1,61 @@
1
+ # mic-core
2
+
3
+ Fondations agnostiques du framework **MIC** (Model / Interface / Controller).
4
+ `mic-core` est le socle : il ne dépend d'aucune brique de persistance ni de
5
+ plateforme (voir le DAG `platform → data → core` dans
6
+ [`../README.md`](../README.md)).
7
+
8
+ ## Modules
9
+
10
+ | Module | Rôle |
11
+ | -- | -- |
12
+ | `mic.contracts` | Contrats / DTO partagés inter-couches |
13
+ | `mic.model` | Erreurs de domaine (`DomainError`, `TransientError`), value objects |
14
+ | `mic.http` | Spécifications HTTP **agnostiques** (`HttpRouteSpec`, `HttpResponseSpec`, `HttpCookieSpec`, `HttpRequestContext`) |
15
+ | `mic.fastapi` | Adaptateur FastAPI (router builder, middlewares) — extra `[fastapi]` |
16
+ | `mic.litestar` | Adaptateur Litestar — extra `[litestar]` |
17
+ | `mic.auth` | JWT service-to-service (signer/verifier) — extra `[auth]` |
18
+ | `mic.client` | Client HTTP inter-services (httpx) — extra `[client]` |
19
+ | `mic.observability` | Logs JSON, métriques Prometheus, traces OTel — extra `[observability]` / `[full-observability]` |
20
+ | `mic.healthcheck` | Sondes liveness/readiness |
21
+ | `mic.resilience` | Circuit breaker, retry, timeouts |
22
+ | `mic.cli` | Squelette CLI (argparse stdlib) — extra `[cli]` |
23
+ | `mic.grpc` | Transport gRPC — extra `[grpc]` |
24
+
25
+ Le paquet est un **namespace PEP 420** (pas de `mic/__init__.py`) : `import mic.*`
26
+ reste identique côté consommateur, qu'on installe `mic-core` seul ou avec
27
+ `mic-datastore` / `mic-platform`.
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ pip install "mic-core[fastapi,auth,client,observability]"
33
+ ```
34
+
35
+ ## Extras
36
+
37
+ | Extra | Tire |
38
+ | -- | -- |
39
+ | `fastapi` | fastapi + uvicorn[standard] + starlette |
40
+ | `litestar` | litestar |
41
+ | `auth` | pyjwt |
42
+ | `client` | httpx |
43
+ | `observability` | prometheus-client + opentelemetry-api |
44
+ | `sentry` | sentry-sdk (+ urllib3 patché) |
45
+ | `tracing` | opentelemetry-sdk + exporter OTLP |
46
+ | `full-observability` | `observability` + `sentry` + `tracing` |
47
+ | `grpc` | grpcio + grpcio-health-checking |
48
+ | `cli` | (rien — argparse stdlib) |
49
+
50
+ ## Versionnage
51
+
52
+ `mic-core` démarre en `0.x` (pré-releases `dev`/`rc` sur TestPyPI). Il **graduera
53
+ en `1.0`** une fois les frontières prouvées et les services migrés (fin du split,
54
+ cf. [ADR-0001](../../docs/decisions/ADR-0001-split-mic-struct-into-three-packages.md)).
55
+ En `0.x`, un bump *minor* peut porter un changement cassant (SemVer 0.x) —
56
+ contraindre avec `>=0.1,<1`.
57
+
58
+ ## Release
59
+
60
+ Voir [`../../RELEASING.md`](../../RELEASING.md) § « Release par paquet »
61
+ (`make release-pkg PKG=core VERSION=x.y.z` → tag `core-vX.Y.Z`).
@@ -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__
@@ -0,0 +1,39 @@
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.service_auth import (
22
+ ServiceAuthClaims,
23
+ ServiceAuthSigner,
24
+ ServiceAuthVerifier,
25
+ )
26
+
27
+ _EXPORTS: dict[str, LazyExport] = {
28
+ "ServiceAuthClaims": ("mic.auth.service_auth", "auth"),
29
+ "ServiceAuthSigner": ("mic.auth.service_auth", "auth"),
30
+ "ServiceAuthVerifier": ("mic.auth.service_auth", "auth"),
31
+ }
32
+
33
+ __getattr__ = make_lazy_getattr(package=__name__, exports=_EXPORTS)
34
+
35
+ __all__ = [
36
+ "ServiceAuthClaims",
37
+ "ServiceAuthSigner",
38
+ "ServiceAuthVerifier",
39
+ ]
@@ -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.cache.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.cache.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"
@@ -0,0 +1,24 @@
1
+ """CLI canon — argparse stdlib + ``CliCommandSpec`` builder.
2
+
3
+ Cf. R12 dans ``CONTRIBUTING.md`` :
4
+
5
+ - Tout service avec opérations opérateur (drain, requeue,
6
+ reindex, query, replay, …) doit livrer un CLI via cette brique.
7
+ - Framework imposé : ``argparse`` stdlib (pas de Click/Typer).
8
+ - 1 fichier ``frameworks/cli/<groupe>_commands.py`` par bounded
9
+ context, chaque commande exposée via un ``CliCommandSpec``.
10
+
11
+ L'extra ``mic-struct[cli]`` existe pour cohérence — ``mic.cli`` n'a
12
+ aucune dépendance externe (stdlib only).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from mic.cli.builder import build_parser, dispatch
18
+ from mic.cli.spec import CliCommandSpec
19
+
20
+ __all__ = [
21
+ "CliCommandSpec",
22
+ "build_parser",
23
+ "dispatch",
24
+ ]