mic-core 0.1.0__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/_lazy.py +80 -0
- mic/auth/__init__.py +39 -0
- mic/auth/service_auth.py +317 -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/fastapi/__init__.py +79 -0
- mic/fastapi/_resolve.py +86 -0
- mic/fastapi/middlewares.py +680 -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 +268 -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/litestar/__init__.py +64 -0
- mic/litestar/_resolve.py +86 -0
- mic/litestar/middlewares.py +503 -0
- mic/litestar/router.py +241 -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/py.typed +0 -0
- mic/resilience/__init__.py +44 -0
- mic/resilience/circuit_breaker.py +301 -0
- mic_core-0.1.0.dist-info/METADATA +100 -0
- mic_core-0.1.0.dist-info/RECORD +59 -0
- mic_core-0.1.0.dist-info/WHEEL +4 -0
mic/_lazy.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Lazy package re-exports (PEP 562) — keeps ``import mic.X`` cheap and
|
|
2
|
+
import-safe even when the optional extra for X isn't installed.
|
|
3
|
+
|
|
4
|
+
Problem
|
|
5
|
+
-------
|
|
6
|
+
A package ``__init__.py`` that eagerly does
|
|
7
|
+
``from mic.X.submodule import Symbol`` forces ``import mic.X`` to import
|
|
8
|
+
``submodule`` at package-load time. If that submodule imports an optional
|
|
9
|
+
dependency at module top-level (``import prometheus_client``,
|
|
10
|
+
``import httpx``, ``import grpc``, …), then ``import mic.X`` **crashes**
|
|
11
|
+
for anyone who ran ``pip install mic-struct`` without the ``[X]`` extra —
|
|
12
|
+
even if they only wanted a stdlib-only symbol from another submodule.
|
|
13
|
+
|
|
14
|
+
Fix
|
|
15
|
+
---
|
|
16
|
+
Defer the submodule import to first attribute access via PEP 562
|
|
17
|
+
``__getattr__``. ``import mic.X`` only runs ``__init__.py`` itself; the
|
|
18
|
+
heavy submodule loads when (and only when) a symbol from it is used.
|
|
19
|
+
If the optional dependency is missing at that point, the ``ImportError``
|
|
20
|
+
is re-raised with a clear ``pip install 'mic-struct[X]'`` hint.
|
|
21
|
+
|
|
22
|
+
This keeps the established "Philosophy B" of the codebase consistent:
|
|
23
|
+
*importing a package never fails for a missing optional dependency —
|
|
24
|
+
only actually using the dependency-backed symbol does.*
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import importlib
|
|
30
|
+
from collections.abc import Callable
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
#: Une entrée d'export lazy : ``(chemin du sous-module, nom de l'extra | None)``.
|
|
34
|
+
#: ``extra=None`` = sous-module sans dépendance optionnelle (stdlib only) —
|
|
35
|
+
#: le lazy reste utile pour ne pas charger inutilement, mais pas de
|
|
36
|
+
#: message "install extra" à émettre.
|
|
37
|
+
LazyExport = tuple[str, str | None]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def make_lazy_getattr(
|
|
41
|
+
*,
|
|
42
|
+
package: str,
|
|
43
|
+
exports: dict[str, LazyExport],
|
|
44
|
+
fallback: Callable[[str], Any] | None = None,
|
|
45
|
+
) -> Callable[[str], Any]:
|
|
46
|
+
"""Construit un ``__getattr__`` PEP 562 pour un ``__init__.py`` de package.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
package: le ``__name__`` du package (pour les messages d'erreur).
|
|
50
|
+
exports: ``{symbole: (sous-module, extra | None)}``. Le sous-module
|
|
51
|
+
est importé à la demande au premier accès du symbole.
|
|
52
|
+
fallback: handler optionnel essayé quand le nom n'est **pas** dans
|
|
53
|
+
``exports`` (ex: alias deprecated). Doit lever ``AttributeError``
|
|
54
|
+
s'il ne sait pas résoudre — sinon la vraie ``AttributeError``
|
|
55
|
+
Python serait masquée.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Une fonction ``__getattr__(name) -> Any`` à assigner au niveau
|
|
59
|
+
module dans le ``__init__.py`` du package.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __getattr__(name: str) -> Any:
|
|
63
|
+
entry = exports.get(name)
|
|
64
|
+
if entry is None:
|
|
65
|
+
if fallback is not None:
|
|
66
|
+
return fallback(name)
|
|
67
|
+
raise AttributeError(f"module {package!r} has no attribute {name!r}")
|
|
68
|
+
module_path, extra = entry
|
|
69
|
+
try:
|
|
70
|
+
module = importlib.import_module(module_path)
|
|
71
|
+
except ImportError as exc:
|
|
72
|
+
if extra is not None:
|
|
73
|
+
raise ImportError(
|
|
74
|
+
f"{package}.{name} requires the optional [{extra}] extra. "
|
|
75
|
+
f"Install it with: pip install 'mic-struct[{extra}]'"
|
|
76
|
+
) from exc
|
|
77
|
+
raise
|
|
78
|
+
return getattr(module, name)
|
|
79
|
+
|
|
80
|
+
return __getattr__
|
mic/auth/__init__.py
ADDED
|
@@ -0,0 +1,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
|
+
]
|
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.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"
|
mic/cli/__init__.py
ADDED
|
@@ -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
|
+
]
|
mic/cli/builder.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Builder ``ArgumentParser`` racine + dispatch robuste.
|
|
2
|
+
|
|
3
|
+
Convention :
|
|
4
|
+
|
|
5
|
+
parser = build_parser(
|
|
6
|
+
prog="my-service",
|
|
7
|
+
description="My service operator CLI.",
|
|
8
|
+
version="1.2.3", # optional → ajoute --version
|
|
9
|
+
verbose=True, # optional → ajoute -v/--verbose
|
|
10
|
+
commands=(
|
|
11
|
+
CliCommandSpec(name="health", run=health_command),
|
|
12
|
+
CliCommandSpec(
|
|
13
|
+
name="drain-once",
|
|
14
|
+
run=drain_command,
|
|
15
|
+
build_args=lambda p: p.add_argument(
|
|
16
|
+
"--batch-size", type=int, default=10
|
|
17
|
+
),
|
|
18
|
+
),
|
|
19
|
+
),
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
sys.exit(dispatch(parser, sys.argv[1:]))
|
|
24
|
+
|
|
25
|
+
``dispatch`` traite les conditions shell standard :
|
|
26
|
+
|
|
27
|
+
- ``KeyboardInterrupt`` (Ctrl-C) → exit ``130`` (= ``128 + SIGINT``)
|
|
28
|
+
- ``BrokenPipeError`` (pipe fermé en aval, ex. ``cmd | head``) → exit ``0``
|
|
29
|
+
- ``SystemExit(code)`` (argparse error, sys.exit, ...) → propagé tel quel
|
|
30
|
+
- Toute autre exception → propagée (laissée au caller pour traceback)
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import argparse
|
|
36
|
+
import logging
|
|
37
|
+
import sys
|
|
38
|
+
from collections.abc import Iterable, Sequence
|
|
39
|
+
|
|
40
|
+
from mic.cli.spec import CliCommandSpec
|
|
41
|
+
|
|
42
|
+
#: Code shell standard quand un process est interrompu par SIGINT.
|
|
43
|
+
#: 128 + signal_number (SIGINT=2). Cf. POSIX advisory.
|
|
44
|
+
_EXIT_SIGINT = 130
|
|
45
|
+
|
|
46
|
+
#: Seuil ``-vv``+ (count ≥ ce seuil → DEBUG, sinon INFO).
|
|
47
|
+
_VERBOSE_DEBUG_THRESHOLD = 2
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_parser(
|
|
51
|
+
*,
|
|
52
|
+
prog: str,
|
|
53
|
+
description: str,
|
|
54
|
+
commands: Iterable[CliCommandSpec],
|
|
55
|
+
version: str | None = None,
|
|
56
|
+
verbose: bool = False,
|
|
57
|
+
) -> argparse.ArgumentParser:
|
|
58
|
+
"""Construit un ``ArgumentParser`` racine avec un sub-parser par
|
|
59
|
+
``CliCommandSpec``.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
prog: nom du programme (affiché dans ``--help``).
|
|
63
|
+
description: description courte (affichée dans ``--help`` racine).
|
|
64
|
+
commands: les ``CliCommandSpec`` à exposer (≥ 1).
|
|
65
|
+
version: si fourni, ajoute ``--version`` qui imprime ``f"{prog}
|
|
66
|
+
{version}"`` puis exit 0.
|
|
67
|
+
verbose: si True, ajoute le flag global ``-v/--verbose`` (action
|
|
68
|
+
``count``, accumulable : ``-v``, ``-vv``, ``-vvv``).
|
|
69
|
+
Disponible dans tous les sub-commands via ``args.verbose``
|
|
70
|
+
(int ≥ 0). Le runner choisit comment l'interpréter (souvent
|
|
71
|
+
``WARNING/INFO/DEBUG`` selon le count).
|
|
72
|
+
|
|
73
|
+
Lève ``ValueError`` si :
|
|
74
|
+
|
|
75
|
+
- ``commands`` est vide (= CLI inutilisable) ;
|
|
76
|
+
- deux specs ont le même ``name`` (collision sub-command) ;
|
|
77
|
+
- un alias collisionne avec un nom déjà pris.
|
|
78
|
+
"""
|
|
79
|
+
specs = tuple(commands)
|
|
80
|
+
if not specs:
|
|
81
|
+
raise ValueError("build_parser(commands=...) must contain at least one CliCommandSpec")
|
|
82
|
+
|
|
83
|
+
seen: set[str] = set()
|
|
84
|
+
for spec in specs:
|
|
85
|
+
if spec.name in seen:
|
|
86
|
+
raise ValueError(f"duplicate CLI command name: {spec.name!r}")
|
|
87
|
+
seen.add(spec.name)
|
|
88
|
+
for alias in spec.aliases:
|
|
89
|
+
if alias in seen:
|
|
90
|
+
raise ValueError(
|
|
91
|
+
f"CLI alias {alias!r} (on command {spec.name!r}) collides with "
|
|
92
|
+
f"an existing name or alias"
|
|
93
|
+
)
|
|
94
|
+
seen.add(alias)
|
|
95
|
+
|
|
96
|
+
parser = argparse.ArgumentParser(prog=prog, description=description)
|
|
97
|
+
if version is not None:
|
|
98
|
+
parser.add_argument(
|
|
99
|
+
"--version",
|
|
100
|
+
action="version",
|
|
101
|
+
version=f"{prog} {version}",
|
|
102
|
+
)
|
|
103
|
+
if verbose:
|
|
104
|
+
parser.add_argument(
|
|
105
|
+
"-v",
|
|
106
|
+
"--verbose",
|
|
107
|
+
action="count",
|
|
108
|
+
default=0,
|
|
109
|
+
help="Increase verbosity (-v, -vv, -vvv for WARNING/INFO/DEBUG).",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
sub = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
|
|
113
|
+
|
|
114
|
+
for spec in specs:
|
|
115
|
+
sub_parser = sub.add_parser(
|
|
116
|
+
spec.name,
|
|
117
|
+
help=spec.resolved_help(),
|
|
118
|
+
aliases=list(spec.aliases),
|
|
119
|
+
)
|
|
120
|
+
if spec.build_args is not None:
|
|
121
|
+
spec.build_args(sub_parser)
|
|
122
|
+
sub_parser.set_defaults(_mic_run=spec.run)
|
|
123
|
+
|
|
124
|
+
return parser
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def dispatch(parser: argparse.ArgumentParser, argv: Sequence[str] | None = None) -> int:
|
|
128
|
+
"""Parse ``argv`` puis exécute la commande sélectionnée.
|
|
129
|
+
|
|
130
|
+
Codes de sortie :
|
|
131
|
+
|
|
132
|
+
- ``0`` = succès (le runner a retourné 0 OU pipe cassé en aval)
|
|
133
|
+
- ``130`` = ``KeyboardInterrupt`` (Ctrl-C, convention shell SIGINT)
|
|
134
|
+
- ce que retourne le ``run`` de la commande pour les autres cas
|
|
135
|
+
|
|
136
|
+
Les ``SystemExit`` (argparse errors, ``sys.exit(N)`` dans un runner)
|
|
137
|
+
sont laissés se propager — le caller décide. ``KeyboardInterrupt``
|
|
138
|
+
et ``BrokenPipeError`` sont catchés explicitement parce qu'ils sont
|
|
139
|
+
*normaux* en CLI (Ctrl-C, ``cmd | head``) et ne devraient pas
|
|
140
|
+
afficher de traceback.
|
|
141
|
+
"""
|
|
142
|
+
args = parser.parse_args(argv)
|
|
143
|
+
runner = getattr(args, "_mic_run", None)
|
|
144
|
+
if runner is None: # pragma: no cover — argparse ``required=True`` empêche
|
|
145
|
+
parser.error("no command specified")
|
|
146
|
+
|
|
147
|
+
if getattr(args, "verbose", 0):
|
|
148
|
+
_configure_logging_from_verbose(args.verbose)
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
return int(runner(args))
|
|
152
|
+
except KeyboardInterrupt:
|
|
153
|
+
return _EXIT_SIGINT
|
|
154
|
+
except BrokenPipeError:
|
|
155
|
+
# ``cmd | head`` ferme le pipe → on sort silencieusement comme
|
|
156
|
+
# le ferait n'importe quel utilitaire Unix bien élevé.
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _configure_logging_from_verbose(level_count: int) -> None:
|
|
161
|
+
"""Map ``-v`` count → niveau logging stdlib.
|
|
162
|
+
|
|
163
|
+
Convention :
|
|
164
|
+
- ``-v`` → INFO
|
|
165
|
+
- ``-vv``+ → DEBUG
|
|
166
|
+
- 0 → WARNING (default Python ; on n'override pas si non demandé)
|
|
167
|
+
"""
|
|
168
|
+
if level_count >= _VERBOSE_DEBUG_THRESHOLD:
|
|
169
|
+
log_level = logging.DEBUG
|
|
170
|
+
else:
|
|
171
|
+
log_level = logging.INFO
|
|
172
|
+
logging.basicConfig(
|
|
173
|
+
stream=sys.stderr,
|
|
174
|
+
level=log_level,
|
|
175
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
176
|
+
)
|