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 +185 -0
- overturo/_experimental.py +37 -0
- overturo/_http.py +279 -0
- overturo/_logger.py +67 -0
- overturo/authorize/__init__.py +30 -0
- overturo/authorize/_jurisdiction.py +14 -0
- overturo/authorize/adapter.py +33 -0
- overturo/authorize/client.py +334 -0
- overturo/authorize/dpop.py +139 -0
- overturo/authorize/receipt.py +50 -0
- overturo/authorize/record_stream.py +76 -0
- overturo/canonicalize.py +45 -0
- overturo/consent_records/__init__.py +34 -0
- overturo/consent_records/client.py +285 -0
- overturo/decisions/__init__.py +46 -0
- overturo/decisions/client.py +338 -0
- overturo/errors.py +374 -0
- overturo/meetings/__init__.py +32 -0
- overturo/meetings/client.py +373 -0
- overturo/models.py +567 -0
- overturo/oversight/__init__.py +34 -0
- overturo/oversight/client.py +336 -0
- overturo/oversight/discovery.py +132 -0
- overturo/oversight/evidence_digest.py +25 -0
- overturo/oversight/heartbeat.py +71 -0
- overturo/oversight/iss_role.py +22 -0
- overturo/oversight/sequence.py +52 -0
- overturo/oversight/token.py +44 -0
- overturo/oversight/types.py +78 -0
- overturo/receipts/__init__.py +14 -0
- overturo/receipts/client.py +171 -0
- overturo/records.py +225 -0
- overturo/verify.py +429 -0
- overturo_sdk-2.0.0.dist-info/METADATA +144 -0
- overturo_sdk-2.0.0.dist-info/RECORD +37 -0
- overturo_sdk-2.0.0.dist-info/WHEEL +4 -0
- overturo_sdk-2.0.0.dist-info/licenses/LICENSE +201 -0
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."""
|