conduit-admin-client 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,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: conduit-admin-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared client for the Conduit Admin authorization service
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: cachetools>=5.3
|
|
7
|
+
Requires-Dist: httpx>=0.25
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: httpx>=0.25; extra == 'dev'
|
|
10
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
11
|
+
Requires-Dist: respx>=0.20; extra == 'dev'
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "conduit-admin-client"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Shared client for the Conduit Admin authorization service"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"httpx>=0.25",
|
|
8
|
+
"cachetools>=5.3",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[project.optional-dependencies]
|
|
12
|
+
dev = [
|
|
13
|
+
"pytest>=7.0",
|
|
14
|
+
"respx>=0.20",
|
|
15
|
+
"httpx>=0.25",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["hatchling"]
|
|
20
|
+
build-backend = "hatchling.build"
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["src/conduit_admin_client"]
|
|
24
|
+
|
|
25
|
+
[tool.pytest.ini_options]
|
|
26
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Shared client for the Conduit Admin authorization service.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
from conduit_admin_client import AdminClient
|
|
5
|
+
|
|
6
|
+
client = AdminClient(
|
|
7
|
+
base_url="http://admin-api:8000",
|
|
8
|
+
app_code="extractor",
|
|
9
|
+
service_token="shared-secret",
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
# In a request handler, forward the user's JWT
|
|
13
|
+
client.set_auth_token(jwt_token)
|
|
14
|
+
|
|
15
|
+
# Get authz context (cached 60s per user)
|
|
16
|
+
ctx = client.get_user_context(object_id)
|
|
17
|
+
|
|
18
|
+
# JIT provision if user not found
|
|
19
|
+
if not ctx:
|
|
20
|
+
ctx = client.jit_provision(object_id, email, display_name, tenant_id)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from conduit_admin_client.client import AdminClient
|
|
24
|
+
|
|
25
|
+
__all__ = ["AdminClient"]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""HTTP client for the Conduit Admin authorization service.
|
|
2
|
+
|
|
3
|
+
Provides authz resolution and JIT user provisioning with per-user TTL caching.
|
|
4
|
+
Each consumer app creates an instance with its own app_code (e.g. "extractor",
|
|
5
|
+
"mapper", "scripter") so the Admin API resolves app-scoped roles correctly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import threading
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from cachetools import TTLCache
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AdminClient:
|
|
18
|
+
"""Client for the Conduit Admin authz API.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
base_url: Admin API base URL (e.g. "http://admin-api:8000").
|
|
22
|
+
app_code: This app's code for role resolution (e.g. "extractor").
|
|
23
|
+
service_token: Shared secret for service-to-service auth (optional).
|
|
24
|
+
cache_ttl: Seconds to cache authz responses per user. Default 60.
|
|
25
|
+
cache_maxsize: Max cached users. Default 500.
|
|
26
|
+
timeout: HTTP request timeout in seconds. Default 5.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
base_url: str,
|
|
32
|
+
app_code: str,
|
|
33
|
+
service_token: str | None = None,
|
|
34
|
+
cache_ttl: int = 60,
|
|
35
|
+
cache_maxsize: int = 500,
|
|
36
|
+
timeout: float = 5.0,
|
|
37
|
+
):
|
|
38
|
+
self._base_url = base_url.rstrip("/")
|
|
39
|
+
self._app_code = app_code
|
|
40
|
+
self._service_token = service_token
|
|
41
|
+
self._timeout = timeout
|
|
42
|
+
self._cache: TTLCache = TTLCache(maxsize=cache_maxsize, ttl=cache_ttl)
|
|
43
|
+
self._local = threading.local()
|
|
44
|
+
|
|
45
|
+
def set_auth_token(self, token: str | None):
|
|
46
|
+
"""Store the current request's JWT token for forwarding to Admin API."""
|
|
47
|
+
self._local.token = token
|
|
48
|
+
|
|
49
|
+
def _headers(self) -> dict:
|
|
50
|
+
"""Build headers -- forwards JWT token + service token."""
|
|
51
|
+
h: dict[str, str] = {"Content-Type": "application/json"}
|
|
52
|
+
if self._service_token:
|
|
53
|
+
h["X-Conduit-Service-Token"] = self._service_token
|
|
54
|
+
token = getattr(self._local, "token", None)
|
|
55
|
+
if token:
|
|
56
|
+
h["Authorization"] = f"Bearer {token}"
|
|
57
|
+
return h
|
|
58
|
+
|
|
59
|
+
def get_user_context(self, object_id: str) -> dict | None:
|
|
60
|
+
"""Get authorization context for a user from Admin.
|
|
61
|
+
|
|
62
|
+
Returns the full authz response dict, or None if user not found.
|
|
63
|
+
Cached for ``cache_ttl`` seconds per object_id.
|
|
64
|
+
"""
|
|
65
|
+
cache_key = f"authz:{object_id}"
|
|
66
|
+
cached = self._cache.get(cache_key)
|
|
67
|
+
if cached is not None:
|
|
68
|
+
return cached
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
resp = httpx.get(
|
|
72
|
+
f"{self._base_url}/api/authz/{object_id}",
|
|
73
|
+
params={"app": self._app_code},
|
|
74
|
+
headers=self._headers(),
|
|
75
|
+
timeout=self._timeout,
|
|
76
|
+
)
|
|
77
|
+
if resp.status_code == 200:
|
|
78
|
+
data = resp.json()
|
|
79
|
+
self._cache[cache_key] = data
|
|
80
|
+
return data
|
|
81
|
+
elif resp.status_code == 404:
|
|
82
|
+
return None
|
|
83
|
+
else:
|
|
84
|
+
logger.error("Admin authz returned %s: %s", resp.status_code, resp.text)
|
|
85
|
+
return None
|
|
86
|
+
except Exception as e:
|
|
87
|
+
logger.error("Failed to call Admin authz API: %s", e)
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
def jit_provision(
|
|
91
|
+
self,
|
|
92
|
+
object_id: str,
|
|
93
|
+
email: str,
|
|
94
|
+
display_name: str,
|
|
95
|
+
tenant_id: str | None = None,
|
|
96
|
+
) -> dict | None:
|
|
97
|
+
"""JIT-provision a user via Admin and return authz context."""
|
|
98
|
+
try:
|
|
99
|
+
resp = httpx.post(
|
|
100
|
+
f"{self._base_url}/api/authz/jit",
|
|
101
|
+
json={
|
|
102
|
+
"object_id": object_id,
|
|
103
|
+
"email": email,
|
|
104
|
+
"display_name": display_name,
|
|
105
|
+
"tenant_id": tenant_id,
|
|
106
|
+
},
|
|
107
|
+
headers=self._headers(),
|
|
108
|
+
timeout=self._timeout,
|
|
109
|
+
)
|
|
110
|
+
if resp.status_code == 200:
|
|
111
|
+
data = resp.json()
|
|
112
|
+
self._cache[f"authz:{object_id}"] = data
|
|
113
|
+
return data
|
|
114
|
+
else:
|
|
115
|
+
logger.error("Admin JIT returned %s: %s", resp.status_code, resp.text)
|
|
116
|
+
return None
|
|
117
|
+
except Exception as e:
|
|
118
|
+
logger.error("Failed to call Admin JIT API: %s", e)
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
def invalidate_cache(self, object_id: str | None = None):
|
|
122
|
+
"""Clear cached authz data for one user or all users."""
|
|
123
|
+
if object_id:
|
|
124
|
+
self._cache.pop(f"authz:{object_id}", None)
|
|
125
|
+
else:
|
|
126
|
+
self._cache.clear()
|