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.
- mic_platform-0.1.0/.gitignore +28 -0
- mic_platform-0.1.0/PKG-INFO +52 -0
- mic_platform-0.1.0/README.md +36 -0
- mic_platform-0.1.0/mic/authz/__init__.py +60 -0
- mic_platform-0.1.0/mic/authz/base.py +44 -0
- mic_platform-0.1.0/mic/authz/errors.py +27 -0
- mic_platform-0.1.0/mic/authz/in_memory.py +91 -0
- mic_platform-0.1.0/mic/authz/opa.py +131 -0
- mic_platform-0.1.0/mic/authz/openfga.py +167 -0
- mic_platform-0.1.0/mic/py.typed +0 -0
- mic_platform-0.1.0/mic/ratelimit/__init__.py +89 -0
- mic_platform-0.1.0/mic/ratelimit/base.py +64 -0
- mic_platform-0.1.0/mic/ratelimit/in_memory.py +97 -0
- mic_platform-0.1.0/mic/ratelimit/middleware.py +138 -0
- mic_platform-0.1.0/mic/ratelimit/redis_backend.py +269 -0
- mic_platform-0.1.0/mic/ratelimit/spec.py +385 -0
- mic_platform-0.1.0/pyproject.toml +44 -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,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
|