mic-platform 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.
@@ -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,52 @@
1
+ Metadata-Version: 2.4
2
+ Name: mic-platform
3
+ Version: 0.1.0
4
+ Summary: MIC — policy & contrôle (authz OPA/OpenFGA, ratelimit).
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.14
7
+ Requires-Dist: mic-core
8
+ Requires-Dist: mic-datastore
9
+ Provides-Extra: opa
10
+ Requires-Dist: mic-core[client]; extra == 'opa'
11
+ Provides-Extra: openfga
12
+ Requires-Dist: mic-core[client]; extra == 'openfga'
13
+ Provides-Extra: redis
14
+ Requires-Dist: mic-datastore[redis]; extra == 'redis'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # mic-platform
18
+
19
+ Briques **policy & contrôle** du framework MIC. Sommet du DAG
20
+ `platform → data → core` : dépend de `mic-core` ET `mic-datastore`.
21
+
22
+ ## Modules
23
+
24
+ | Module | Rôle |
25
+ | -- | -- |
26
+ | `mic.authz` | Autorisation : moteur in-memory, OPA, OpenFGA (appels API **via httpx**) |
27
+ | `mic.ratelimit` | Rate limiting (in-memory / Redis Lua) + `RateLimitMiddleware` |
28
+
29
+ Namespace **PEP 420** (pas de `mic/__init__.py`).
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ pip install "mic-platform[openfga,redis]"
35
+ ```
36
+
37
+ ## Extras
38
+
39
+ | Extra | Tire |
40
+ | -- | -- |
41
+ | `opa` | `mic-core[client]` (httpx — appels HTTP vers l'agent OPA) |
42
+ | `openfga` | `mic-core[client]` (httpx — API OpenFGA ; **pas** le SDK officiel) |
43
+ | `redis` | `mic-datastore[redis]` (backend Lua du rate limiter) |
44
+
45
+ > ℹ️ L'adaptateur OpenFGA (`mic.authz.openfga`) parle à l'API HTTP via `httpx`,
46
+ > il n'utilise **pas** `openfga-sdk`.
47
+
48
+ ## Versionnage
49
+
50
+ Brique volatile (OPA/OpenFGA bougent vite) — reste en `0.x`. Contraindre avec
51
+ `>=0.1,<1`. Release : `make release-pkg PKG=platform VERSION=x.y.z` → tag
52
+ `platform-vX.Y.Z` (cf. [`../../RELEASING.md`](../../RELEASING.md)).
@@ -0,0 +1,36 @@
1
+ # mic-platform
2
+
3
+ Briques **policy & contrôle** du framework MIC. Sommet du DAG
4
+ `platform → data → core` : dépend de `mic-core` ET `mic-datastore`.
5
+
6
+ ## Modules
7
+
8
+ | Module | Rôle |
9
+ | -- | -- |
10
+ | `mic.authz` | Autorisation : moteur in-memory, OPA, OpenFGA (appels API **via httpx**) |
11
+ | `mic.ratelimit` | Rate limiting (in-memory / Redis Lua) + `RateLimitMiddleware` |
12
+
13
+ Namespace **PEP 420** (pas de `mic/__init__.py`).
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ pip install "mic-platform[openfga,redis]"
19
+ ```
20
+
21
+ ## Extras
22
+
23
+ | Extra | Tire |
24
+ | -- | -- |
25
+ | `opa` | `mic-core[client]` (httpx — appels HTTP vers l'agent OPA) |
26
+ | `openfga` | `mic-core[client]` (httpx — API OpenFGA ; **pas** le SDK officiel) |
27
+ | `redis` | `mic-datastore[redis]` (backend Lua du rate limiter) |
28
+
29
+ > ℹ️ L'adaptateur OpenFGA (`mic.authz.openfga`) parle à l'API HTTP via `httpx`,
30
+ > il n'utilise **pas** `openfga-sdk`.
31
+
32
+ ## Versionnage
33
+
34
+ Brique volatile (OPA/OpenFGA bougent vite) — reste en `0.x`. Contraindre avec
35
+ `>=0.1,<1`. Release : `make release-pkg PKG=platform VERSION=x.y.z` → tag
36
+ `platform-vX.Y.Z` (cf. [`../../RELEASING.md`](../../RELEASING.md)).
@@ -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
+ ]
@@ -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: ...
@@ -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
@@ -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()))
@@ -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
@@ -0,0 +1,167 @@
1
+ """``OpenFgaAuthorizationEngine`` — OpenFGA backend via REST.
2
+
3
+ OpenFGA implémente le modèle Google Zanzibar (relation-based access
4
+ control / ReBAC). Au lieu de "user X a le rôle admin" (RBAC), on
5
+ modélise "user X a la relation ``viewer`` avec l'objet
6
+ ``document:roadmap``" — fine-grained et naturellement transitif
7
+ (héritage via group, parent, etc.).
8
+
9
+ Mapping du Protocol :class:`AuthorizationEngine` → API OpenFGA Check :
10
+
11
+ - ``subject`` → ``tuple_key.user`` (ex. ``"user:42"``)
12
+ - ``action`` → ``tuple_key.relation`` (ex. ``"viewer"``, ``"editor"``)
13
+ - ``resource`` → ``tuple_key.object`` (ex. ``"document:roadmap"``)
14
+
15
+ Endpoint OpenFGA attendu :
16
+ ``POST {api_url}/stores/{store_id}/check``
17
+ Réponse attendue :
18
+ ``{"allowed": true|false}``
19
+
20
+ Ref : https://openfga.dev/docs/interacting/relationship-queries#check
21
+
22
+ Conventions (identiques à :class:`OpaAuthorizationEngine`) :
23
+
24
+ - ``httpx`` est tiré par l'extra ``[client]``. L'extra ``[openfga]``
25
+ est un alias documentaire (pas de SDK officiel tiré — l'API REST
26
+ est stable et minimaliste, le SDK ajoute du poids inutile).
27
+ - ``fail_close=True`` (default) : OpenFGA injoignable / réponse
28
+ malformée → ``check()`` retourne ``False`` (= refuser, principe de
29
+ moindre privilège).
30
+ - ``authorization_model_id`` est optionnel (OpenFGA prend le dernier
31
+ modèle déployé si absent ; le passer rend la décision déterministe
32
+ dans le temps).
33
+
34
+ Exemple de modèle OpenFGA pour un cas "documents partagés" :
35
+
36
+ model
37
+ schema 1.1
38
+ type user
39
+ type document
40
+ relations
41
+ define viewer: [user]
42
+ define editor: [user]
43
+ define owner: [user]
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import logging
49
+ from typing import Any
50
+
51
+ from mic.authz.base import AuthorizationEngine
52
+
53
+ _logger = logging.getLogger("mic.authz.openfga")
54
+
55
+ _HTTP_OK = 200
56
+
57
+
58
+ class OpenFgaAuthorizationEngine(AuthorizationEngine):
59
+ """Backend OpenFGA via REST API. Implémente :class:`AuthorizationEngine`.
60
+
61
+ Args:
62
+ api_url: URL racine OpenFGA, sans slash final. Ex.
63
+ ``"http://openfga.observability.svc.cluster.local:8080"``.
64
+ store_id: identifiant du store OpenFGA (``01HXX...``).
65
+ authorization_model_id: modèle d'autorisation à utiliser
66
+ (optionnel ; default = dernier modèle déployé).
67
+ api_token: bearer token optionnel (OpenFGA Cloud / déploiements
68
+ sécurisés).
69
+ timeout_seconds: timeout réseau HTTP. Default 1s — OpenFGA
70
+ sidecar < 10ms en latence locale.
71
+ client: ``httpx.Client`` pré-construit (utile en tests).
72
+ fail_close: si True (default), un OpenFGA injoignable / réponse
73
+ malformée → ``check()`` retourne ``False``. Si False,
74
+ retourne ``True`` (fail-open : utile en dev local sans
75
+ OpenFGA, dangereux en prod).
76
+ """
77
+
78
+ def __init__(
79
+ self,
80
+ *,
81
+ api_url: str,
82
+ store_id: str,
83
+ authorization_model_id: str | None = None,
84
+ api_token: str | None = None,
85
+ timeout_seconds: float = 1.0,
86
+ client: Any = None,
87
+ fail_close: bool = True,
88
+ ) -> None:
89
+ if not api_url:
90
+ raise ValueError("OpenFgaAuthorizationEngine.api_url must be non-empty")
91
+ if not store_id:
92
+ raise ValueError("OpenFgaAuthorizationEngine.store_id must be non-empty")
93
+ self._api_url = api_url.rstrip("/")
94
+ self._store_id = store_id
95
+ self._authorization_model_id = authorization_model_id
96
+ self._api_token = api_token
97
+ self._timeout_seconds = timeout_seconds
98
+ self._fail_close = fail_close
99
+ if client is not None:
100
+ self._client = client
101
+ else:
102
+ try:
103
+ import httpx # noqa: PLC0415 — optional dep guard
104
+ except ImportError as exc:
105
+ raise ImportError(
106
+ "httpx not installed. Install with: pip install 'mic-struct[openfga]'"
107
+ ) from exc
108
+ self._client = httpx.Client(timeout=timeout_seconds)
109
+
110
+ @property
111
+ def check_url(self) -> str:
112
+ """URL complète de l'endpoint Check pour ce store."""
113
+ return f"{self._api_url}/stores/{self._store_id}/check"
114
+
115
+ @property
116
+ def native(self) -> Any:
117
+ """Le ``httpx.Client`` natif — escape-hatch pour les autres
118
+ endpoints OpenFGA (Write, ListObjects, Expand, ...). Aligné
119
+ avec ``.native`` MIC.
120
+ """
121
+ return self._client
122
+
123
+ def check(self, *, subject: str, action: str, resource: str) -> bool:
124
+ """POST l'input à OpenFGA et retourne la décision.
125
+
126
+ En cas d'erreur réseau / format de réponse inattendu, retourne
127
+ ``not self._fail_close`` (= False par default = refuser).
128
+ """
129
+ payload: dict[str, Any] = {
130
+ "tuple_key": {
131
+ "user": subject,
132
+ "relation": action,
133
+ "object": resource,
134
+ }
135
+ }
136
+ if self._authorization_model_id is not None:
137
+ payload["authorization_model_id"] = self._authorization_model_id
138
+
139
+ headers: dict[str, str] = {}
140
+ if self._api_token is not None:
141
+ headers["Authorization"] = f"Bearer {self._api_token}"
142
+
143
+ try:
144
+ response = self._client.post(self.check_url, json=payload, headers=headers)
145
+ except Exception as exc:
146
+ _logger.warning("OpenFGA check failed (network): %s", exc)
147
+ return not self._fail_close
148
+
149
+ if response.status_code != _HTTP_OK:
150
+ _logger.warning(
151
+ "OpenFGA check returned HTTP %s : %s",
152
+ response.status_code,
153
+ response.text[:200],
154
+ )
155
+ return not self._fail_close
156
+
157
+ try:
158
+ body = response.json()
159
+ except Exception as exc:
160
+ _logger.warning("OpenFGA check : invalid JSON response: %s", exc)
161
+ return not self._fail_close
162
+
163
+ allowed = body.get("allowed")
164
+ if isinstance(allowed, bool):
165
+ return allowed
166
+ _logger.warning("OpenFGA check : unexpected response shape: %r", body)
167
+ return not self._fail_close
File without changes