devora-python 0.1.1__py3-none-any.whl → 0.1.2__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.1.dist-info → devora_python-0.1.2.dist-info}/METADATA +76 -42
- devora_python-0.1.2.dist-info/RECORD +19 -0
- devora_sdk/__init__.py +6 -3
- devora_sdk/constants.py +8 -3
- devora_sdk/handler.py +4 -4
- devora_sdk/models.py +24 -1
- devora_sdk/sdk.py +57 -49
- devora_sdk/signing.py +0 -9
- devora_sdk/utils.py +3 -1
- devora_python-0.1.1.dist-info/RECORD +0 -20
- devora_sdk/replay.py +0 -41
- {devora_python-0.1.1.dist-info → devora_python-0.1.2.dist-info}/WHEEL +0 -0
- {devora_python-0.1.1.dist-info → devora_python-0.1.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: devora-python
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Devora Python backend SDK for secure impersonation
|
|
5
5
|
Project-URL: Documentation, https://docs.devora.sh
|
|
6
6
|
Project-URL: Repository, https://github.com/getdevora/devora-sdks
|
|
@@ -34,13 +34,12 @@ Python backend core SDK for Devora customer integrations.
|
|
|
34
34
|
## Requirements
|
|
35
35
|
|
|
36
36
|
- Python `>=3.10,<4.0`
|
|
37
|
-
-
|
|
38
|
-
Redis 6.2 or newer, for `SET ... PXAT`)
|
|
37
|
+
- Outbound HTTPS access from your backend to the Devora API
|
|
39
38
|
|
|
40
39
|
## Install
|
|
41
40
|
|
|
42
41
|
```bash
|
|
43
|
-
pip install devora-python
|
|
42
|
+
pip install devora-python
|
|
44
43
|
```
|
|
45
44
|
|
|
46
45
|
## Quick Start
|
|
@@ -49,31 +48,32 @@ pip install devora-python redis
|
|
|
49
48
|
import os
|
|
50
49
|
from dataclasses import asdict
|
|
51
50
|
|
|
52
|
-
import redis
|
|
53
51
|
from devora_sdk import DEVORA_ENDPOINTS, devora_sdk
|
|
54
52
|
|
|
55
|
-
# Replay protection shared by every worker and instance (required in production).
|
|
56
|
-
redis_client = redis.Redis.from_url(os.environ["REDIS_URL"])
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
class RedisReplayStore:
|
|
60
|
-
def consume(self, namespace: str, request_id: str, expires_at: int) -> bool:
|
|
61
|
-
# Atomic insert-if-absent kept until expires_at; an exception makes the SDK fail closed (503).
|
|
62
|
-
key = f"devora:replay:{namespace}:{request_id}"
|
|
63
|
-
return bool(redis_client.set(key, b"1", nx=True, pxat=expires_at))
|
|
64
|
-
|
|
65
|
-
|
|
66
53
|
sdk = devora_sdk(
|
|
67
54
|
api_key=os.environ["DEVORA_API_KEY"], # pk_server_live_...
|
|
68
55
|
secret_key=os.environ["DEVORA_SECRET_KEY"], # sk_server_live_...
|
|
69
56
|
org_id=os.environ["DEVORA_ORG_ID"],
|
|
70
|
-
replay_store=RedisReplayStore(),
|
|
71
57
|
)
|
|
72
58
|
|
|
73
59
|
|
|
74
60
|
@sdk.register(DEVORA_ENDPOINTS.USER_SEARCH)
|
|
75
61
|
def search_users(req):
|
|
76
|
-
|
|
62
|
+
# Match name, email and the exact user ID with a parameterised query.
|
|
63
|
+
term = req.query.get("term", "")
|
|
64
|
+
limit = int(req.query.get("limit", 10))
|
|
65
|
+
users = search_customer_users(term, limit)
|
|
66
|
+
return {
|
|
67
|
+
"users": [
|
|
68
|
+
{
|
|
69
|
+
"id": u.id,
|
|
70
|
+
"name": u.name,
|
|
71
|
+
"email": u.email,
|
|
72
|
+
"attributes": {"company": u.company, "role": u.role, "plan": u.plan},
|
|
73
|
+
}
|
|
74
|
+
for u in users
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
77
|
|
|
78
78
|
|
|
79
79
|
@sdk.register(DEVORA_ENDPOINTS.IMPERSONATE)
|
|
@@ -86,8 +86,25 @@ def impersonate(req):
|
|
|
86
86
|
devora = {**asdict(ctx), "is_impersonation": True}
|
|
87
87
|
token = create_customer_token(ctx.target_user["id"], devora)
|
|
88
88
|
return {"token": token}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@sdk.register(DEVORA_ENDPOINTS.TERMINATE)
|
|
92
|
+
def terminate(req):
|
|
93
|
+
session_id = req.params["id"]
|
|
94
|
+
reason = (req.body or {}).get("reason")
|
|
95
|
+
# YOU IMPLEMENT: mark this Devora session revoked so every credential issued for it
|
|
96
|
+
# is rejected, including one issued after this call. Must be idempotent: Devora
|
|
97
|
+
# retries with a new request id (up to 5 attempts over 6 hours).
|
|
98
|
+
auth.revoke_impersonation_session(session_id=session_id, reason=reason)
|
|
99
|
+
return {"success": True}
|
|
89
100
|
```
|
|
90
101
|
|
|
102
|
+
User search should match name, email and the exact user ID. `attributes` are optional display fields such as company, role or plan: up to 12 per user, lowercase keys like `last_login`, and string, number, boolean or `null` values. Devora drops invalid entries silently; see [Search results and templates](https://docs.devora.sh/guide/search-results) for the limits and how your team lays out results.
|
|
103
|
+
|
|
104
|
+
Each verified request is claimed once from Devora before your handler runs, so
|
|
105
|
+
replay protection needs no storage on your side; your backend only needs
|
|
106
|
+
outbound HTTPS access to the Devora API. See [Replay protection](#replay-protection).
|
|
107
|
+
|
|
91
108
|
Register every handler, then mount the SDK with the
|
|
92
109
|
[Django](https://pypi.org/project/devora-django/) or
|
|
93
110
|
[FastAPI](https://pypi.org/project/devora-fastapi/) adapter. For any other
|
|
@@ -108,32 +125,50 @@ def handle_devora(method: str, path: str, query: str, body: bytes, headers) -> t
|
|
|
108
125
|
return status, result # send as JSON with Cache-Control: private, no-store
|
|
109
126
|
```
|
|
110
127
|
|
|
111
|
-
`async_process_request` runs signature verification, the
|
|
128
|
+
`async_process_request` runs signature verification, the request claim and
|
|
112
129
|
synchronous handlers in a worker thread, so it never blocks the event loop.
|
|
113
130
|
`process_request` cannot run `async def` handlers.
|
|
114
131
|
|
|
115
|
-
###
|
|
116
|
-
|
|
117
|
-
Every signed request carries a single-use id.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
`
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
132
|
+
### Replay protection
|
|
133
|
+
|
|
134
|
+
Every signed request carries a single-use id. After the signature verifies, the
|
|
135
|
+
SDK claims that id from Devora with one signed call
|
|
136
|
+
(`POST /api/sdk/request-claim`, `REQUEST_CLAIM_TIMEOUT_SECONDS = 3.0`); the
|
|
137
|
+
handler runs only after a successful claim. Only requests whose signature
|
|
138
|
+
verified are ever claimed, so unauthenticated traffic cannot use up request
|
|
139
|
+
ids. The claim can fail with:
|
|
140
|
+
|
|
141
|
+
| Status | `errorCode` | Cause |
|
|
142
|
+
| ------ | --------------------------- | ------------------------------------------------------------------------ |
|
|
143
|
+
| 401 | `REPLAYED_REQUEST` | Devora already claimed this request id (the request was already processed) |
|
|
144
|
+
| 409 | `SESSION_NOT_STARTABLE` | Start request for a session Devora is no longer starting |
|
|
145
|
+
| 401 | `TIMESTAMP_EXPIRED` | Devora says the request is too old to claim |
|
|
146
|
+
| 503 | `REQUEST_CLAIM_UNAVAILABLE` | Devora could not be reached or gave no clear answer (fails closed) |
|
|
147
|
+
|
|
148
|
+
Devora's **Test connection** check is claimed too, so it also proves your
|
|
149
|
+
backend can reach the Devora API. See
|
|
150
|
+
[SIGNING.md](https://github.com/getdevora/devora-sdks/blob/main/SIGNING.md#request-claims).
|
|
151
|
+
|
|
152
|
+
### Session termination
|
|
153
|
+
|
|
154
|
+
Devora calls `DELETE /impersonate/:id/terminate` when a session ends. The
|
|
155
|
+
session id is `req.params["id"]` (also `req.session_id`), and the body is
|
|
156
|
+
`{"reason": ..., "terminatedBy": ...}`. `terminatedBy` is the external user id
|
|
157
|
+
of the person who ended the session and is absent for automatic ends. `reason`
|
|
158
|
+
is one of `user_ended`, `time_limit`, `admin_terminated`, `superseded`,
|
|
159
|
+
`request_revoked`, `membership_revoked`, `role_downgraded`,
|
|
160
|
+
`workos_session_revoked`, `principal_erased`, `organization_erased`,
|
|
161
|
+
`start_failed` (Devora sent the start request, and your handler may have issued
|
|
162
|
+
a token, but the session never started) or `not_started` (your handler issued a
|
|
163
|
+
token but the impersonation link was never opened).
|
|
164
|
+
|
|
165
|
+
Devora retries a failed terminate call (up to 5 attempts over 6 hours, with
|
|
166
|
+
backoff), each as a new signed request with a new request id, so the handler
|
|
167
|
+
must be idempotent; any 2xx counts as delivered. A 4xx other than 408, 425 or 429 is
|
|
168
|
+
not retried; when overloaded, answer 429 or 503 with `Retry-After`. Store the
|
|
169
|
+
Devora session id with the credential you mint and revoke by session, so a
|
|
170
|
+
credential minted by a slow start handler after the terminate is still rejected. See
|
|
171
|
+
[Session lifecycle & cleanup](https://docs.devora.sh/guide/session-lifecycle).
|
|
137
172
|
|
|
138
173
|
The guard's session-liveness checker caches at most 1,024 results and permits at
|
|
139
174
|
most 64 distinct concurrent lookups per checker. Requests for the same session
|
|
@@ -158,7 +193,6 @@ sdk = devora_sdk(
|
|
|
158
193
|
api_key=os.environ["DEVORA_API_KEY"],
|
|
159
194
|
secret_key=os.environ["DEVORA_SECRET_KEY"],
|
|
160
195
|
org_id=os.environ["DEVORA_ORG_ID"],
|
|
161
|
-
replay_store=RedisReplayStore(),
|
|
162
196
|
prefetch_scope_config=True,
|
|
163
197
|
)
|
|
164
198
|
```
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
devora_sdk/__init__.py,sha256=0ZJLVYd6y88yiRaM2H7lZL3-L1vrlmkcbxY2kYE9HgM,2249
|
|
2
|
+
devora_sdk/api_url_gen.py,sha256=wfYa1I23mbENK9IoDQrw0ZT_6ivkszRI9Jhs48yTcYI,116
|
|
3
|
+
devora_sdk/browser_session.py,sha256=Get6voUbs4dg9ZvqJtBgiMr4gOqtHZh-Gi9xHX6E-tY,2007
|
|
4
|
+
devora_sdk/constants.py,sha256=dO5IOTkg1HyzSKCvjC9tjXPXLwqUTzyhg6Hrim0Lw2w,1756
|
|
5
|
+
devora_sdk/guard.py,sha256=idlLUrt-HKstvuLJvwy4oCiW0BQ90n_WD3XiYMS6lCU,18284
|
|
6
|
+
devora_sdk/handler.py,sha256=6HtoFkdfnfVNKC0lBLkWaJnIvq7fU_yVFdfm9awBSAQ,10261
|
|
7
|
+
devora_sdk/hmac.py,sha256=a0OyYEij39j_rWXsgIyKsbgydl0dpVgiIs-BYZR198k,1889
|
|
8
|
+
devora_sdk/models.py,sha256=MKHn41GRPy35VMElLe9JFkmSzfU8D8f0leQqWIp6RwU,2516
|
|
9
|
+
devora_sdk/policy.py,sha256=MykEDCuCSvH9mso73ShsEdN5XtnMiJRHmBY4c0LuEZM,6999
|
|
10
|
+
devora_sdk/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
11
|
+
devora_sdk/sdk.py,sha256=iZGYqDYt7wSmVckeuG14UO3M2a_gGmuA551KxDl4d0o,14734
|
|
12
|
+
devora_sdk/security.py,sha256=gfKwz_rF9tGXFejNcwOPBqlczK_g1AZrKZe1SMxdKCA,1437
|
|
13
|
+
devora_sdk/signing.py,sha256=9y_zEP3k8iHiLg31xoRBzU8L3stbEB6rAeaYu7HTtUs,8564
|
|
14
|
+
devora_sdk/transport.py,sha256=plTQ0J4B_-LGUs6waOqBk8wL4P06KuS_LilyEYr9sW4,5528
|
|
15
|
+
devora_sdk/utils.py,sha256=DG5pf3xPO147xLgsTMiBoDxyoaF3hAcnepS99_QIuDc,5584
|
|
16
|
+
devora_python-0.1.2.dist-info/METADATA,sha256=HRJxADCl0i5l2KxzTK6kRc_4797fEWxn74x1KFnG3X8,9135
|
|
17
|
+
devora_python-0.1.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
18
|
+
devora_python-0.1.2.dist-info/licenses/LICENSE,sha256=ZrqlZJezuSZhSOx4R8S5JOwwQ-sLmssj2XW7-3coRjA,1063
|
|
19
|
+
devora_python-0.1.2.dist-info/RECORD,,
|
devora_sdk/__init__.py
CHANGED
|
@@ -34,13 +34,15 @@ from .models import (
|
|
|
34
34
|
DevoraImpersonationContext,
|
|
35
35
|
DevoraRequest,
|
|
36
36
|
DevoraResponse,
|
|
37
|
+
DevoraUser,
|
|
38
|
+
DevoraUserAttributeValue,
|
|
37
39
|
SDKRoute,
|
|
38
40
|
SDKStats,
|
|
41
|
+
UserSearchResponse,
|
|
39
42
|
ValidationResult,
|
|
40
43
|
)
|
|
41
44
|
from .policy import ScopeConfig, ScopeConfigFetcher
|
|
42
45
|
from .sdk import DevoraBackendSDK, devora_sdk
|
|
43
|
-
from .replay import InMemoryReplayStore, ReplayStore
|
|
44
46
|
from .browser_session import resolve_browser_session
|
|
45
47
|
from .constants import BROWSER_SESSION_BRIDGE_PATH
|
|
46
48
|
|
|
@@ -54,13 +56,14 @@ __all__ = [
|
|
|
54
56
|
"DevoraImpersonationContext",
|
|
55
57
|
"DevoraRequest",
|
|
56
58
|
"DevoraResponse",
|
|
59
|
+
"DevoraUser",
|
|
60
|
+
"DevoraUserAttributeValue",
|
|
57
61
|
"GuardDecision",
|
|
58
62
|
"ImpersonationContext",
|
|
59
|
-
"InMemoryReplayStore",
|
|
60
63
|
"ProcessRequestOptions",
|
|
61
|
-
"ReplayStore",
|
|
62
64
|
"SDKRoute",
|
|
63
65
|
"SDKStats",
|
|
66
|
+
"UserSearchResponse",
|
|
64
67
|
"ScopeConfig",
|
|
65
68
|
"ScopeEndpoint",
|
|
66
69
|
"ScopeConfigFetcher",
|
devora_sdk/constants.py
CHANGED
|
@@ -5,7 +5,6 @@ from .api_url_gen import DEVORA_API_ORIGIN
|
|
|
5
5
|
|
|
6
6
|
class DEVORA_ENDPOINTS:
|
|
7
7
|
USER_SEARCH = "/user/search"
|
|
8
|
-
USER_BY_ID = "/user/:id"
|
|
9
8
|
IMPERSONATE = "/impersonate/:id"
|
|
10
9
|
TERMINATE = "/impersonate/:id/terminate"
|
|
11
10
|
TEST = "/test"
|
|
@@ -14,7 +13,6 @@ class DEVORA_ENDPOINTS:
|
|
|
14
13
|
|
|
15
14
|
ENDPOINT_METHODS = {
|
|
16
15
|
DEVORA_ENDPOINTS.USER_SEARCH: "GET",
|
|
17
|
-
DEVORA_ENDPOINTS.USER_BY_ID: "GET",
|
|
18
16
|
DEVORA_ENDPOINTS.IMPERSONATE: "POST",
|
|
19
17
|
DEVORA_ENDPOINTS.TERMINATE: "DELETE",
|
|
20
18
|
DEVORA_ENDPOINTS.TEST: "GET",
|
|
@@ -34,7 +32,7 @@ class SECURITY_HEADERS:
|
|
|
34
32
|
SESSION_ID = "x-devora-session-id"
|
|
35
33
|
|
|
36
34
|
|
|
37
|
-
SDK_VERSION = "0.1.
|
|
35
|
+
SDK_VERSION = "0.1.2"
|
|
38
36
|
DEFAULT_API_URL = DEVORA_API_ORIGIN
|
|
39
37
|
DEFAULT_TIMESTAMP_TOLERANCE_SECONDS = 300
|
|
40
38
|
DEFAULT_MAX_BODY_SIZE_BYTES = 1024 * 1024
|
|
@@ -45,5 +43,12 @@ WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
|
|
45
43
|
BROWSER_SESSION_BRIDGE_PATH = "/api/devora/browser-session"
|
|
46
44
|
BROWSER_RESUME_CODE_ENDPOINT = "/api/sdk/browser-resume-code"
|
|
47
45
|
BROWSER_RESUME_ENDPOINT = "/api/sdk/browser-resume"
|
|
46
|
+
|
|
47
|
+
# Single use of signed Devora -> customer requests (see @devorash/core REQUEST_CLAIM):
|
|
48
|
+
# after verifying a signature the SDK claims the request id from Devora, and the
|
|
49
|
+
# handler runs only for the first claim. Customers need no storage of their own.
|
|
50
|
+
REQUEST_CLAIM_ENDPOINT = "/api/sdk/request-claim"
|
|
51
|
+
# Total deadline in seconds, leaving room for the handler within Devora's own deadline.
|
|
52
|
+
REQUEST_CLAIM_TIMEOUT_SECONDS = 3.0
|
|
48
53
|
# \Z, not $: $ also matches before a trailing newline.
|
|
49
54
|
TAB_REF_PATTERN = r"^[A-Za-z0-9_-]{4,96}\Z"
|
devora_sdk/handler.py
CHANGED
|
@@ -84,10 +84,10 @@ async def async_process_request(
|
|
|
84
84
|
import asyncio
|
|
85
85
|
|
|
86
86
|
loop = asyncio.get_running_loop()
|
|
87
|
-
# _prepare_request verifies the HMAC signature and
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
87
|
+
# _prepare_request verifies the HMAC signature and claims the request id
|
|
88
|
+
# from Devora over the network, so this can block. Run it off the event
|
|
89
|
+
# loop rather than stalling every other request this async server is
|
|
90
|
+
# handling. Using the stdlib executor
|
|
91
91
|
# (rather than anyio/Starlette's threadpool) keeps this module usable from
|
|
92
92
|
# any asyncio-based framework, not just FastAPI.
|
|
93
93
|
prepared = await loop.run_in_executor(None, _prepare_request, sdk, routes, request, options)
|
devora_sdk/models.py
CHANGED
|
@@ -1,7 +1,30 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass, field
|
|
4
|
-
from typing import Any, Callable, Mapping, MutableMapping, Optional
|
|
4
|
+
from typing import Any, Callable, Mapping, MutableMapping, Optional, TypedDict, Union
|
|
5
|
+
|
|
6
|
+
# An extra value shown in Devora's search results; None means "not set".
|
|
7
|
+
DevoraUserAttributeValue = Optional[Union[str, int, float, bool]]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DevoraUser(TypedDict, total=False):
|
|
11
|
+
"""A user returned from your USER_SEARCH handler (``id`` is required).
|
|
12
|
+
|
|
13
|
+
``attributes`` holds extra display fields such as company, role or plan: keys
|
|
14
|
+
are lowercase letters, digits and underscores starting with a letter (e.g.
|
|
15
|
+
``last_login``); at most 12; strings up to 120 characters. Never include
|
|
16
|
+
secrets: keys that look like passwords, tokens, keys or card data are dropped.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
id: str
|
|
20
|
+
name: str
|
|
21
|
+
email: str
|
|
22
|
+
avatar: str
|
|
23
|
+
attributes: dict[str, DevoraUserAttributeValue]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class UserSearchResponse(TypedDict):
|
|
27
|
+
users: list[DevoraUser]
|
|
5
28
|
|
|
6
29
|
|
|
7
30
|
@dataclass(frozen=True)
|
devora_sdk/sdk.py
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
-
import inspect
|
|
5
|
-
import os
|
|
6
4
|
import re
|
|
7
|
-
import time
|
|
8
5
|
from typing import Any, Callable, Optional
|
|
9
6
|
|
|
10
7
|
from .constants import (
|
|
@@ -15,6 +12,8 @@ from .constants import (
|
|
|
15
12
|
PROTECTED_ENDPOINTS,
|
|
16
13
|
SDK_VERSION,
|
|
17
14
|
BROWSER_RESUME_CODE_ENDPOINT,
|
|
15
|
+
REQUEST_CLAIM_ENDPOINT,
|
|
16
|
+
REQUEST_CLAIM_TIMEOUT_SECONDS,
|
|
18
17
|
TAB_REF_PATTERN,
|
|
19
18
|
)
|
|
20
19
|
from .hmac import sha256_hex, sign_request, signature_matches
|
|
@@ -25,23 +24,18 @@ from .signing import (
|
|
|
25
24
|
CUSTOMER_TO_DEVORA,
|
|
26
25
|
DEVORA_TO_CUSTOMER,
|
|
27
26
|
build_canonical_string,
|
|
27
|
+
get_single_header,
|
|
28
28
|
has_identity_content_encoding,
|
|
29
29
|
is_valid_signed_path,
|
|
30
30
|
is_valid_signed_query,
|
|
31
31
|
matches,
|
|
32
32
|
parse_signature_headers,
|
|
33
|
-
|
|
34
|
-
replay_namespace,
|
|
33
|
+
parse_verified_json_body,
|
|
35
34
|
)
|
|
36
35
|
from .transport import ControlPlaneError, control_plane_request
|
|
37
|
-
from .replay import InMemoryReplayStore, ReplayStore
|
|
38
36
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
"""Explicit option first, then DEVORA_ENV / NODE_ENV as hints, else production."""
|
|
42
|
-
raw = (explicit or os.environ.get("DEVORA_ENV") or os.environ.get("NODE_ENV") or "production")
|
|
43
|
-
raw = str(raw).strip().lower()
|
|
44
|
-
return raw if raw in ("development", "test") else "production"
|
|
37
|
+
# A start request: ``POST .../impersonate/:id`` (path is relative to the SDK mount).
|
|
38
|
+
_START_REQUEST_PATH = re.compile(r"/impersonate/[^/]+\Z")
|
|
45
39
|
|
|
46
40
|
|
|
47
41
|
class DevoraBackendSDK:
|
|
@@ -54,8 +48,6 @@ class DevoraBackendSDK:
|
|
|
54
48
|
collect_stats: bool = False,
|
|
55
49
|
debug: bool = False,
|
|
56
50
|
api_url: Optional[str] = None,
|
|
57
|
-
replay_store: Optional[ReplayStore] = None,
|
|
58
|
-
environment: Optional[str] = None,
|
|
59
51
|
prefetch_scope_config: bool = False,
|
|
60
52
|
**_ignored_options: Any,
|
|
61
53
|
) -> None:
|
|
@@ -77,17 +69,6 @@ class DevoraBackendSDK:
|
|
|
77
69
|
self.debug = debug
|
|
78
70
|
self.stats = SDKStats()
|
|
79
71
|
self._routes: list[SDKRoute] = []
|
|
80
|
-
# Fail closed: anything that is not explicitly a development/test runtime
|
|
81
|
-
# is production, and production must share replay protection across
|
|
82
|
-
# processes. Explicit option first, then DEVORA_ENV / NODE_ENV as hints.
|
|
83
|
-
self.environment = resolve_environment(environment)
|
|
84
|
-
if self.environment == "production" and replay_store is None:
|
|
85
|
-
raise ValueError(
|
|
86
|
-
"Devora SDK: replay_store is required in production. Pass a persistent "
|
|
87
|
-
"ReplayStore, or environment='development' (InMemoryReplayStore) for local "
|
|
88
|
-
"development only."
|
|
89
|
-
)
|
|
90
|
-
self._replay_store = replay_store or InMemoryReplayStore()
|
|
91
72
|
self._scope_config = ScopeConfigFetcher(
|
|
92
73
|
api_key=api_key,
|
|
93
74
|
api_url=self.api_url,
|
|
@@ -156,7 +137,8 @@ class DevoraBackendSDK:
|
|
|
156
137
|
``path`` is relative to the SDK mount and still percent-encoded; ``query``
|
|
157
138
|
is everything after the first ``?``; ``body`` is the raw bytes; ``headers``
|
|
158
139
|
is a mapping or a list of ``(name, value)`` pairs (duplicates are
|
|
159
|
-
rejected).
|
|
140
|
+
rejected). A verified request is then claimed once from Devora; only a
|
|
141
|
+
request whose signature verified is ever claimed.
|
|
160
142
|
"""
|
|
161
143
|
tolerance = self.timestamp_tolerance if timestamp_tolerance is None else timestamp_tolerance
|
|
162
144
|
if not is_valid_timestamp_tolerance(tolerance):
|
|
@@ -198,24 +180,20 @@ class DevoraBackendSDK:
|
|
|
198
180
|
if not signature_matches(self._secret_key, canonical, parsed["signature"]):
|
|
199
181
|
return ValidationResult(valid=False, error="Invalid HMAC signature", error_code="INVALID_SIGNATURE")
|
|
200
182
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
return ValidationResult(
|
|
209
|
-
valid=False, error="Replay protection is unavailable", error_code="REPLAY_STORE_UNAVAILABLE"
|
|
183
|
+
# A start request names the session it starts; Devora lets it be claimed
|
|
184
|
+
# only while that session is still starting. (A start request without a
|
|
185
|
+
# valid session ID is claimed plainly; the handler then refuses it.)
|
|
186
|
+
session_id: Optional[str] = None
|
|
187
|
+
if method == "POST" and _START_REQUEST_PATH.search(path):
|
|
188
|
+
body_ok, parsed_body = parse_verified_json_body(
|
|
189
|
+
bytes(body), get_single_header(headers, "content-type") or None
|
|
210
190
|
)
|
|
211
|
-
|
|
212
|
-
if
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
if not fresh:
|
|
218
|
-
return ValidationResult(valid=False, error="Request was already processed", error_code="REPLAYED_REQUEST")
|
|
191
|
+
value = parsed_body.get("sessionId") if body_ok and isinstance(parsed_body, dict) else None
|
|
192
|
+
if isinstance(value, str) and value and len(value) <= 128:
|
|
193
|
+
session_id = value
|
|
194
|
+
rejection = self._claim_request(parsed["request_id"], parsed["sent_at"], session_id)
|
|
195
|
+
if rejection is not None:
|
|
196
|
+
return rejection
|
|
219
197
|
return ValidationResult(
|
|
220
198
|
valid=True,
|
|
221
199
|
org_id=parsed["org_id"],
|
|
@@ -233,7 +211,41 @@ class DevoraBackendSDK:
|
|
|
233
211
|
def refresh_scope_config(self) -> Optional[ScopeConfig]:
|
|
234
212
|
return self._scope_config.refresh()
|
|
235
213
|
|
|
236
|
-
def
|
|
214
|
+
def _claim_request(
|
|
215
|
+
self, request_id: str, sent_at: str, session_id: Optional[str]
|
|
216
|
+
) -> Optional[ValidationResult]:
|
|
217
|
+
"""Claim a verified request id from Devora: ``None`` for the first claim,
|
|
218
|
+
otherwise the rejection. Anything but a clear answer fails closed."""
|
|
219
|
+
payload: dict[str, Any] = {"requestId": request_id, "sentAt": sent_at}
|
|
220
|
+
if session_id is not None:
|
|
221
|
+
payload["sessionId"] = session_id
|
|
222
|
+
try:
|
|
223
|
+
status, reply = self._signed_devora_post(
|
|
224
|
+
REQUEST_CLAIM_ENDPOINT, payload, timeout=REQUEST_CLAIM_TIMEOUT_SECONDS
|
|
225
|
+
)
|
|
226
|
+
except (ControlPlaneError, ValueError):
|
|
227
|
+
status, reply = 0, None
|
|
228
|
+
data = reply.get("data") if reply else None
|
|
229
|
+
if 200 <= status < 300 and reply and reply.get("success") is True and isinstance(data, dict) and data.get("claimed") is True:
|
|
230
|
+
return None
|
|
231
|
+
code = reply.get("errorCode") if reply and status == 409 else None
|
|
232
|
+
if code == "REPLAYED_REQUEST":
|
|
233
|
+
return ValidationResult(valid=False, error="Request was already processed", error_code="REPLAYED_REQUEST")
|
|
234
|
+
if code == "SESSION_NOT_STARTABLE":
|
|
235
|
+
return ValidationResult(
|
|
236
|
+
valid=False, error="Devora is no longer starting this session", error_code="SESSION_NOT_STARTABLE"
|
|
237
|
+
)
|
|
238
|
+
if code == "TIMESTAMP_EXPIRED":
|
|
239
|
+
return ValidationResult(valid=False, error="Request is too old", error_code="TIMESTAMP_EXPIRED")
|
|
240
|
+
return ValidationResult(
|
|
241
|
+
valid=False,
|
|
242
|
+
error="Devora could not confirm this request is new",
|
|
243
|
+
error_code="REQUEST_CLAIM_UNAVAILABLE",
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
def _signed_devora_post(
|
|
247
|
+
self, path: str, payload: dict[str, Any], timeout: float = 5.0
|
|
248
|
+
) -> tuple[int, Optional[dict[str, Any]]]:
|
|
237
249
|
"""POST a signed JSON body to a Devora SDK endpoint (customer-to-devora)."""
|
|
238
250
|
body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
239
251
|
headers = sign_request(
|
|
@@ -245,7 +257,7 @@ class DevoraBackendSDK:
|
|
|
245
257
|
path=path,
|
|
246
258
|
body=body,
|
|
247
259
|
)
|
|
248
|
-
status, json_body, _ = control_plane_request(f"{self.api_url}{path}", "POST", headers, body)
|
|
260
|
+
status, json_body, _ = control_plane_request(f"{self.api_url}{path}", "POST", headers, body, timeout=timeout)
|
|
249
261
|
return status, json_body
|
|
250
262
|
|
|
251
263
|
def get_session_status(self, session_id: str) -> Optional[dict[str, Any]]:
|
|
@@ -341,8 +353,6 @@ def devora_sdk(
|
|
|
341
353
|
collect_stats: bool = False,
|
|
342
354
|
debug: bool = False,
|
|
343
355
|
api_url: Optional[str] = None,
|
|
344
|
-
replay_store: Optional[ReplayStore] = None,
|
|
345
|
-
environment: Optional[str] = None,
|
|
346
356
|
prefetch_scope_config: bool = False,
|
|
347
357
|
**_ignored_options: Any,
|
|
348
358
|
) -> DevoraBackendSDK:
|
|
@@ -354,8 +364,6 @@ def devora_sdk(
|
|
|
354
364
|
collect_stats=collect_stats,
|
|
355
365
|
debug=debug,
|
|
356
366
|
api_url=api_url,
|
|
357
|
-
replay_store=replay_store,
|
|
358
|
-
environment=environment,
|
|
359
367
|
prefetch_scope_config=prefetch_scope_config,
|
|
360
368
|
)
|
|
361
369
|
|
devora_sdk/signing.py
CHANGED
|
@@ -170,15 +170,6 @@ def has_identity_content_encoding(headers: HeaderInput) -> bool:
|
|
|
170
170
|
return value is None or value == "identity"
|
|
171
171
|
|
|
172
172
|
|
|
173
|
-
def replay_namespace(direction: str, key_id: str) -> str:
|
|
174
|
-
return f"v{VERSION}:{direction}:{key_id}"
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
def replay_expires_at_ms(sent_at: int, now_seconds: int, tolerance_seconds: int) -> int:
|
|
178
|
-
"""Covers the floor second of the timestamp check plus one second of jitter."""
|
|
179
|
-
return (max(now_seconds, sent_at) + tolerance_seconds + 2) * 1000
|
|
180
|
-
|
|
181
|
-
|
|
182
173
|
def parse_verified_query(query: str) -> dict[str, Union[str, list[str]]]:
|
|
183
174
|
"""Parse a verified raw query. Repeated keys become lists in wire order."""
|
|
184
175
|
result: dict[str, Union[str, list[str]]] = {}
|
devora_sdk/utils.py
CHANGED
|
@@ -64,10 +64,12 @@ def get_error_status_code(error_code: Optional[str]) -> int:
|
|
|
64
64
|
"IMPERSONATION_SESSION_ENDED",
|
|
65
65
|
):
|
|
66
66
|
return 401
|
|
67
|
-
if error_code in ("IMPERSONATION_POLICY_UNAVAILABLE", "
|
|
67
|
+
if error_code in ("IMPERSONATION_POLICY_UNAVAILABLE", "REQUEST_CLAIM_UNAVAILABLE"):
|
|
68
68
|
return 503
|
|
69
69
|
if error_code == "REPLAYED_REQUEST":
|
|
70
70
|
return 401
|
|
71
|
+
if error_code == "SESSION_NOT_STARTABLE":
|
|
72
|
+
return 409
|
|
71
73
|
if error_code == "UNSUPPORTED_CONTENT_ENCODING":
|
|
72
74
|
return 415
|
|
73
75
|
if error_code in ("IMPERSONATION_ENDPOINT_BLOCKED", "IMPERSONATION_SCOPE_VIOLATION"):
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
devora_sdk/__init__.py,sha256=X3_377Yyj4fesPUdFmVRG_4MLQCMqaVyjl1uQkXLlUg,2214
|
|
2
|
-
devora_sdk/api_url_gen.py,sha256=wfYa1I23mbENK9IoDQrw0ZT_6ivkszRI9Jhs48yTcYI,116
|
|
3
|
-
devora_sdk/browser_session.py,sha256=Get6voUbs4dg9ZvqJtBgiMr4gOqtHZh-Gi9xHX6E-tY,2007
|
|
4
|
-
devora_sdk/constants.py,sha256=HBoHYI5dqPL34Eg5pjGCnSw-e8mzjwJ65dLpyprcCDw,1395
|
|
5
|
-
devora_sdk/guard.py,sha256=idlLUrt-HKstvuLJvwy4oCiW0BQ90n_WD3XiYMS6lCU,18284
|
|
6
|
-
devora_sdk/handler.py,sha256=Tk98Z7c26gORnlE1CQoKrI9CnwGXuW56Is5oV0cI4K4,10295
|
|
7
|
-
devora_sdk/hmac.py,sha256=a0OyYEij39j_rWXsgIyKsbgydl0dpVgiIs-BYZR198k,1889
|
|
8
|
-
devora_sdk/models.py,sha256=D-uVBWElbkZGkBMeSlUjrO3aeY8baVEJTGhfuGUZT_Y,1768
|
|
9
|
-
devora_sdk/policy.py,sha256=MykEDCuCSvH9mso73ShsEdN5XtnMiJRHmBY4c0LuEZM,6999
|
|
10
|
-
devora_sdk/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
11
|
-
devora_sdk/replay.py,sha256=A0SWPt1ByPZKPGk6mEU2i-rk8eLIEj8yP2aQl6dKPCA,1428
|
|
12
|
-
devora_sdk/sdk.py,sha256=njEEq-H6KKdEyhVa5MwsxcAZjr8xWBf4z-VjWv8-l_4,14162
|
|
13
|
-
devora_sdk/security.py,sha256=gfKwz_rF9tGXFejNcwOPBqlczK_g1AZrKZe1SMxdKCA,1437
|
|
14
|
-
devora_sdk/signing.py,sha256=TzB_DEDX6hkN5cZeJVXXp7Q6Z8OKFXLD8GP9OvopZuA,8906
|
|
15
|
-
devora_sdk/transport.py,sha256=plTQ0J4B_-LGUs6waOqBk8wL4P06KuS_LilyEYr9sW4,5528
|
|
16
|
-
devora_sdk/utils.py,sha256=ZC1kmHMB6RVocv1uqpG9srRsQciOO0SzZEKyswY5NLk,5527
|
|
17
|
-
devora_python-0.1.1.dist-info/METADATA,sha256=fvnBQv-yW_0I3UWUcVcFumBw-VHH3o71g6Fv6j37Zos,6879
|
|
18
|
-
devora_python-0.1.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
19
|
-
devora_python-0.1.1.dist-info/licenses/LICENSE,sha256=ZrqlZJezuSZhSOx4R8S5JOwwQ-sLmssj2XW7-3coRjA,1063
|
|
20
|
-
devora_python-0.1.1.dist-info/RECORD,,
|
devora_sdk/replay.py
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
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
|
|
File without changes
|
|
File without changes
|