overturo-sdk 2.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.
overturo/__init__.py ADDED
@@ -0,0 +1,185 @@
1
+ """Overturo SDK — the trust conductor.
2
+
3
+ Backend SDK unifying the OAP authorize protocol (relying-party side)
4
+ and the Oversight Mode SDK (issuer / attester side).
5
+
6
+
7
+ NOTE: Phase H expands this surface to the full flat re-export set
8
+ (adds the oversight + decisions namespaces). This Phase E version
9
+ exposes the authorize-side public symbols plus the cross-cutting
10
+ errors / models / verify so the relocated tests pass.
11
+ """
12
+
13
+ __version__ = "2.0.0"
14
+
15
+ from . import consent_records, decisions, meetings, receipts
16
+ from .authorize import (
17
+ DpopKeyPair,
18
+ OverturoAuthorize,
19
+ OverturoDecisionAdapter,
20
+ OverturoRecordStream,
21
+ access_token_hash,
22
+ jwk_thumbprint,
23
+ peek_receipt,
24
+ sign_dpop_proof,
25
+ )
26
+ from .errors import (
27
+ OapApprovalRequired,
28
+ OapAuthorizationDenied,
29
+ OapDispatchError,
30
+ OapError,
31
+ OapEscalationDenied,
32
+ OapIntentDenied,
33
+ OapSequenceDenied,
34
+ OapTrajectoryDenied,
35
+ OapWrongRegion,
36
+ OverturoApiError,
37
+ OverturoConfigError,
38
+ OverturoError,
39
+ OverturoNetworkError,
40
+ OverturoRateLimited,
41
+ OverturoServerError,
42
+ OverturoTimeoutError,
43
+ OverturoUnauthorized,
44
+ OverturoValidationError,
45
+ ReceiptInvalid,
46
+ RecordInvalid,
47
+ is_oap_error,
48
+ )
49
+ from .models import (
50
+ AttestResult,
51
+ DecisionKind,
52
+ DecisionMode,
53
+ DecisionStatus,
54
+ DecisionStatusLiteral,
55
+ DecisionToken,
56
+ EscalateResult,
57
+ Escalation,
58
+ FlowDisclosures,
59
+ JwksKey,
60
+ OfflineVerifyResult,
61
+ OverturoBlockInvocation,
62
+ OverturoDecision,
63
+ OverturoDecisionMalformed,
64
+ OverturoEscalation,
65
+ OverturoReceipt,
66
+ PeekedReceipt,
67
+ RevokeResult,
68
+ TokenInfo,
69
+ VerifiedReceipt,
70
+ parse_decision,
71
+ )
72
+ from .oversight import (
73
+ ISS_ROLE_TO_TRUST_WEIGHT,
74
+ ISS_ROLES,
75
+ Decision,
76
+ Discovery,
77
+ IssRole,
78
+ LegalBasis,
79
+ LogLevel,
80
+ OversightConfig,
81
+ OverturoOversight,
82
+ ResolvedEndpoints,
83
+ RevocationReason,
84
+ RevocationScope,
85
+ TrustWeight,
86
+ evidence_digest,
87
+ )
88
+ from .records import (
89
+ VerifiedRecord,
90
+ canonical_bytes,
91
+ manifest_content_hash,
92
+ verify_manifest_pin,
93
+ verify_record,
94
+ )
95
+ from .verify import (
96
+ JwksCache,
97
+ raw_ed25519_to_jwk,
98
+ verify_receipt,
99
+ verify_receipt_offline,
100
+ )
101
+
102
+ # Explicit public surface. Listing every re-export here makes the
103
+ # import block clearly intentional to ruff (F401) and to readers,
104
+ # and pins the SDK's API contract — anything not listed is internal
105
+ # regardless of how it's imported.
106
+ __all__ = [
107
+ "ISS_ROLES",
108
+ "ISS_ROLE_TO_TRUST_WEIGHT",
109
+ "AttestResult",
110
+ "Decision",
111
+ "DecisionKind",
112
+ "DecisionMode",
113
+ "DecisionStatus",
114
+ "DecisionStatusLiteral",
115
+ "DecisionToken",
116
+ "Discovery",
117
+ "DpopKeyPair",
118
+ "EscalateResult",
119
+ "Escalation",
120
+ "FlowDisclosures",
121
+ "IssRole",
122
+ "JwksCache",
123
+ "JwksKey",
124
+ "LegalBasis",
125
+ "LogLevel",
126
+ "OapApprovalRequired",
127
+ "OapAuthorizationDenied",
128
+ "OapDispatchError",
129
+ "OapError",
130
+ "OapEscalationDenied",
131
+ "OapIntentDenied",
132
+ "OapSequenceDenied",
133
+ "OapTrajectoryDenied",
134
+ "OapWrongRegion",
135
+ "OfflineVerifyResult",
136
+ "OversightConfig",
137
+ "OverturoApiError",
138
+ "OverturoAuthorize",
139
+ "OverturoBlockInvocation",
140
+ "OverturoRecordStream",
141
+ "OverturoConfigError",
142
+ "OverturoDecision",
143
+ "OverturoDecisionAdapter",
144
+ "OverturoDecisionMalformed",
145
+ "OverturoError",
146
+ "OverturoEscalation",
147
+ "OverturoNetworkError",
148
+ "OverturoOversight",
149
+ "OverturoRateLimited",
150
+ "OverturoReceipt",
151
+ "OverturoServerError",
152
+ "OverturoTimeoutError",
153
+ "OverturoUnauthorized",
154
+ "OverturoValidationError",
155
+ "PeekedReceipt",
156
+ "ReceiptInvalid",
157
+ "RecordInvalid",
158
+ "ResolvedEndpoints",
159
+ "RevocationReason",
160
+ "RevocationScope",
161
+ "RevokeResult",
162
+ "TokenInfo",
163
+ "TrustWeight",
164
+ "VerifiedReceipt",
165
+ "VerifiedRecord",
166
+ "__version__",
167
+ "access_token_hash",
168
+ "canonical_bytes",
169
+ "consent_records",
170
+ "decisions",
171
+ "evidence_digest",
172
+ "manifest_content_hash",
173
+ "meetings",
174
+ "is_oap_error",
175
+ "jwk_thumbprint",
176
+ "parse_decision",
177
+ "peek_receipt",
178
+ "raw_ed25519_to_jwk",
179
+ "receipts",
180
+ "sign_dpop_proof",
181
+ "verify_manifest_pin",
182
+ "verify_receipt",
183
+ "verify_receipt_offline",
184
+ "verify_record",
185
+ ]
@@ -0,0 +1,37 @@
1
+ """@experimental decorator — marks pre-1.0 stable surfaces.
2
+
3
+ Emits PendingDeprecationWarning on first access per process per surface,
4
+ then never again. Use sparingly — only for surfaces whose API may change
5
+ in 1.x.
6
+
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import functools
12
+ import warnings
13
+ from collections.abc import Callable
14
+ from typing import TypeVar
15
+
16
+ _F = TypeVar("_F", bound=Callable[..., object])
17
+ _SEEN: set[str] = set()
18
+
19
+
20
+ def experimental(reason: str) -> Callable[[_F], _F]:
21
+ def decorator(fn: _F) -> _F:
22
+ key = f"{fn.__module__}.{fn.__qualname__}"
23
+
24
+ @functools.wraps(fn)
25
+ def wrapper(*args: object, **kwargs: object) -> object:
26
+ if key not in _SEEN:
27
+ _SEEN.add(key)
28
+ warnings.warn(
29
+ f"{key} is experimental: {reason}. API may change in overturo 1.x.",
30
+ PendingDeprecationWarning,
31
+ stacklevel=2,
32
+ )
33
+ return fn(*args, **kwargs)
34
+
35
+ return wrapper # type: ignore[return-value]
36
+
37
+ return decorator
overturo/_http.py ADDED
@@ -0,0 +1,279 @@
1
+ """
2
+ HTTP client with retry + `Retry-After`.
3
+
4
+ Wraps an `httpx.AsyncClient` with:
5
+ - Bearer auth (sourced from `TokenManager`)
6
+ - Exponential backoff on 429 / 5xx (honors `Retry-After` header)
7
+ - One-shot token-rotation retry on 401
8
+ - Per-request timeout
9
+ - SDK error class mapping by HTTP status
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import random
16
+ import re
17
+ from email.utils import parsedate_to_datetime
18
+ from typing import Any
19
+
20
+ import httpx
21
+
22
+ from ._logger import Logger
23
+ from .errors import (
24
+ OverturoApiError,
25
+ OverturoNetworkError,
26
+ OverturoRateLimited,
27
+ OverturoServerError,
28
+ OverturoTimeoutError,
29
+ OverturoUnauthorized,
30
+ OverturoValidationError,
31
+ )
32
+ from .oversight.token import TokenManager
33
+
34
+
35
+ class HttpClient:
36
+ def __init__(
37
+ self,
38
+ tokens: TokenManager,
39
+ logger: Logger,
40
+ *,
41
+ client: httpx.AsyncClient | None = None,
42
+ max_retries: int = 3,
43
+ timeout_seconds: float = 30.0,
44
+ owns_client: bool | None = None,
45
+ ) -> None:
46
+ """When `owns_client` is None, the SDK takes ownership iff it
47
+ constructed the underlying httpx client (i.e., `client` was
48
+ not supplied). Callers passing a shared `client` should pass
49
+ `owns_client=False` so `close()` doesn't shut down a client
50
+ the host app is still using.
51
+ """
52
+ self._tokens = tokens
53
+ self._logger = logger
54
+ self._max_retries = max_retries
55
+ self._timeout = timeout_seconds
56
+ self._owns_client = (client is None) if owns_client is None else owns_client
57
+ self._client = client or httpx.AsyncClient(timeout=timeout_seconds)
58
+
59
+ async def close(self) -> None:
60
+ if self._owns_client:
61
+ await self._client.aclose()
62
+
63
+ async def post_json(self, url: str, body: Any, extra_headers: dict | None = None) -> dict:
64
+ response = await self._request("POST", url, body=body, extra_headers=extra_headers)
65
+ if response.status_code == 204:
66
+ return {}
67
+ return response.json()
68
+
69
+ async def get_json(
70
+ self,
71
+ url: str,
72
+ *,
73
+ params: dict | None = None,
74
+ extra_headers: dict | None = None,
75
+ ) -> dict:
76
+ """GET helper that returns parsed JSON.
77
+
78
+ Goes through the standard retry / token-rotation loop so 401 →
79
+ refresh + retry, 429 / 5xx → exponential back-off all work.
80
+ """
81
+ response = await self._request(
82
+ "GET", url, body=None, extra_headers=extra_headers, params=params
83
+ )
84
+ if response.status_code == 204:
85
+ return {}
86
+ return response.json()
87
+
88
+ async def long_poll_get(
89
+ self,
90
+ url: str,
91
+ *,
92
+ params: dict | None = None,
93
+ extra_headers: dict | None = None,
94
+ timeout: float = 35.0,
95
+ ) -> dict:
96
+ """long-poll GET with an extended per-request timeout.
97
+
98
+ The server holds for up to 30 s; the client must
99
+ allow a few extra seconds for in-flight network + JSON
100
+ serialisation. Bypasses the standard exponential back-off loop
101
+ for 200 responses (a 200 with `wait_again: true` is the
102
+ success case and the caller's outer loop handles re-issue).
103
+ 4xx / 5xx still raise the mapped SDK error.
104
+ """
105
+ headers = {
106
+ "Authorization": f"Bearer {self._tokens.current()}",
107
+ "Accept": "application/json",
108
+ }
109
+ if extra_headers:
110
+ headers.update(extra_headers)
111
+
112
+ try:
113
+ response = await self._client.request(
114
+ "GET",
115
+ url,
116
+ params=params,
117
+ headers=headers,
118
+ timeout=timeout,
119
+ )
120
+ except httpx.TimeoutException as e:
121
+ raise OverturoTimeoutError(f"Long-poll to {url} timed out: {e}") from e
122
+ except httpx.HTTPError as e:
123
+ raise OverturoNetworkError(f"Long-poll network error on {url}: {e}") from e
124
+
125
+ if 200 <= response.status_code < 300:
126
+ if response.status_code == 204:
127
+ return {}
128
+ return response.json()
129
+
130
+ raise self._raise_error(response, url)
131
+
132
+ async def heartbeat(self, url: str) -> None:
133
+ await self._request("POST", url, body={})
134
+
135
+ async def _request(
136
+ self,
137
+ method: str,
138
+ url: str,
139
+ body: Any = None,
140
+ extra_headers: dict | None = None,
141
+ params: dict | None = None,
142
+ ) -> httpx.Response:
143
+ attempt = 0
144
+ token_retried = False
145
+
146
+ while True:
147
+ attempt += 1
148
+ try:
149
+ response = await self._send(method, url, body, extra_headers, params)
150
+ except OverturoTimeoutError:
151
+ if attempt > self._max_retries:
152
+ raise
153
+ await asyncio.sleep(_backoff_seconds(attempt))
154
+ continue
155
+ except OverturoNetworkError:
156
+ if attempt > self._max_retries:
157
+ raise
158
+ await asyncio.sleep(_backoff_seconds(attempt))
159
+ continue
160
+
161
+ if 200 <= response.status_code < 300:
162
+ return response
163
+
164
+ if response.status_code == 401 and not token_retried:
165
+ rotated = await self._tokens.refresh()
166
+ if rotated:
167
+ self._logger.info("Token rotated after 401; retrying request once")
168
+ token_retried = True
169
+ attempt = 0
170
+ continue
171
+
172
+ if response.status_code == 429 or response.status_code >= 500:
173
+ if attempt > self._max_retries:
174
+ raise self._raise_error(response, url)
175
+ retry_after = _parse_retry_after(response.headers.get("retry-after"))
176
+ wait = retry_after if retry_after is not None else _backoff_seconds(attempt)
177
+ self._logger.warn(
178
+ f"HTTP {response.status_code} on {url}; backing off {wait:.2f}s "
179
+ f"(attempt {attempt}/{self._max_retries})"
180
+ )
181
+ await asyncio.sleep(wait)
182
+ continue
183
+
184
+ raise self._raise_error(response, url)
185
+
186
+ async def _send(
187
+ self,
188
+ method: str,
189
+ url: str,
190
+ body: Any,
191
+ extra_headers: dict | None,
192
+ params: dict | None = None,
193
+ ) -> httpx.Response:
194
+ headers = {
195
+ "Authorization": f"Bearer {self._tokens.current()}",
196
+ "Accept": "application/json",
197
+ }
198
+ if body is not None:
199
+ headers["Content-Type"] = "application/json"
200
+ if extra_headers:
201
+ headers.update(extra_headers)
202
+
203
+ try:
204
+ return await self._client.request(
205
+ method,
206
+ url,
207
+ json=body,
208
+ headers=headers,
209
+ params=params,
210
+ )
211
+ except httpx.TimeoutException as e:
212
+ raise OverturoTimeoutError(f"Request to {url} timed out: {e}") from e
213
+ except httpx.HTTPError as e:
214
+ raise OverturoNetworkError(f"Network error on {url}: {e}") from e
215
+
216
+ def _raise_error(self, response: httpx.Response, url: str) -> OverturoApiError:
217
+ request_id = response.headers.get("x-request-id")
218
+ body: dict = {}
219
+ try:
220
+ body = response.json()
221
+ except ValueError:
222
+ pass
223
+
224
+ error = body.get("error") if isinstance(body, dict) else None
225
+ reason_code = error.get("reason_code") if isinstance(error, dict) else None
226
+ message = (
227
+ error.get("message")
228
+ if isinstance(error, dict)
229
+ else f"HTTP {response.status_code} on {url}"
230
+ )
231
+ detail = error.get("detail") if isinstance(error, dict) else None
232
+
233
+ kwargs = {
234
+ "reason_code": reason_code,
235
+ "detail": detail,
236
+ "request_id": request_id,
237
+ }
238
+
239
+ if response.status_code == 401:
240
+ raise OverturoUnauthorized(message, http_status=401, **kwargs)
241
+ if response.status_code in (400, 403, 404, 409, 422):
242
+ raise OverturoValidationError(message, http_status=response.status_code, **kwargs)
243
+ if response.status_code == 429:
244
+ retry_after = _parse_retry_after(response.headers.get("retry-after"))
245
+ raise OverturoRateLimited(
246
+ message,
247
+ retry_after_seconds=int(retry_after) if retry_after is not None else None,
248
+ **kwargs,
249
+ )
250
+ if response.status_code >= 500:
251
+ raise OverturoServerError(message, http_status=response.status_code, **kwargs)
252
+ raise OverturoApiError(message, http_status=response.status_code, **kwargs)
253
+
254
+
255
+ # ── helpers ────────────────────────────────────────────────────────
256
+
257
+
258
+ def _backoff_seconds(attempt: int) -> float:
259
+ base = min(1.0 * 2 ** (attempt - 1), 30.0)
260
+ return base + random.uniform(0, 0.25)
261
+
262
+
263
+ def _parse_retry_after(header: str | None) -> float | None:
264
+ if not header:
265
+ return None
266
+ header = header.strip()
267
+ if re.fullmatch(r"\d+", header):
268
+ return float(header)
269
+ try:
270
+ dt = parsedate_to_datetime(header)
271
+ except (TypeError, ValueError):
272
+ return None
273
+ if dt is None:
274
+ return None
275
+ import datetime as _dt
276
+
277
+ now = _dt.datetime.now(tz=dt.tzinfo) if dt.tzinfo else _dt.datetime.now()
278
+ delta = (dt - now).total_seconds()
279
+ return max(0.0, delta)
overturo/_logger.py ADDED
@@ -0,0 +1,67 @@
1
+ """
2
+ SDK logger with token redaction.
3
+
4
+ NEVER emits the bearer token at any log level. Token shape:
5
+ `tat_<region>_<base32>`; regex covers all regions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import re
12
+ from typing import Any
13
+
14
+ _LEVELS = {"silent": 0, "warn": 1, "info": 2, "debug": 3}
15
+
16
+ # Token shape: `tat_<region>_<urlsafe_base64>`.
17
+ # `SecureRandom.urlsafe_base64` emits `-` and `_` in the high-entropy
18
+ # suffix, so the character class MUST include them — `[A-Za-z0-9]+`
19
+ # truncates real tokens at the first `_`/`-`, leaking the suffix.
20
+ _TOKEN_PATTERN = re.compile(r"tat_[a-z]+_[A-Za-z0-9_\-]+")
21
+
22
+
23
+ class Logger:
24
+ def __init__(self, level: str = "warn") -> None:
25
+ if level not in _LEVELS:
26
+ raise ValueError(f"unknown log level: {level!r}")
27
+ self._level = level
28
+ self._stdlib = logging.getLogger("overturo")
29
+
30
+ def warn(self, message: str, **kwargs: Any) -> None:
31
+ if _LEVELS[self._level] >= _LEVELS["warn"]:
32
+ self._stdlib.warning(_redact(message), **_redact_kwargs(kwargs))
33
+
34
+ def info(self, message: str, **kwargs: Any) -> None:
35
+ if _LEVELS[self._level] >= _LEVELS["info"]:
36
+ self._stdlib.info(_redact(message), **_redact_kwargs(kwargs))
37
+
38
+ def debug(self, message: str, **kwargs: Any) -> None:
39
+ if _LEVELS[self._level] >= _LEVELS["debug"]:
40
+ self._stdlib.debug(_redact(message), **_redact_kwargs(kwargs))
41
+
42
+ @property
43
+ def level(self) -> str:
44
+ return self._level
45
+
46
+
47
+ def _redact(value: str | None) -> str:
48
+ if value is None:
49
+ return ""
50
+ return _TOKEN_PATTERN.sub("tat_<redacted>", value)
51
+
52
+
53
+ def _redact_kwargs(kwargs: dict) -> dict:
54
+ out: dict = {}
55
+ for k, v in kwargs.items():
56
+ out[k] = _scrub(v)
57
+ return out
58
+
59
+
60
+ def _scrub(value: Any) -> Any:
61
+ if isinstance(value, str):
62
+ return _redact(value)
63
+ if isinstance(value, dict):
64
+ return {k: _scrub(v) for k, v in value.items()}
65
+ if isinstance(value, list):
66
+ return [_scrub(v) for v in value]
67
+ return value
@@ -0,0 +1,30 @@
1
+ """Relying-party SDK for the Overturo Authorize protocol (OAP v1.0).
2
+
3
+ Public surface of the OAP authorize SDK — the client class,
4
+ the DPoP key plumbing, the chronicle stream iterator, and the
5
+ receipt peeker. Cross-cutting types (OverturoDecision,
6
+ JwksKey, etc.) live in `overturo.models`; errors in
7
+ `overturo.errors`; verification in `overturo.verify`.
8
+ """
9
+
10
+ from .adapter import OverturoDecisionAdapter
11
+ from .client import OverturoAuthorize
12
+ from .dpop import (
13
+ DpopKeyPair,
14
+ access_token_hash,
15
+ jwk_thumbprint,
16
+ sign_dpop_proof,
17
+ )
18
+ from .receipt import peek_receipt
19
+ from .record_stream import OverturoRecordStream
20
+
21
+ __all__ = [
22
+ "OverturoAuthorize",
23
+ "OverturoDecisionAdapter",
24
+ "OverturoRecordStream",
25
+ "DpopKeyPair",
26
+ "access_token_hash",
27
+ "jwk_thumbprint",
28
+ "sign_dpop_proof",
29
+ "peek_receipt",
30
+ ]
@@ -0,0 +1,14 @@
1
+ # audit-public-language: allow
2
+ #
3
+ # maps the SDK's public ``jurisdiction`` parameter (an ISO country)
4
+ # onto the internal authorize-context routing key the platform reads. Confined to
5
+ # this opt-out-marked module so the rest of the SDK stays under the language
6
+ # firewall and only ever speaks of ``jurisdiction`` / country.
7
+ from typing import Any
8
+
9
+
10
+ def jurisdiction_context(
11
+ jurisdiction: str, context: dict[str, Any] | None = None
12
+ ) -> dict[str, Any]:
13
+ """Merge the declared jurisdiction into the authorize context."""
14
+ return {**(context or {}), "destination_region": jurisdiction}
@@ -0,0 +1,33 @@
1
+ """Customer decision-engine adapter contract.
2
+
3
+ Customers hosting an HTTP endpoint that `conductor.policy_gate` calls
4
+ via its `external_endpoint_url` config implement this ABC to bridge
5
+ their decision engine (Cedar, OPA, in-house) to OverturoDecision.
6
+
7
+ The SDK provides the type contract; the customer hosts the HTTP server.
8
+ Concrete deployment guidance lives in the per-engine example adapters
9
+ under ``examples/`` (cedar_adapter, etc).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from abc import ABC, abstractmethod
15
+ from typing import Any
16
+
17
+ from ..models import OverturoDecision
18
+
19
+ __all__ = ["OverturoDecisionAdapter"]
20
+
21
+
22
+ class OverturoDecisionAdapter(ABC):
23
+ """Customer adapter shape. Implementations receive a parsed OAP
24
+ authorize request (as a dict matching the wire body shape) and
25
+ return an OverturoDecision.
26
+
27
+ Partial decisions are allowed — the SDK fills defaults for any
28
+ unspecified fields when round-tripping back to the wire layer.
29
+ """
30
+
31
+ @abstractmethod
32
+ def evaluate(self, request: dict[str, Any]) -> OverturoDecision:
33
+ """Evaluate one OAP authorize request and return a decision."""