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.
- odcp_client/__init__.py +23 -0
- odcp_client/_http.py +147 -0
- odcp_client/branding.py +150 -0
- odcp_client/client.py +291 -0
- odcp_client/exceptions.py +36 -0
- odcp_client/jwks.py +151 -0
- odcp_client/license.py +122 -0
- odcp_client/rbac.py +95 -0
- odcp_client/whoami.py +211 -0
- odcp_client-1.0.0.dist-info/METADATA +232 -0
- odcp_client-1.0.0.dist-info/RECORD +13 -0
- odcp_client-1.0.0.dist-info/WHEEL +5 -0
- odcp_client-1.0.0.dist-info/top_level.txt +1 -0
odcp_client/__init__.py
ADDED
|
@@ -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
|
odcp_client/branding.py
ADDED
|
@@ -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
|
+
"""
|