odcp-client 1.0.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.
@@ -0,0 +1,23 @@
1
+ """odcp-client — official Python SDK for the Owndivision Control Plane.
2
+
3
+ Public API is frozen at v1.0. Only the symbols re-exported below are
4
+ considered stable. Anything else (modules with leading underscore, internal
5
+ classes) may change without a major version bump.
6
+ """
7
+
8
+ from odcp_client.client import AsyncOdcpClient, OdcpClient
9
+ from odcp_client.exceptions import (
10
+ LicenseInvalid,
11
+ OdcpError,
12
+ WhoamiNotFound,
13
+ )
14
+
15
+ __all__ = [
16
+ "OdcpClient",
17
+ "AsyncOdcpClient",
18
+ "OdcpError",
19
+ "LicenseInvalid",
20
+ "WhoamiNotFound",
21
+ ]
22
+
23
+ __version__ = "1.0.0"
odcp_client/_http.py ADDED
@@ -0,0 +1,147 @@
1
+ """Internal HTTP helpers — sync + async with bounded retries.
2
+
3
+ Retry policy:
4
+ - On ``httpx.ConnectError``, ``httpx.ReadTimeout``, or 5xx response
5
+ - Up to ``retry_max`` additional attempts (so total = retry_max + 1)
6
+ - Backoff: ``min(0.1 * 2**attempt, 2.0)`` seconds
7
+ - Never retry on 4xx
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import time
14
+ from typing import Any, Mapping
15
+
16
+ import httpx
17
+
18
+ _RETRYABLE_STATUS = (500, 502, 503, 504)
19
+ _BACKOFF_BASE_SECONDS = 0.1
20
+ _BACKOFF_MAX_SECONDS = 2.0
21
+
22
+
23
+ def _backoff_seconds(attempt: int) -> float:
24
+ return min(_BACKOFF_BASE_SECONDS * (2**attempt), _BACKOFF_MAX_SECONDS)
25
+
26
+
27
+ def _auth_headers(api_token: str | None) -> dict[str, str]:
28
+ if not api_token:
29
+ return {}
30
+ return {"Authorization": f"Bearer {api_token}"}
31
+
32
+
33
+ class SyncHttpClient:
34
+ def __init__(
35
+ self,
36
+ *,
37
+ base_url: str,
38
+ api_token: str | None,
39
+ timeout: float,
40
+ retry_max: int,
41
+ ) -> None:
42
+ self._client = httpx.Client(base_url=base_url, timeout=timeout)
43
+ self._api_token = api_token
44
+ self._retry_max = retry_max
45
+
46
+ def close(self) -> None:
47
+ self._client.close()
48
+
49
+ def request(
50
+ self,
51
+ method: str,
52
+ url: str,
53
+ *,
54
+ headers: Mapping[str, str] | None = None,
55
+ params: Mapping[str, Any] | None = None,
56
+ json: Any | None = None,
57
+ authenticated: bool = True,
58
+ ) -> httpx.Response:
59
+ merged_headers: dict[str, str] = {}
60
+ if authenticated:
61
+ merged_headers.update(_auth_headers(self._api_token))
62
+ if headers:
63
+ merged_headers.update(headers)
64
+
65
+ last_exc: Exception | None = None
66
+ for attempt in range(self._retry_max + 1):
67
+ try:
68
+ response = self._client.request(
69
+ method,
70
+ url,
71
+ headers=merged_headers,
72
+ params=params,
73
+ json=json,
74
+ )
75
+ except (httpx.ConnectError, httpx.ReadTimeout) as exc:
76
+ last_exc = exc
77
+ if attempt >= self._retry_max:
78
+ raise
79
+ time.sleep(_backoff_seconds(attempt))
80
+ continue
81
+
82
+ if response.status_code in _RETRYABLE_STATUS and attempt < self._retry_max:
83
+ time.sleep(_backoff_seconds(attempt))
84
+ continue
85
+ return response
86
+
87
+ # Unreachable but keeps mypy happy.
88
+ assert last_exc is not None
89
+ raise last_exc
90
+
91
+
92
+ class AsyncHttpClient:
93
+ def __init__(
94
+ self,
95
+ *,
96
+ base_url: str,
97
+ api_token: str | None,
98
+ timeout: float,
99
+ retry_max: int,
100
+ ) -> None:
101
+ self._client = httpx.AsyncClient(base_url=base_url, timeout=timeout)
102
+ self._api_token = api_token
103
+ self._retry_max = retry_max
104
+
105
+ async def aclose(self) -> None:
106
+ await self._client.aclose()
107
+
108
+ async def request(
109
+ self,
110
+ method: str,
111
+ url: str,
112
+ *,
113
+ headers: Mapping[str, str] | None = None,
114
+ params: Mapping[str, Any] | None = None,
115
+ json: Any | None = None,
116
+ authenticated: bool = True,
117
+ ) -> httpx.Response:
118
+ merged_headers: dict[str, str] = {}
119
+ if authenticated:
120
+ merged_headers.update(_auth_headers(self._api_token))
121
+ if headers:
122
+ merged_headers.update(headers)
123
+
124
+ last_exc: Exception | None = None
125
+ for attempt in range(self._retry_max + 1):
126
+ try:
127
+ response = await self._client.request(
128
+ method,
129
+ url,
130
+ headers=merged_headers,
131
+ params=params,
132
+ json=json,
133
+ )
134
+ except (httpx.ConnectError, httpx.ReadTimeout) as exc:
135
+ last_exc = exc
136
+ if attempt >= self._retry_max:
137
+ raise
138
+ await asyncio.sleep(_backoff_seconds(attempt))
139
+ continue
140
+
141
+ if response.status_code in _RETRYABLE_STATUS and attempt < self._retry_max:
142
+ await asyncio.sleep(_backoff_seconds(attempt))
143
+ continue
144
+ return response
145
+
146
+ assert last_exc is not None
147
+ raise last_exc
@@ -0,0 +1,150 @@
1
+ """Branding fetch + cache."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any
7
+
8
+ from odcp_client._http import AsyncHttpClient, SyncHttpClient
9
+ from odcp_client.rbac import CacheBackend, InMemoryTTLCache
10
+ from odcp_contracts.branding import BrandingSnapshot
11
+
12
+ BRANDING_PATH_TEMPLATE = "/api/v1/branding/{slug}"
13
+
14
+
15
+ def _cache_key(workspace_slug: str) -> str:
16
+ return f"branding:v1:{workspace_slug}"
17
+
18
+
19
+ def _etag_key(workspace_slug: str) -> str:
20
+ return f"branding:v1:{workspace_slug}:etag"
21
+
22
+
23
+ class BrandingCache:
24
+ """Sync branding cache for DP login rendering."""
25
+
26
+ def __init__(
27
+ self,
28
+ *,
29
+ http: SyncHttpClient,
30
+ backend: CacheBackend,
31
+ ttl_seconds: int = 300,
32
+ ) -> None:
33
+ self._http = http
34
+ self._backend = backend
35
+ self._ttl = ttl_seconds
36
+
37
+ def get(self, workspace_slug: str) -> BrandingSnapshot:
38
+ slug = _validate_slug(workspace_slug)
39
+ cached = self._backend.get(_cache_key(slug))
40
+ if cached is not None:
41
+ return cached
42
+ snapshot = self._fetch_branding(slug)
43
+ self._backend.set(_cache_key(slug), snapshot, self._ttl)
44
+ return snapshot
45
+
46
+ def _fetch_branding(self, workspace_slug: str) -> BrandingSnapshot:
47
+ etag = self._backend.get(_etag_key(workspace_slug))
48
+ response = self._http.request(
49
+ "GET",
50
+ BRANDING_PATH_TEMPLATE.format(slug=workspace_slug),
51
+ headers=_etag_headers(etag),
52
+ authenticated=False,
53
+ )
54
+ if response.status_code == 304:
55
+ cached = self._backend.get(_cache_key(workspace_slug))
56
+ if cached is not None:
57
+ return cached
58
+ response = self._http.request(
59
+ "GET",
60
+ BRANDING_PATH_TEMPLATE.format(slug=workspace_slug),
61
+ authenticated=False,
62
+ )
63
+ response.raise_for_status()
64
+ snapshot = BrandingSnapshot.model_validate(response.json())
65
+ normalised_etag = _normalise_etag(response.headers.get("etag"))
66
+ if normalised_etag is not None:
67
+ self._backend.set(_etag_key(workspace_slug), normalised_etag, self._ttl)
68
+ return snapshot
69
+
70
+
71
+ class AsyncBrandingCache:
72
+ def __init__(
73
+ self,
74
+ *,
75
+ http: AsyncHttpClient,
76
+ backend: CacheBackend,
77
+ ttl_seconds: int = 300,
78
+ ) -> None:
79
+ self._http = http
80
+ self._backend = backend
81
+ self._ttl = ttl_seconds
82
+ self._backend_is_blocking = not isinstance(backend, InMemoryTTLCache)
83
+
84
+ async def get(self, workspace_slug: str) -> BrandingSnapshot:
85
+ slug = _validate_slug(workspace_slug)
86
+ cached = await self._cache_get(_cache_key(slug))
87
+ if cached is not None:
88
+ return cached
89
+ snapshot = await self._fetch_branding(slug)
90
+ await self._cache_set(_cache_key(slug), snapshot)
91
+ return snapshot
92
+
93
+ async def _fetch_branding(self, workspace_slug: str) -> BrandingSnapshot:
94
+ etag = await self._cache_get(_etag_key(workspace_slug))
95
+ response = await self._http.request(
96
+ "GET",
97
+ BRANDING_PATH_TEMPLATE.format(slug=workspace_slug),
98
+ headers=_etag_headers(etag),
99
+ authenticated=False,
100
+ )
101
+ if response.status_code == 304:
102
+ cached = await self._cache_get(_cache_key(workspace_slug))
103
+ if cached is not None:
104
+ return cached
105
+ response = await self._http.request(
106
+ "GET",
107
+ BRANDING_PATH_TEMPLATE.format(slug=workspace_slug),
108
+ authenticated=False,
109
+ )
110
+ response.raise_for_status()
111
+ snapshot = BrandingSnapshot.model_validate(response.json())
112
+ normalised_etag = _normalise_etag(response.headers.get("etag"))
113
+ if normalised_etag is not None:
114
+ await self._cache_set(_etag_key(workspace_slug), normalised_etag)
115
+ return snapshot
116
+
117
+ # ---- backend wrappers --------------------------------------------
118
+
119
+ async def _cache_get(self, key: str) -> Any | None:
120
+ if self._backend_is_blocking:
121
+ return await asyncio.to_thread(self._backend.get, key)
122
+ return self._backend.get(key)
123
+
124
+ async def _cache_set(self, key: str, value: Any) -> None:
125
+ if self._backend_is_blocking:
126
+ await asyncio.to_thread(self._backend.set, key, value, self._ttl)
127
+ else:
128
+ self._backend.set(key, value, self._ttl)
129
+
130
+
131
+ def _validate_slug(workspace_slug: str) -> str:
132
+ slug = workspace_slug.strip()
133
+ if not slug:
134
+ raise ValueError("workspace_slug is required")
135
+ return slug
136
+
137
+
138
+ def _normalise_etag(value: str | None) -> str | None:
139
+ if value is None:
140
+ return None
141
+ etag = value.strip()
142
+ if etag.startswith("W/"):
143
+ etag = etag[2:].strip()
144
+ return etag.strip('"') or None
145
+
146
+
147
+ def _etag_headers(etag: Any | None) -> dict[str, str] | None:
148
+ if not isinstance(etag, str):
149
+ return None
150
+ return {"If-None-Match": f'"{etag}"'}
odcp_client/client.py ADDED
@@ -0,0 +1,291 @@
1
+ """OdcpClient + AsyncOdcpClient — official Python SDK for the Owndivision CP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+ from uuid import UUID
7
+
8
+ from odcp_client._http import AsyncHttpClient, SyncHttpClient
9
+ from odcp_client.branding import AsyncBrandingCache, BrandingCache
10
+ from odcp_client.exceptions import LicenseInvalid
11
+ from odcp_client.rbac import CacheBackend, InMemoryTTLCache
12
+ from odcp_client.whoami import AsyncWhoamiCache, WhoamiCache
13
+
14
+ if TYPE_CHECKING:
15
+ from odcp_client.jwks import AsyncJwksCache, JwksCache
16
+ from odcp_contracts.branding import BrandingSnapshot
17
+ from odcp_contracts.licensing import DeploymentLicenseClaims
18
+ from odcp_contracts.whoami import WhoAmIResponse
19
+
20
+
21
+ class _BaseClient:
22
+ def __init__(
23
+ self,
24
+ *,
25
+ base_url: str,
26
+ api_token: str | None = None,
27
+ license_token: str | None = None,
28
+ whoami_cache_ttl: int = 60,
29
+ jwks_cache_ttl: int = 3600,
30
+ http_timeout: float = 5.0,
31
+ retry_max: int = 3,
32
+ cache_backend: Any | None = None,
33
+ ) -> None:
34
+ if not base_url:
35
+ raise ValueError("base_url is required")
36
+ # Strip trailing slash so callers can append paths consistently.
37
+ self._base_url = base_url.rstrip("/")
38
+ self._api_token = api_token
39
+ self._license_token = license_token
40
+ self._whoami_cache_ttl = whoami_cache_ttl
41
+ self._jwks_cache_ttl = jwks_cache_ttl
42
+ self._http_timeout = http_timeout
43
+ self._retry_max = retry_max
44
+ self._cache_backend: CacheBackend = cache_backend # type: ignore[assignment]
45
+
46
+
47
+ class OdcpClient(_BaseClient):
48
+ def __init__(
49
+ self,
50
+ *,
51
+ signing_issuer: str = "owndivision-control-plane",
52
+ signing_audience: str = "owndivision-dp",
53
+ **kwargs: Any,
54
+ ) -> None:
55
+ super().__init__(**kwargs)
56
+ self._http = SyncHttpClient(
57
+ base_url=self._base_url,
58
+ api_token=self._api_token,
59
+ timeout=self._http_timeout,
60
+ retry_max=self._retry_max,
61
+ )
62
+ self._signing_issuer = signing_issuer
63
+ self._signing_audience = signing_audience
64
+ self._cache_backend = self._cache_backend or InMemoryTTLCache()
65
+ self._jwks: JwksCache | None = None
66
+ self._whoami_cache = WhoamiCache(
67
+ http=self._http,
68
+ backend=self._cache_backend,
69
+ ttl_seconds=self._whoami_cache_ttl,
70
+ )
71
+ self._branding_cache = BrandingCache(
72
+ http=self._http,
73
+ backend=self._cache_backend,
74
+ ttl_seconds=300,
75
+ )
76
+
77
+ def _jwks_cache(self) -> JwksCache:
78
+ if self._jwks is None:
79
+ from odcp_client.jwks import JwksCache
80
+
81
+ self._jwks = JwksCache(http=self._http, ttl_seconds=self._jwks_cache_ttl)
82
+ return self._jwks
83
+
84
+ def verify_license(self, token: str | None = None) -> DeploymentLicenseClaims:
85
+ """Verify the configured (or supplied) license token.
86
+
87
+ If ``token`` is omitted, uses the ``license_token`` passed to the
88
+ constructor. Raises ``LicenseInvalid`` if no token is available.
89
+ """
90
+ from odcp_client.license import verify_license_jwt
91
+
92
+ license_token = token or self._license_token
93
+ if not license_token:
94
+ raise LicenseInvalid("malformed", "No license token configured or supplied")
95
+ return verify_license_jwt(
96
+ license_token,
97
+ jwks=self._jwks_cache(),
98
+ issuer=self._signing_issuer,
99
+ audience=self._signing_audience,
100
+ )
101
+
102
+ def whoami(
103
+ self,
104
+ *,
105
+ provider: str,
106
+ subject: str,
107
+ workspace_id: UUID | str,
108
+ ) -> WhoAmIResponse:
109
+ return self._whoami_cache.get(
110
+ provider=provider,
111
+ subject=subject,
112
+ workspace_id=workspace_id,
113
+ )
114
+
115
+ def has_permission(
116
+ self,
117
+ *,
118
+ provider: str,
119
+ subject: str,
120
+ workspace_id: UUID | str,
121
+ permission: str,
122
+ ) -> bool:
123
+ """True if the user has the named permission in the given workspace.
124
+
125
+ Reads from the membership's permissions list. Returns False if
126
+ the user has no membership for that workspace, even if they have
127
+ other memberships.
128
+ """
129
+ whoami = self.whoami(
130
+ provider=provider, subject=subject, workspace_id=workspace_id
131
+ )
132
+ memberships = getattr(whoami, "workspaces", None) or []
133
+ for m in memberships:
134
+ if str(getattr(m, "workspace_id", "")) != str(workspace_id):
135
+ continue
136
+ # Direct permissions list on the membership (v2 schema).
137
+ direct_perms: list[Any] = getattr(m, "permissions", None) or []
138
+ # Also check role.permissions for forward-compat with future schemas.
139
+ role_perms: list[Any] = (
140
+ getattr(getattr(m, "role", None), "permissions", None) or []
141
+ )
142
+ all_perms = direct_perms + role_perms
143
+ permission_codes: set[str] = set()
144
+ for p in all_perms:
145
+ if isinstance(p, str):
146
+ permission_codes.add(p)
147
+ elif isinstance(p, dict):
148
+ code = p.get("code")
149
+ if isinstance(code, str):
150
+ permission_codes.add(code)
151
+ else:
152
+ code = getattr(p, "code", None)
153
+ if isinstance(code, str):
154
+ permission_codes.add(code)
155
+ return permission in permission_codes
156
+ return False
157
+
158
+ def branding(self, workspace_slug: str) -> BrandingSnapshot:
159
+ """Fetch and cache the public branding snapshot for a workspace."""
160
+ if not workspace_slug:
161
+ raise ValueError("workspace_slug is required")
162
+ return self._branding_cache.get(workspace_slug)
163
+
164
+ def close(self) -> None:
165
+ self._http.close()
166
+
167
+ def __enter__(self) -> OdcpClient:
168
+ return self
169
+
170
+ def __exit__(self, *_exc: Any) -> None:
171
+ self.close()
172
+
173
+
174
+ class AsyncOdcpClient(_BaseClient):
175
+ def __init__(
176
+ self,
177
+ *,
178
+ signing_issuer: str = "owndivision-control-plane",
179
+ signing_audience: str = "owndivision-dp",
180
+ **kwargs: Any,
181
+ ) -> None:
182
+ super().__init__(**kwargs)
183
+ self._http = AsyncHttpClient(
184
+ base_url=self._base_url,
185
+ api_token=self._api_token,
186
+ timeout=self._http_timeout,
187
+ retry_max=self._retry_max,
188
+ )
189
+ self._signing_issuer = signing_issuer
190
+ self._signing_audience = signing_audience
191
+ self._cache_backend = self._cache_backend or InMemoryTTLCache()
192
+ self._jwks: AsyncJwksCache | None = None
193
+ self._whoami_cache = AsyncWhoamiCache(
194
+ http=self._http,
195
+ backend=self._cache_backend,
196
+ ttl_seconds=self._whoami_cache_ttl,
197
+ )
198
+ self._branding_cache = AsyncBrandingCache(
199
+ http=self._http,
200
+ backend=self._cache_backend,
201
+ ttl_seconds=300,
202
+ )
203
+
204
+ def _jwks_cache(self) -> AsyncJwksCache:
205
+ if self._jwks is None:
206
+ from odcp_client.jwks import AsyncJwksCache
207
+
208
+ self._jwks = AsyncJwksCache(
209
+ http=self._http, ttl_seconds=self._jwks_cache_ttl
210
+ )
211
+ return self._jwks
212
+
213
+ async def verify_license(self, token: str | None = None) -> DeploymentLicenseClaims:
214
+ from odcp_client.license import averify_license_jwt
215
+
216
+ license_token = token or self._license_token
217
+ if not license_token:
218
+ raise LicenseInvalid("malformed", "No license token configured or supplied")
219
+ return await averify_license_jwt(
220
+ license_token,
221
+ jwks=self._jwks_cache(),
222
+ issuer=self._signing_issuer,
223
+ audience=self._signing_audience,
224
+ )
225
+
226
+ async def whoami(
227
+ self,
228
+ *,
229
+ provider: str,
230
+ subject: str,
231
+ workspace_id: UUID | str,
232
+ ) -> WhoAmIResponse:
233
+ return await self._whoami_cache.get(
234
+ provider=provider,
235
+ subject=subject,
236
+ workspace_id=workspace_id,
237
+ )
238
+
239
+ async def has_permission(
240
+ self,
241
+ *,
242
+ provider: str,
243
+ subject: str,
244
+ workspace_id: UUID | str,
245
+ permission: str,
246
+ ) -> bool:
247
+ whoami = await self.whoami(
248
+ provider=provider, subject=subject, workspace_id=workspace_id
249
+ )
250
+ memberships = (
251
+ getattr(whoami, "workspaces", None)
252
+ or getattr(whoami, "memberships", None)
253
+ or []
254
+ )
255
+ for m in memberships:
256
+ if str(getattr(m, "workspace_id", "")) != str(workspace_id):
257
+ continue
258
+ direct_perms: list[Any] = getattr(m, "permissions", None) or []
259
+ role_perms: list[Any] = (
260
+ getattr(getattr(m, "role", None), "permissions", None) or []
261
+ )
262
+ all_perms = direct_perms + role_perms
263
+ permission_codes: set[str] = set()
264
+ for p in all_perms:
265
+ if isinstance(p, str):
266
+ permission_codes.add(p)
267
+ elif isinstance(p, dict):
268
+ code = p.get("code")
269
+ if isinstance(code, str):
270
+ permission_codes.add(code)
271
+ else:
272
+ code = getattr(p, "code", None)
273
+ if isinstance(code, str):
274
+ permission_codes.add(code)
275
+ return permission in permission_codes
276
+ return False
277
+
278
+ async def branding(self, workspace_slug: str) -> BrandingSnapshot:
279
+ """Fetch and cache the public branding snapshot for a workspace."""
280
+ if not workspace_slug:
281
+ raise ValueError("workspace_slug is required")
282
+ return await self._branding_cache.get(workspace_slug)
283
+
284
+ async def aclose(self) -> None:
285
+ await self._http.aclose()
286
+
287
+ async def __aenter__(self) -> AsyncOdcpClient:
288
+ return self
289
+
290
+ async def __aexit__(self, *_exc: Any) -> None:
291
+ await self.aclose()
@@ -0,0 +1,36 @@
1
+ """Exception hierarchy for odcp-client.
2
+
3
+ All exceptions inherit from ``OdcpError`` so DP code can do a single
4
+ ``except OdcpError`` if it wants a catch-all.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class OdcpError(Exception):
11
+ """Base for all errors raised by odcp-client."""
12
+
13
+
14
+ class LicenseInvalid(OdcpError):
15
+ """Raised when a license token cannot be verified.
16
+
17
+ The ``reason`` attribute is a short stable code suitable for logging /
18
+ metrics: ``"expired"``, ``"bad_signature"``, ``"wrong_issuer"``,
19
+ ``"wrong_audience"``, ``"unknown_kid"``, ``"malformed"``.
20
+ """
21
+
22
+ def __init__(self, reason: str, message: str | None = None) -> None:
23
+ self.reason = reason
24
+ super().__init__(message or reason)
25
+
26
+
27
+ class WhoamiNotFound(OdcpError):
28
+ """Raised when CP responds 404 to a whoami lookup."""
29
+
30
+
31
+ class RbacRevisionMismatch(OdcpError):
32
+ """Internal — used by the cache layer to signal a stale entry.
33
+
34
+ Not part of the public API. Callers must never see this; the cache
35
+ refresh path catches it and re-fetches.
36
+ """