devora-python 0.1.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.
- devora_python-0.1.0.dist-info/METADATA +132 -0
- devora_python-0.1.0.dist-info/RECORD +20 -0
- devora_python-0.1.0.dist-info/WHEEL +4 -0
- devora_python-0.1.0.dist-info/licenses/LICENSE +21 -0
- devora_sdk/__init__.py +92 -0
- devora_sdk/api_url_gen.py +2 -0
- devora_sdk/browser_session.py +45 -0
- devora_sdk/constants.py +49 -0
- devora_sdk/guard.py +485 -0
- devora_sdk/handler.py +298 -0
- devora_sdk/hmac.py +67 -0
- devora_sdk/models.py +79 -0
- devora_sdk/policy.py +208 -0
- devora_sdk/py.typed +1 -0
- devora_sdk/replay.py +41 -0
- devora_sdk/sdk.py +390 -0
- devora_sdk/security.py +37 -0
- devora_sdk/signing.py +240 -0
- devora_sdk/transport.py +158 -0
- devora_sdk/utils.py +164 -0
devora_sdk/handler.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import logging
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Callable, Mapping, Optional, Union
|
|
7
|
+
|
|
8
|
+
from .constants import DEFAULT_MAX_BODY_SIZE_BYTES, DEVORA_ENDPOINTS
|
|
9
|
+
from .models import DevoraImpersonationContext, DevoraRequest, SDKRoute
|
|
10
|
+
from .security import validate_timestamp_tolerance
|
|
11
|
+
from .signing import get_single_header, parse_verified_json_body, parse_verified_query
|
|
12
|
+
from .utils import (
|
|
13
|
+
create_error_response,
|
|
14
|
+
create_success_response,
|
|
15
|
+
match_path,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("devora_sdk")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class AdapterRequest:
|
|
23
|
+
"""A request exactly as received. The signature covers these bytes.
|
|
24
|
+
|
|
25
|
+
``path`` is relative to the SDK mount and still percent-encoded; ``query`` is
|
|
26
|
+
everything after the first ``?``; ``body`` is the raw bytes; ``headers`` is a
|
|
27
|
+
mapping or a list of ``(name, value)`` pairs (so duplicates are visible).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
method: str
|
|
31
|
+
path: str
|
|
32
|
+
query: str
|
|
33
|
+
body: bytes
|
|
34
|
+
headers: Any
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class ProcessRequestOptions:
|
|
39
|
+
timestamp_tolerance: Optional[int] = None
|
|
40
|
+
max_body_size: int = DEFAULT_MAX_BODY_SIZE_BYTES
|
|
41
|
+
|
|
42
|
+
def __post_init__(self) -> None:
|
|
43
|
+
validate_timestamp_tolerance(self.timestamp_tolerance)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class _PreparedRequest:
|
|
48
|
+
route: SDKRoute
|
|
49
|
+
devora_request: DevoraRequest
|
|
50
|
+
record: Callable[[str, Optional[str]], None]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def process_request(
|
|
54
|
+
sdk: Any,
|
|
55
|
+
routes: list[SDKRoute],
|
|
56
|
+
request: AdapterRequest,
|
|
57
|
+
options: Optional[ProcessRequestOptions] = None,
|
|
58
|
+
) -> dict[str, Any]:
|
|
59
|
+
prepared = _prepare_request(sdk, routes, request, options)
|
|
60
|
+
if isinstance(prepared, dict):
|
|
61
|
+
return prepared
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
result = prepared.route.handler(prepared.devora_request)
|
|
65
|
+
if inspect.isawaitable(result):
|
|
66
|
+
close = getattr(result, "close", None)
|
|
67
|
+
if callable(close):
|
|
68
|
+
close()
|
|
69
|
+
raise TypeError("Async Devora handlers require async_process_request")
|
|
70
|
+
prepared.record("success", prepared.route.path)
|
|
71
|
+
return create_success_response(result)
|
|
72
|
+
except Exception:
|
|
73
|
+
logger.exception("Devora SDK: customer handler raised")
|
|
74
|
+
prepared.record("failure", prepared.route.path)
|
|
75
|
+
return create_error_response("Customer handler failed", "HANDLER_ERROR")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def async_process_request(
|
|
79
|
+
sdk: Any,
|
|
80
|
+
routes: list[SDKRoute],
|
|
81
|
+
request: AdapterRequest,
|
|
82
|
+
options: Optional[ProcessRequestOptions] = None,
|
|
83
|
+
) -> dict[str, Any]:
|
|
84
|
+
import asyncio
|
|
85
|
+
|
|
86
|
+
loop = asyncio.get_running_loop()
|
|
87
|
+
# _prepare_request verifies the HMAC signature and consumes the replay
|
|
88
|
+
# nonce; in production the replay store is disk- or network-backed, so
|
|
89
|
+
# this can block. Run it off the event loop rather than stalling every
|
|
90
|
+
# other request this async server is handling. Using the stdlib executor
|
|
91
|
+
# (rather than anyio/Starlette's threadpool) keeps this module usable from
|
|
92
|
+
# any asyncio-based framework, not just FastAPI.
|
|
93
|
+
prepared = await loop.run_in_executor(None, _prepare_request, sdk, routes, request, options)
|
|
94
|
+
if isinstance(prepared, dict):
|
|
95
|
+
return prepared
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
if inspect.iscoroutinefunction(prepared.route.handler):
|
|
99
|
+
result = await prepared.route.handler(prepared.devora_request)
|
|
100
|
+
else:
|
|
101
|
+
# A synchronous customer handler may itself block (a DB call, an
|
|
102
|
+
# HTTP request, ...) — never call it directly on the event loop.
|
|
103
|
+
# This also lets a sync Django ORM call run safely from Django's
|
|
104
|
+
# async view path, which otherwise raises SynchronousOnlyOperation.
|
|
105
|
+
result = await loop.run_in_executor(None, prepared.route.handler, prepared.devora_request)
|
|
106
|
+
if inspect.isawaitable(result):
|
|
107
|
+
result = await result
|
|
108
|
+
prepared.record("success", prepared.route.path)
|
|
109
|
+
return create_success_response(result)
|
|
110
|
+
except Exception:
|
|
111
|
+
logger.exception("Devora SDK: customer handler raised")
|
|
112
|
+
prepared.record("failure", prepared.route.path)
|
|
113
|
+
return create_error_response("Customer handler failed", "HANDLER_ERROR")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def create_generic_handler(
|
|
117
|
+
sdk: Any,
|
|
118
|
+
routes: list[SDKRoute],
|
|
119
|
+
options: Optional[ProcessRequestOptions] = None,
|
|
120
|
+
) -> Callable[[AdapterRequest], dict[str, Any]]:
|
|
121
|
+
def handler(request: AdapterRequest) -> dict[str, Any]:
|
|
122
|
+
return process_request(sdk, routes, request, options)
|
|
123
|
+
|
|
124
|
+
return handler
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _prepare_request(
|
|
128
|
+
sdk: Any,
|
|
129
|
+
routes: list[SDKRoute],
|
|
130
|
+
request: AdapterRequest,
|
|
131
|
+
options: Optional[ProcessRequestOptions] = None,
|
|
132
|
+
) -> Union[dict[str, Any], _PreparedRequest]:
|
|
133
|
+
options = options or ProcessRequestOptions()
|
|
134
|
+
method = request.method
|
|
135
|
+
path = request.path
|
|
136
|
+
|
|
137
|
+
def record(outcome: str, endpoint: Optional[str] = None) -> None:
|
|
138
|
+
if hasattr(sdk, "record_request"):
|
|
139
|
+
sdk.record_request(endpoint or "UNMATCHED", outcome)
|
|
140
|
+
|
|
141
|
+
if not isinstance(request.body, (bytes, bytearray)):
|
|
142
|
+
record("failure")
|
|
143
|
+
return create_error_response("Adapter must supply the raw request body bytes", "INVALID_BODY")
|
|
144
|
+
body = bytes(request.body)
|
|
145
|
+
if len(body) > options.max_body_size:
|
|
146
|
+
record("failure")
|
|
147
|
+
return create_error_response("Request body too large", "BODY_TOO_LARGE")
|
|
148
|
+
if method in ("GET", "HEAD") and body:
|
|
149
|
+
record("failure")
|
|
150
|
+
return create_error_response("GET and HEAD requests must not have a body", "INVALID_BODY")
|
|
151
|
+
|
|
152
|
+
# Signature v3 over the exact wire bytes, before any route lookup, so
|
|
153
|
+
# unauthenticated callers learn nothing about registered routes.
|
|
154
|
+
validation = sdk.verify_request(method, path, request.query, body, request.headers, options.timestamp_tolerance)
|
|
155
|
+
if not validation.valid:
|
|
156
|
+
record("security_error")
|
|
157
|
+
return create_error_response(
|
|
158
|
+
validation.error or "Security validation failed",
|
|
159
|
+
validation.error_code or "INVALID_SIGNATURE",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
route = _find_matching_route(routes, method, path)
|
|
163
|
+
if not route:
|
|
164
|
+
record("failure")
|
|
165
|
+
return create_error_response("No handler found for this request", "NOT_FOUND")
|
|
166
|
+
_, params = match_path(route.path, path)
|
|
167
|
+
|
|
168
|
+
body_ok, parsed_body = parse_verified_json_body(body, get_single_header(request.headers, "content-type") or None)
|
|
169
|
+
if not body_ok:
|
|
170
|
+
record("failure", route.path)
|
|
171
|
+
return create_error_response(parsed_body, "INVALID_BODY")
|
|
172
|
+
try:
|
|
173
|
+
parsed_query = parse_verified_query(request.query)
|
|
174
|
+
except (UnicodeDecodeError, ValueError):
|
|
175
|
+
record("failure", route.path)
|
|
176
|
+
return create_error_response("Invalid query string", "INVALID_REQUEST_TARGET")
|
|
177
|
+
|
|
178
|
+
context_result = _build_devora_context(route, path, parsed_body, params)
|
|
179
|
+
if context_result.get("error"):
|
|
180
|
+
record("security_error", route.path)
|
|
181
|
+
return create_error_response(context_result["error"], "INVALID_IMPERSONATION_CONTEXT")
|
|
182
|
+
|
|
183
|
+
context = context_result.get("context")
|
|
184
|
+
session_id = context.session_id if context else _session_id_from_route(route, params, parsed_body)
|
|
185
|
+
|
|
186
|
+
devora_request = DevoraRequest(
|
|
187
|
+
method=method,
|
|
188
|
+
path=path,
|
|
189
|
+
params=params,
|
|
190
|
+
query=parsed_query,
|
|
191
|
+
body=parsed_body,
|
|
192
|
+
headers=request.headers,
|
|
193
|
+
org_id=validation.org_id or "",
|
|
194
|
+
key_id=validation.key_id or "",
|
|
195
|
+
session_id=session_id,
|
|
196
|
+
devora_context=context,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
return _PreparedRequest(route=route, devora_request=devora_request, record=record)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _find_matching_route(routes: list[SDKRoute], method: str, path: str) -> Optional[SDKRoute]:
|
|
203
|
+
for route in routes:
|
|
204
|
+
matched, _ = match_path(route.path, path)
|
|
205
|
+
if route.method.upper() == method and matched:
|
|
206
|
+
return route
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _build_devora_context(
|
|
211
|
+
route: SDKRoute, path: str, body: Any, params: Mapping[str, str]
|
|
212
|
+
) -> dict[str, Any]:
|
|
213
|
+
if route.path != DEVORA_ENDPOINTS.IMPERSONATE:
|
|
214
|
+
return {}
|
|
215
|
+
if not isinstance(body, dict):
|
|
216
|
+
return {"error": "Impersonation request body must be an object"}
|
|
217
|
+
session_id = _read_string(body.get("sessionId"))
|
|
218
|
+
scope = body.get("scope")
|
|
219
|
+
expires_at = _read_number(body.get("expiresAt"))
|
|
220
|
+
if not session_id or len(session_id) > 128:
|
|
221
|
+
return {"error": "Impersonation request is missing a valid session ID"}
|
|
222
|
+
if scope not in ("read", "write"):
|
|
223
|
+
return {"error": "Impersonation request is missing a valid scope"}
|
|
224
|
+
from .utils import now_ms
|
|
225
|
+
|
|
226
|
+
if not expires_at or expires_at <= now_ms():
|
|
227
|
+
return {"error": "Impersonation request is expired or missing expiration"}
|
|
228
|
+
target_user = _read_user(body.get("targetUser"), params.get("id"))
|
|
229
|
+
if not target_user:
|
|
230
|
+
_, matched_params = match_path(route.path, path)
|
|
231
|
+
target_user = _read_user(body.get("targetUser"), matched_params.get("id"))
|
|
232
|
+
if not target_user:
|
|
233
|
+
return {"error": "Impersonation request is missing target user context"}
|
|
234
|
+
impersonator = _read_user(body.get("impersonator"))
|
|
235
|
+
if not impersonator:
|
|
236
|
+
return {"error": "Impersonation request is missing impersonator context"}
|
|
237
|
+
auth_method = body.get("authMethod")
|
|
238
|
+
if auth_method != "devora_impersonation":
|
|
239
|
+
return {"error": "Impersonation request has an invalid authentication method"}
|
|
240
|
+
authorization_source = body.get("authorizationSource")
|
|
241
|
+
if authorization_source not in (
|
|
242
|
+
"standard",
|
|
243
|
+
"self_approved",
|
|
244
|
+
"self_approved_read",
|
|
245
|
+
"break_glass",
|
|
246
|
+
):
|
|
247
|
+
return {"error": "Impersonation request has an invalid authorization source"}
|
|
248
|
+
recording_allowed = body.get("recordingAllowed")
|
|
249
|
+
if not isinstance(recording_allowed, bool):
|
|
250
|
+
return {"error": "Impersonation request is missing recording authorization"}
|
|
251
|
+
return {
|
|
252
|
+
"context": DevoraImpersonationContext(
|
|
253
|
+
session_id=session_id,
|
|
254
|
+
scope=str(scope),
|
|
255
|
+
expires_at=int(expires_at),
|
|
256
|
+
impersonator=impersonator,
|
|
257
|
+
target_user=target_user,
|
|
258
|
+
auth_method=auth_method,
|
|
259
|
+
authorization_source=authorization_source,
|
|
260
|
+
recording_allowed=recording_allowed,
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _session_id_from_route(route: SDKRoute, params: Mapping[str, str], body: Any) -> Optional[str]:
|
|
266
|
+
if route.path == DEVORA_ENDPOINTS.TERMINATE and params.get("id"):
|
|
267
|
+
return params["id"]
|
|
268
|
+
if isinstance(body, dict):
|
|
269
|
+
return _read_string(body.get("sessionId"))
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _read_string(value: Any) -> Optional[str]:
|
|
274
|
+
return value if isinstance(value, str) and value.strip() else None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _read_number(value: Any) -> Optional[int]:
|
|
278
|
+
if isinstance(value, (int, float)):
|
|
279
|
+
return int(value)
|
|
280
|
+
if isinstance(value, str) and value.strip():
|
|
281
|
+
try:
|
|
282
|
+
return int(float(value))
|
|
283
|
+
except ValueError:
|
|
284
|
+
return None
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _read_user(value: Any, fallback_id: Optional[str] = None) -> Optional[dict[str, Any]]:
|
|
289
|
+
source = value if isinstance(value, dict) else {}
|
|
290
|
+
user_id = _read_string(source.get("id")) or fallback_id
|
|
291
|
+
if not user_id:
|
|
292
|
+
return None
|
|
293
|
+
result = {"id": user_id}
|
|
294
|
+
if _read_string(source.get("email")):
|
|
295
|
+
result["email"] = source["email"]
|
|
296
|
+
if _read_string(source.get("name")):
|
|
297
|
+
result["name"] = source["name"]
|
|
298
|
+
return result
|
devora_sdk/hmac.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Request signing v3 primitives (hashlib/hmac)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac as _hmac
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from .constants import SECURITY_HEADERS
|
|
11
|
+
from .signing import VERSION, build_canonical_string, matches
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def sha256_hex(body: bytes) -> str:
|
|
15
|
+
return hashlib.sha256(body).hexdigest()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def hmac_hex(secret_key: str, canonical: str) -> str:
|
|
19
|
+
"""Lowercase hex HMAC-SHA256 keyed by the secret's ASCII bytes."""
|
|
20
|
+
return _hmac.new(secret_key.encode("ascii"), canonical.encode("ascii"), hashlib.sha256).hexdigest()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def signature_matches(secret_key: str, canonical: str, provided: object) -> bool:
|
|
24
|
+
"""Constant-time byte comparison. Never raises; lenient hex is rejected."""
|
|
25
|
+
if not matches("signature", provided):
|
|
26
|
+
return False
|
|
27
|
+
return _hmac.compare_digest(bytes.fromhex(hmac_hex(secret_key, canonical)), bytes.fromhex(provided)) # type: ignore[arg-type]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def sign_request(
|
|
31
|
+
*,
|
|
32
|
+
secret_key: str,
|
|
33
|
+
direction: str,
|
|
34
|
+
key_id: str,
|
|
35
|
+
org_id: str,
|
|
36
|
+
method: str,
|
|
37
|
+
path: str,
|
|
38
|
+
query: str = "",
|
|
39
|
+
body: bytes = b"",
|
|
40
|
+
sent_at: Optional[int] = None,
|
|
41
|
+
request_id: Optional[str] = None,
|
|
42
|
+
) -> dict[str, str]:
|
|
43
|
+
"""Headers for an outgoing signed request (the body is sent as given)."""
|
|
44
|
+
sent = str(int(time.time()) if sent_at is None else sent_at)
|
|
45
|
+
rid = request_id or str(uuid.uuid4())
|
|
46
|
+
canonical = build_canonical_string(
|
|
47
|
+
direction=direction,
|
|
48
|
+
key_id=key_id,
|
|
49
|
+
org_id=org_id,
|
|
50
|
+
sent_at=sent,
|
|
51
|
+
request_id=rid,
|
|
52
|
+
method=method,
|
|
53
|
+
path=path,
|
|
54
|
+
query=query,
|
|
55
|
+
body_sha256=sha256_hex(body),
|
|
56
|
+
)
|
|
57
|
+
headers = {
|
|
58
|
+
SECURITY_HEADERS.SIGNATURE_VERSION: VERSION,
|
|
59
|
+
SECURITY_HEADERS.KEY_ID: key_id,
|
|
60
|
+
SECURITY_HEADERS.ORG_ID: org_id,
|
|
61
|
+
SECURITY_HEADERS.SENT_AT: sent,
|
|
62
|
+
SECURITY_HEADERS.REQUEST_ID: rid,
|
|
63
|
+
SECURITY_HEADERS.SIGNATURE: hmac_hex(secret_key, canonical),
|
|
64
|
+
}
|
|
65
|
+
if body:
|
|
66
|
+
headers["Content-Type"] = "application/json"
|
|
67
|
+
return headers
|
devora_sdk/models.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Callable, Mapping, MutableMapping, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class DevoraImpersonationContext:
|
|
9
|
+
session_id: str
|
|
10
|
+
scope: str
|
|
11
|
+
expires_at: int
|
|
12
|
+
impersonator: dict[str, Any]
|
|
13
|
+
target_user: dict[str, Any]
|
|
14
|
+
auth_method: str
|
|
15
|
+
authorization_source: str
|
|
16
|
+
recording_allowed: bool
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def actor(self) -> dict[str, Any]:
|
|
20
|
+
return self.impersonator
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def subject(self) -> dict[str, Any]:
|
|
24
|
+
return self.target_user
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class DevoraRequest:
|
|
29
|
+
method: str
|
|
30
|
+
path: str
|
|
31
|
+
params: Mapping[str, str]
|
|
32
|
+
query: Mapping[str, Any]
|
|
33
|
+
body: Any
|
|
34
|
+
headers: Mapping[str, Any]
|
|
35
|
+
org_id: str
|
|
36
|
+
key_id: str
|
|
37
|
+
session_id: Optional[str] = None
|
|
38
|
+
devora_context: Optional[DevoraImpersonationContext] = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
DevoraResponse = dict[str, Any]
|
|
42
|
+
RouteHandler = Callable[[DevoraRequest], Any]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class SDKRoute:
|
|
47
|
+
path: str
|
|
48
|
+
method: str
|
|
49
|
+
handler: RouteHandler
|
|
50
|
+
is_built_in: bool = False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class SDKStats:
|
|
55
|
+
total_requests: int = 0
|
|
56
|
+
successful_requests: int = 0
|
|
57
|
+
failed_requests: int = 0
|
|
58
|
+
security_errors: int = 0
|
|
59
|
+
requests_by_endpoint: MutableMapping[str, int] = field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
def to_dict(self) -> dict[str, Any]:
|
|
62
|
+
return {
|
|
63
|
+
"totalRequests": self.total_requests,
|
|
64
|
+
"successfulRequests": self.successful_requests,
|
|
65
|
+
"failedRequests": self.failed_requests,
|
|
66
|
+
"securityErrors": self.security_errors,
|
|
67
|
+
"requestsByEndpoint": dict(self.requests_by_endpoint),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class ValidationResult:
|
|
73
|
+
valid: bool
|
|
74
|
+
error: Optional[str] = None
|
|
75
|
+
error_code: Optional[str] = None
|
|
76
|
+
org_id: Optional[str] = None
|
|
77
|
+
key_id: Optional[str] = None
|
|
78
|
+
timestamp: Optional[int] = None
|
|
79
|
+
request_id: Optional[str] = None
|
devora_sdk/policy.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import math
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, Callable, Optional
|
|
9
|
+
|
|
10
|
+
from .constants import DEFAULT_API_URL
|
|
11
|
+
from .transport import ControlPlaneError, control_plane_request
|
|
12
|
+
|
|
13
|
+
# How long a cached policy may be served past its window during an outage.
|
|
14
|
+
STALE_GRACE_MS = 5 * 60 * 1000
|
|
15
|
+
# Largest accepted policy, and the longest server-sent cache horizon trusted.
|
|
16
|
+
MAX_POLICY_ENTRIES = 1000
|
|
17
|
+
MAX_PATTERN_LENGTH = 500
|
|
18
|
+
MAX_POLICY_TTL_MS = 60 * 60 * 1000
|
|
19
|
+
_METHOD = re.compile(r"\*|[A-Z]{3,7}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _read_endpoints(value: Any) -> Optional[list[dict[str, str]]]:
|
|
23
|
+
if not isinstance(value, list) or len(value) > MAX_POLICY_ENTRIES:
|
|
24
|
+
return None
|
|
25
|
+
endpoints = []
|
|
26
|
+
for entry in value:
|
|
27
|
+
method = entry.get("method") if isinstance(entry, dict) else None
|
|
28
|
+
pattern = entry.get("pattern") if isinstance(entry, dict) else None
|
|
29
|
+
if (
|
|
30
|
+
not isinstance(method, str)
|
|
31
|
+
or not _METHOD.fullmatch(method)
|
|
32
|
+
or not isinstance(pattern, str)
|
|
33
|
+
or not pattern
|
|
34
|
+
or len(pattern) > MAX_PATTERN_LENGTH
|
|
35
|
+
):
|
|
36
|
+
return None
|
|
37
|
+
endpoints.append({"method": method, "pattern": pattern})
|
|
38
|
+
return endpoints
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _read_policy(data: Any, now: int) -> Optional["ScopeConfig"]:
|
|
42
|
+
"""Validate a policy response; ``None`` when any part is malformed (fail closed)."""
|
|
43
|
+
if not isinstance(data, dict):
|
|
44
|
+
return None
|
|
45
|
+
safe = _read_endpoints(data.get("safeReadEndpoints"))
|
|
46
|
+
blocked = _read_endpoints(data.get("blockedEndpoints"))
|
|
47
|
+
version = _safe_integer(data.get("version"), 0)
|
|
48
|
+
requested = _safe_integer(data.get("cachedUntil"), 1)
|
|
49
|
+
# Mirror JavaScript safe integers, including rejection of booleans. A
|
|
50
|
+
# missing deny list/version/cache horizon cannot mean an empty valid policy.
|
|
51
|
+
if safe is None or blocked is None or version is None or requested is None:
|
|
52
|
+
return None
|
|
53
|
+
return ScopeConfig(
|
|
54
|
+
safe_read_endpoints=safe,
|
|
55
|
+
blocked_endpoints=blocked,
|
|
56
|
+
version=version,
|
|
57
|
+
cached_until=int(min(max(requested, now), now + MAX_POLICY_TTL_MS)),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _safe_integer(value: Any, minimum: int) -> Optional[int]:
|
|
62
|
+
if isinstance(value, bool):
|
|
63
|
+
return None
|
|
64
|
+
if isinstance(value, float):
|
|
65
|
+
if not math.isfinite(value) or not value.is_integer():
|
|
66
|
+
return None
|
|
67
|
+
value = int(value)
|
|
68
|
+
return value if isinstance(value, int) and minimum <= value <= 2**53 - 1 else None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class ScopeConfig:
|
|
73
|
+
safe_read_endpoints: list[dict[str, str]]
|
|
74
|
+
blocked_endpoints: list[dict[str, str]]
|
|
75
|
+
version: int
|
|
76
|
+
cached_until: int
|
|
77
|
+
|
|
78
|
+
def to_dict(self) -> dict[str, Any]:
|
|
79
|
+
return {
|
|
80
|
+
"safeReadEndpoints": self.safe_read_endpoints,
|
|
81
|
+
"blockedEndpoints": self.blocked_endpoints,
|
|
82
|
+
"version": self.version,
|
|
83
|
+
"cachedUntil": self.cached_until,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ScopeConfigFetcher:
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
api_key: str,
|
|
91
|
+
cache_ttl_ms: int = 5 * 60 * 1000,
|
|
92
|
+
api_url: Optional[str] = None,
|
|
93
|
+
prefetch: bool = False,
|
|
94
|
+
sign_request: Optional[Callable[[], dict[str, str]]] = None,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""``sign_request`` returns fresh v3 signature headers for
|
|
97
|
+
``GET /api/sdk/scope-config`` (the policy is not public);
|
|
98
|
+
``DevoraBackendSDK`` supplies it from the server secret."""
|
|
99
|
+
if isinstance(cache_ttl_ms, bool) or not isinstance(cache_ttl_ms, int) or not 0 < cache_ttl_ms <= MAX_POLICY_TTL_MS:
|
|
100
|
+
raise ValueError("cache_ttl_ms must be an integer between 1 and 3600000")
|
|
101
|
+
self.api_key = api_key
|
|
102
|
+
self._sign_request = sign_request
|
|
103
|
+
self.api_url = api_url or DEFAULT_API_URL
|
|
104
|
+
self.cache_ttl_ms = cache_ttl_ms
|
|
105
|
+
self.cached_config: Optional[ScopeConfig] = None
|
|
106
|
+
self.etag: Optional[str] = None
|
|
107
|
+
self.stale_until = 0
|
|
108
|
+
self._timer: Optional[threading.Timer] = None
|
|
109
|
+
self._lock = threading.Lock()
|
|
110
|
+
if prefetch:
|
|
111
|
+
# Warm the cache in the background at construction time instead of
|
|
112
|
+
# leaving the first request(s) to pay for it. Without this, every
|
|
113
|
+
# worker process that just started serves its first request (and any
|
|
114
|
+
# request racing it) a synchronous fetch or a 503
|
|
115
|
+
# IMPERSONATION_POLICY_UNAVAILABLE, since get_config() only fetches
|
|
116
|
+
# lazily and a concurrent caller during that first fetch gets None
|
|
117
|
+
# rather than waiting (see refresh()'s single-flight comment).
|
|
118
|
+
# Fire-and-forget: refresh() already handles its own errors.
|
|
119
|
+
threading.Thread(target=self.refresh, daemon=True).start()
|
|
120
|
+
|
|
121
|
+
def get_config(self) -> Optional[ScopeConfig]:
|
|
122
|
+
if self.cached_config and _now_ms() < self.cached_config.cached_until:
|
|
123
|
+
return self.cached_config
|
|
124
|
+
config = self.refresh()
|
|
125
|
+
if config and not self._timer:
|
|
126
|
+
self._schedule_refresh()
|
|
127
|
+
return config
|
|
128
|
+
|
|
129
|
+
def get_cached_config(self) -> Optional[ScopeConfig]:
|
|
130
|
+
return self.cached_config
|
|
131
|
+
|
|
132
|
+
def refresh(self) -> Optional[ScopeConfig]:
|
|
133
|
+
# Single-flight without blocking: a second caller during an in-progress
|
|
134
|
+
# refresh gets the cached/stale policy immediately instead of queueing a
|
|
135
|
+
# worker thread behind a 5-second network call.
|
|
136
|
+
if not self._lock.acquire(blocking=False):
|
|
137
|
+
return self._stale_or_none()
|
|
138
|
+
try:
|
|
139
|
+
return self._refresh_locked()
|
|
140
|
+
finally:
|
|
141
|
+
self._lock.release()
|
|
142
|
+
|
|
143
|
+
def _refresh_locked(self) -> Optional[ScopeConfig]:
|
|
144
|
+
if self._sign_request is None:
|
|
145
|
+
return self._stale_or_none()
|
|
146
|
+
headers = dict(self._sign_request())
|
|
147
|
+
if self.etag:
|
|
148
|
+
headers["If-None-Match"] = self.etag
|
|
149
|
+
try:
|
|
150
|
+
status, payload, response_headers = control_plane_request(
|
|
151
|
+
f"{self.api_url}/api/sdk/scope-config", "GET", headers
|
|
152
|
+
)
|
|
153
|
+
except ControlPlaneError:
|
|
154
|
+
return self._stale_or_none()
|
|
155
|
+
if status == 304 and self.cached_config:
|
|
156
|
+
return self._extend_cached_config()
|
|
157
|
+
config = (
|
|
158
|
+
_read_policy(payload.get("data"), _now_ms())
|
|
159
|
+
if 200 <= status < 300 and payload and payload.get("success") is True
|
|
160
|
+
else None
|
|
161
|
+
)
|
|
162
|
+
if config is None:
|
|
163
|
+
return self._stale_or_none()
|
|
164
|
+
self.etag = response_headers.get("etag")
|
|
165
|
+
self.cached_config = config
|
|
166
|
+
self.stale_until = config.cached_until + STALE_GRACE_MS
|
|
167
|
+
self._schedule_refresh()
|
|
168
|
+
return config
|
|
169
|
+
|
|
170
|
+
def stop(self) -> None:
|
|
171
|
+
if self._timer:
|
|
172
|
+
self._timer.cancel()
|
|
173
|
+
self._timer = None
|
|
174
|
+
|
|
175
|
+
def _stale_or_none(self) -> Optional[ScopeConfig]:
|
|
176
|
+
if self.cached_config and _now_ms() < self.stale_until:
|
|
177
|
+
return self.cached_config
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
def _extend_cached_config(self) -> ScopeConfig:
|
|
181
|
+
self.cached_config = ScopeConfig(
|
|
182
|
+
safe_read_endpoints=self.cached_config.safe_read_endpoints,
|
|
183
|
+
blocked_endpoints=self.cached_config.blocked_endpoints,
|
|
184
|
+
version=self.cached_config.version,
|
|
185
|
+
cached_until=_now_ms() + self.cache_ttl_ms,
|
|
186
|
+
)
|
|
187
|
+
self.stale_until = self.cached_config.cached_until + STALE_GRACE_MS
|
|
188
|
+
self._schedule_refresh()
|
|
189
|
+
return self.cached_config
|
|
190
|
+
|
|
191
|
+
def _schedule_refresh(self) -> None:
|
|
192
|
+
self.stop()
|
|
193
|
+
refresh_in_ms = self.cache_ttl_ms
|
|
194
|
+
if self.cached_config:
|
|
195
|
+
refresh_in_ms = max(
|
|
196
|
+
self.cached_config.cached_until - _now_ms() - 30_000,
|
|
197
|
+
min(self.cache_ttl_ms // 2, 60_000),
|
|
198
|
+
)
|
|
199
|
+
self._timer = threading.Timer(refresh_in_ms / 1000, self._refresh_from_timer)
|
|
200
|
+
self._timer.daemon = True
|
|
201
|
+
self._timer.start()
|
|
202
|
+
|
|
203
|
+
def _refresh_from_timer(self) -> None:
|
|
204
|
+
self.refresh()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _now_ms() -> int:
|
|
208
|
+
return int(time.time() * 1000)
|
devora_sdk/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
devora_sdk/replay.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import time
|
|
5
|
+
from threading import Lock
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ReplayStore(Protocol):
|
|
10
|
+
"""Atomic replay protection shared by every application instance.
|
|
11
|
+
|
|
12
|
+
``consume`` must be an atomic insert-if-absent (for example Redis
|
|
13
|
+
``SET key 1 NX PXAT expires_at``, or a unique-key database insert) and must
|
|
14
|
+
never evict an entry before ``expires_at``. A store that cannot guarantee
|
|
15
|
+
this must raise; the SDK then fails closed with a 503.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def consume(self, namespace: str, request_id: str, expires_at: int) -> bool:
|
|
19
|
+
"""Return True only for the first consumption of ``request_id`` within
|
|
20
|
+
``namespace`` before ``expires_at`` (Unix milliseconds)."""
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InMemoryReplayStore:
|
|
25
|
+
"""Development-only replay store. Production must use persistent storage."""
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self._entries: dict[str, int] = {}
|
|
29
|
+
self._lock = Lock()
|
|
30
|
+
|
|
31
|
+
def consume(self, namespace: str, request_id: str, expires_at: int) -> bool:
|
|
32
|
+
if isinstance(expires_at, bool) or not isinstance(expires_at, (int, float)) or not math.isfinite(expires_at):
|
|
33
|
+
raise ValueError("Invalid replay expiry")
|
|
34
|
+
now = int(time.time() * 1000)
|
|
35
|
+
key = f"{namespace}\n{request_id}"
|
|
36
|
+
with self._lock:
|
|
37
|
+
self._entries = {entry: expiry for entry, expiry in self._entries.items() if expiry > now}
|
|
38
|
+
if key in self._entries:
|
|
39
|
+
return False
|
|
40
|
+
self._entries[key] = int(expires_at)
|
|
41
|
+
return True
|