devora-python 0.1.1__tar.gz → 0.1.2__tar.gz

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.
Files changed (22) hide show
  1. {devora_python-0.1.1 → devora_python-0.1.2}/PKG-INFO +76 -42
  2. devora_python-0.1.2/README.md +178 -0
  3. {devora_python-0.1.1 → devora_python-0.1.2}/pyproject.toml +1 -1
  4. {devora_python-0.1.1 → devora_python-0.1.2}/src/devora_sdk/__init__.py +6 -3
  5. {devora_python-0.1.1 → devora_python-0.1.2}/src/devora_sdk/constants.py +8 -3
  6. {devora_python-0.1.1 → devora_python-0.1.2}/src/devora_sdk/handler.py +4 -4
  7. {devora_python-0.1.1 → devora_python-0.1.2}/src/devora_sdk/models.py +24 -1
  8. {devora_python-0.1.1 → devora_python-0.1.2}/src/devora_sdk/sdk.py +57 -49
  9. {devora_python-0.1.1 → devora_python-0.1.2}/src/devora_sdk/signing.py +0 -9
  10. {devora_python-0.1.1 → devora_python-0.1.2}/src/devora_sdk/utils.py +3 -1
  11. devora_python-0.1.1/README.md +0 -144
  12. devora_python-0.1.1/src/devora_sdk/replay.py +0 -41
  13. {devora_python-0.1.1 → devora_python-0.1.2}/.gitignore +0 -0
  14. {devora_python-0.1.1 → devora_python-0.1.2}/LICENSE +0 -0
  15. {devora_python-0.1.1 → devora_python-0.1.2}/src/devora_sdk/api_url_gen.py +0 -0
  16. {devora_python-0.1.1 → devora_python-0.1.2}/src/devora_sdk/browser_session.py +0 -0
  17. {devora_python-0.1.1 → devora_python-0.1.2}/src/devora_sdk/guard.py +0 -0
  18. {devora_python-0.1.1 → devora_python-0.1.2}/src/devora_sdk/hmac.py +0 -0
  19. {devora_python-0.1.1 → devora_python-0.1.2}/src/devora_sdk/policy.py +0 -0
  20. {devora_python-0.1.1 → devora_python-0.1.2}/src/devora_sdk/py.typed +0 -0
  21. {devora_python-0.1.1 → devora_python-0.1.2}/src/devora_sdk/security.py +0 -0
  22. {devora_python-0.1.1 → devora_python-0.1.2}/src/devora_sdk/transport.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: devora-python
3
- Version: 0.1.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
- - In production, a replay store shared by every worker (the example below uses
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 redis
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
- return {"users": search_customer_users(req.query.get("term", ""))}
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 replay store and
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
- ### Production replay store
116
-
117
- Every signed request carries a single-use id. In production the SDK needs a
118
- shared, atomic `replay_store` so a captured request cannot be replayed against
119
- another worker or instance. The environment comes from `environment=`, else
120
- `DEVORA_ENV`, else `NODE_ENV`. Anything other than `development` or `test`
121
- (including unset) is production, and production refuses to start without a
122
- `replay_store`. Any store whose `consume` is an atomic insert-if-absent works;
123
- see the
124
- [replay-store contract](https://github.com/getdevora/devora-sdks/blob/main/SIGNING.md#replay-store).
125
-
126
- **Local development only:** to run without Redis, omit `replay_store` and set
127
- `DEVORA_ENV=development` (or pass `environment="development"`). The SDK then
128
- keeps request ids in memory, which protects a single process only. Never use
129
- this in production.
130
-
131
- `consume` must be a regular method returning `True` or `False`; the SDK calls
132
- it synchronously, including from `async_process_request` (in a worker thread).
133
- Use a synchronous client such as `redis.Redis`: an `async def consume` fails
134
- closed with a 503. Keep it a single short round trip, and do not let the store
135
- evict these keys before they expire. A unique-key database insert with an
136
- expiry column works too.
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,178 @@
1
+ # devora-python
2
+
3
+ Recording, masking and capture policy are configured in the Devora dashboard and
4
+ authorized server-side for each session. This backend SDK takes no capture
5
+ settings, and `devora_sdk()` ignores keyword arguments it does not recognize,
6
+ so check option names carefully. See
7
+ [capture settings](https://github.com/getdevora/devora-sdks/blob/main/SETTINGS.md).
8
+
9
+ Python backend core SDK for Devora customer integrations.
10
+
11
+ ## Requirements
12
+
13
+ - Python `>=3.10,<4.0`
14
+ - Outbound HTTPS access from your backend to the Devora API
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install devora-python
20
+ ```
21
+
22
+ ## Quick Start
23
+
24
+ ```python
25
+ import os
26
+ from dataclasses import asdict
27
+
28
+ from devora_sdk import DEVORA_ENDPOINTS, devora_sdk
29
+
30
+ sdk = devora_sdk(
31
+ api_key=os.environ["DEVORA_API_KEY"], # pk_server_live_...
32
+ secret_key=os.environ["DEVORA_SECRET_KEY"], # sk_server_live_...
33
+ org_id=os.environ["DEVORA_ORG_ID"],
34
+ )
35
+
36
+
37
+ @sdk.register(DEVORA_ENDPOINTS.USER_SEARCH)
38
+ def search_users(req):
39
+ # Match name, email and the exact user ID with a parameterised query.
40
+ term = req.query.get("term", "")
41
+ limit = int(req.query.get("limit", 10))
42
+ users = search_customer_users(term, limit)
43
+ return {
44
+ "users": [
45
+ {
46
+ "id": u.id,
47
+ "name": u.name,
48
+ "email": u.email,
49
+ "attributes": {"company": u.company, "role": u.role, "plan": u.plan},
50
+ }
51
+ for u in users
52
+ ]
53
+ }
54
+
55
+
56
+ @sdk.register(DEVORA_ENDPOINTS.IMPERSONATE)
57
+ def impersonate(req):
58
+ ctx = req.devora_context
59
+ if not ctx:
60
+ raise ValueError("Missing impersonation context")
61
+ # Store the whole verified context in the token: the scope guard reads every
62
+ # field of it back. Expire the token no later than ctx.expires_at.
63
+ devora = {**asdict(ctx), "is_impersonation": True}
64
+ token = create_customer_token(ctx.target_user["id"], devora)
65
+ return {"token": token}
66
+
67
+
68
+ @sdk.register(DEVORA_ENDPOINTS.TERMINATE)
69
+ def terminate(req):
70
+ session_id = req.params["id"]
71
+ reason = (req.body or {}).get("reason")
72
+ # YOU IMPLEMENT: mark this Devora session revoked so every credential issued for it
73
+ # is rejected, including one issued after this call. Must be idempotent: Devora
74
+ # retries with a new request id (up to 5 attempts over 6 hours).
75
+ auth.revoke_impersonation_session(session_id=session_id, reason=reason)
76
+ return {"success": True}
77
+ ```
78
+
79
+ 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.
80
+
81
+ Each verified request is claimed once from Devora before your handler runs, so
82
+ replay protection needs no storage on your side; your backend only needs
83
+ outbound HTTPS access to the Devora API. See [Replay protection](#replay-protection).
84
+
85
+ Register every handler, then mount the SDK with the
86
+ [Django](https://pypi.org/project/devora-django/) or
87
+ [FastAPI](https://pypi.org/project/devora-fastapi/) adapter. For any other
88
+ framework, pass the request exactly as received to `process_request` (sync) or
89
+ `await async_process_request` (async):
90
+
91
+ ```python
92
+ from devora_sdk import AdapterRequest, process_request
93
+ from devora_sdk.utils import get_error_status_code
94
+
95
+
96
+ def handle_devora(method: str, path: str, query: str, body: bytes, headers) -> tuple[int, dict]:
97
+ # path: still percent-encoded and relative to your Devora mount, e.g. "/user/search"
98
+ # query: the raw query string without "?"; body: the raw bytes (cap the read yourself)
99
+ # headers: a mapping, or (name, value) pairs so duplicate headers stay visible
100
+ result = process_request(sdk, sdk.get_routes(), AdapterRequest(method, path, query, body, headers))
101
+ status = 200 if result["success"] else get_error_status_code(result.get("errorCode"))
102
+ return status, result # send as JSON with Cache-Control: private, no-store
103
+ ```
104
+
105
+ `async_process_request` runs signature verification, the request claim and
106
+ synchronous handlers in a worker thread, so it never blocks the event loop.
107
+ `process_request` cannot run `async def` handlers.
108
+
109
+ ### Replay protection
110
+
111
+ Every signed request carries a single-use id. After the signature verifies, the
112
+ SDK claims that id from Devora with one signed call
113
+ (`POST /api/sdk/request-claim`, `REQUEST_CLAIM_TIMEOUT_SECONDS = 3.0`); the
114
+ handler runs only after a successful claim. Only requests whose signature
115
+ verified are ever claimed, so unauthenticated traffic cannot use up request
116
+ ids. The claim can fail with:
117
+
118
+ | Status | `errorCode` | Cause |
119
+ | ------ | --------------------------- | ------------------------------------------------------------------------ |
120
+ | 401 | `REPLAYED_REQUEST` | Devora already claimed this request id (the request was already processed) |
121
+ | 409 | `SESSION_NOT_STARTABLE` | Start request for a session Devora is no longer starting |
122
+ | 401 | `TIMESTAMP_EXPIRED` | Devora says the request is too old to claim |
123
+ | 503 | `REQUEST_CLAIM_UNAVAILABLE` | Devora could not be reached or gave no clear answer (fails closed) |
124
+
125
+ Devora's **Test connection** check is claimed too, so it also proves your
126
+ backend can reach the Devora API. See
127
+ [SIGNING.md](https://github.com/getdevora/devora-sdks/blob/main/SIGNING.md#request-claims).
128
+
129
+ ### Session termination
130
+
131
+ Devora calls `DELETE /impersonate/:id/terminate` when a session ends. The
132
+ session id is `req.params["id"]` (also `req.session_id`), and the body is
133
+ `{"reason": ..., "terminatedBy": ...}`. `terminatedBy` is the external user id
134
+ of the person who ended the session and is absent for automatic ends. `reason`
135
+ is one of `user_ended`, `time_limit`, `admin_terminated`, `superseded`,
136
+ `request_revoked`, `membership_revoked`, `role_downgraded`,
137
+ `workos_session_revoked`, `principal_erased`, `organization_erased`,
138
+ `start_failed` (Devora sent the start request, and your handler may have issued
139
+ a token, but the session never started) or `not_started` (your handler issued a
140
+ token but the impersonation link was never opened).
141
+
142
+ Devora retries a failed terminate call (up to 5 attempts over 6 hours, with
143
+ backoff), each as a new signed request with a new request id, so the handler
144
+ must be idempotent; any 2xx counts as delivered. A 4xx other than 408, 425 or 429 is
145
+ not retried; when overloaded, answer 429 or 503 with `Retry-After`. Store the
146
+ Devora session id with the credential you mint and revoke by session, so a
147
+ credential minted by a slow start handler after the terminate is still rejected. See
148
+ [Session lifecycle & cleanup](https://docs.devora.sh/guide/session-lifecycle).
149
+
150
+ The guard's session-liveness checker caches at most 1,024 results and permits at
151
+ most 64 distinct concurrent lookups per checker. Requests for the same session
152
+ share a lookup. Live/ended results use the configured TTL (five seconds by
153
+ default); unavailable results use at most one second. Cache hits never extend a
154
+ verdict's lifetime. Capacity exhaustion follows the configured unavailable
155
+ policy, which denies access by default.
156
+
157
+ ### Cold-start latency
158
+
159
+ The impersonation guard's scope policy is fetched lazily on first use, so the
160
+ first request handled by a freshly started worker process pays for that fetch
161
+ synchronously (and any request racing it gets a `503
162
+ IMPERSONATION_POLICY_UNAVAILABLE` rather than waiting). If your deployment
163
+ runs multiple worker processes (gunicorn, uWSGI, etc.), pass
164
+ `prefetch_scope_config=True` to start that fetch in a background thread as soon
165
+ as `devora_sdk(...)` is constructed, so the cache is usually warm by the first
166
+ request:
167
+
168
+ ```python
169
+ sdk = devora_sdk(
170
+ api_key=os.environ["DEVORA_API_KEY"],
171
+ secret_key=os.environ["DEVORA_SECRET_KEY"],
172
+ org_id=os.environ["DEVORA_ORG_ID"],
173
+ prefetch_scope_config=True,
174
+ )
175
+ ```
176
+
177
+ Options, error codes and the guard are documented in the
178
+ [Python reference](https://docs.devora.sh/reference/python).
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "devora-python"
7
- version = "0.1.1"
7
+ version = "0.1.2"
8
8
  description = "Devora Python backend SDK for secure impersonation"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10,<4.0"
@@ -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",
@@ -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.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"
@@ -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 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
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)
@@ -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)
@@ -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
- replay_expires_at_ms,
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
- def resolve_environment(explicit: Optional[str] = None) -> str:
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). Steps before the replay store never touch it.
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
- try:
202
- fresh = self._replay_store.consume(
203
- replay_namespace(DEVORA_TO_CUSTOMER, parsed["key_id"]),
204
- parsed["request_id"],
205
- replay_expires_at_ms(sent_at, int(time.time()), tolerance),
206
- )
207
- except Exception:
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
- if not isinstance(fresh, bool):
212
- if inspect.iscoroutine(fresh):
213
- fresh.close()
214
- return ValidationResult(
215
- valid=False, error="Replay store must return a boolean decision", error_code="REPLAY_STORE_UNAVAILABLE"
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 _signed_devora_post(self, path: str, payload: dict[str, Any]) -> tuple[int, Optional[dict[str, Any]]]:
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
 
@@ -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]]] = {}
@@ -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", "REPLAY_STORE_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,144 +0,0 @@
1
- # devora-python
2
-
3
- Recording, masking and capture policy are configured in the Devora dashboard and
4
- authorized server-side for each session. This backend SDK takes no capture
5
- settings, and `devora_sdk()` ignores keyword arguments it does not recognize,
6
- so check option names carefully. See
7
- [capture settings](https://github.com/getdevora/devora-sdks/blob/main/SETTINGS.md).
8
-
9
- Python backend core SDK for Devora customer integrations.
10
-
11
- ## Requirements
12
-
13
- - Python `>=3.10,<4.0`
14
- - In production, a replay store shared by every worker (the example below uses
15
- Redis 6.2 or newer, for `SET ... PXAT`)
16
-
17
- ## Install
18
-
19
- ```bash
20
- pip install devora-python redis
21
- ```
22
-
23
- ## Quick Start
24
-
25
- ```python
26
- import os
27
- from dataclasses import asdict
28
-
29
- import redis
30
- from devora_sdk import DEVORA_ENDPOINTS, devora_sdk
31
-
32
- # Replay protection shared by every worker and instance (required in production).
33
- redis_client = redis.Redis.from_url(os.environ["REDIS_URL"])
34
-
35
-
36
- class RedisReplayStore:
37
- def consume(self, namespace: str, request_id: str, expires_at: int) -> bool:
38
- # Atomic insert-if-absent kept until expires_at; an exception makes the SDK fail closed (503).
39
- key = f"devora:replay:{namespace}:{request_id}"
40
- return bool(redis_client.set(key, b"1", nx=True, pxat=expires_at))
41
-
42
-
43
- sdk = devora_sdk(
44
- api_key=os.environ["DEVORA_API_KEY"], # pk_server_live_...
45
- secret_key=os.environ["DEVORA_SECRET_KEY"], # sk_server_live_...
46
- org_id=os.environ["DEVORA_ORG_ID"],
47
- replay_store=RedisReplayStore(),
48
- )
49
-
50
-
51
- @sdk.register(DEVORA_ENDPOINTS.USER_SEARCH)
52
- def search_users(req):
53
- return {"users": search_customer_users(req.query.get("term", ""))}
54
-
55
-
56
- @sdk.register(DEVORA_ENDPOINTS.IMPERSONATE)
57
- def impersonate(req):
58
- ctx = req.devora_context
59
- if not ctx:
60
- raise ValueError("Missing impersonation context")
61
- # Store the whole verified context in the token: the scope guard reads every
62
- # field of it back. Expire the token no later than ctx.expires_at.
63
- devora = {**asdict(ctx), "is_impersonation": True}
64
- token = create_customer_token(ctx.target_user["id"], devora)
65
- return {"token": token}
66
- ```
67
-
68
- Register every handler, then mount the SDK with the
69
- [Django](https://pypi.org/project/devora-django/) or
70
- [FastAPI](https://pypi.org/project/devora-fastapi/) adapter. For any other
71
- framework, pass the request exactly as received to `process_request` (sync) or
72
- `await async_process_request` (async):
73
-
74
- ```python
75
- from devora_sdk import AdapterRequest, process_request
76
- from devora_sdk.utils import get_error_status_code
77
-
78
-
79
- def handle_devora(method: str, path: str, query: str, body: bytes, headers) -> tuple[int, dict]:
80
- # path: still percent-encoded and relative to your Devora mount, e.g. "/user/search"
81
- # query: the raw query string without "?"; body: the raw bytes (cap the read yourself)
82
- # headers: a mapping, or (name, value) pairs so duplicate headers stay visible
83
- result = process_request(sdk, sdk.get_routes(), AdapterRequest(method, path, query, body, headers))
84
- status = 200 if result["success"] else get_error_status_code(result.get("errorCode"))
85
- return status, result # send as JSON with Cache-Control: private, no-store
86
- ```
87
-
88
- `async_process_request` runs signature verification, the replay store and
89
- synchronous handlers in a worker thread, so it never blocks the event loop.
90
- `process_request` cannot run `async def` handlers.
91
-
92
- ### Production replay store
93
-
94
- Every signed request carries a single-use id. In production the SDK needs a
95
- shared, atomic `replay_store` so a captured request cannot be replayed against
96
- another worker or instance. The environment comes from `environment=`, else
97
- `DEVORA_ENV`, else `NODE_ENV`. Anything other than `development` or `test`
98
- (including unset) is production, and production refuses to start without a
99
- `replay_store`. Any store whose `consume` is an atomic insert-if-absent works;
100
- see the
101
- [replay-store contract](https://github.com/getdevora/devora-sdks/blob/main/SIGNING.md#replay-store).
102
-
103
- **Local development only:** to run without Redis, omit `replay_store` and set
104
- `DEVORA_ENV=development` (or pass `environment="development"`). The SDK then
105
- keeps request ids in memory, which protects a single process only. Never use
106
- this in production.
107
-
108
- `consume` must be a regular method returning `True` or `False`; the SDK calls
109
- it synchronously, including from `async_process_request` (in a worker thread).
110
- Use a synchronous client such as `redis.Redis`: an `async def consume` fails
111
- closed with a 503. Keep it a single short round trip, and do not let the store
112
- evict these keys before they expire. A unique-key database insert with an
113
- expiry column works too.
114
-
115
- The guard's session-liveness checker caches at most 1,024 results and permits at
116
- most 64 distinct concurrent lookups per checker. Requests for the same session
117
- share a lookup. Live/ended results use the configured TTL (five seconds by
118
- default); unavailable results use at most one second. Cache hits never extend a
119
- verdict's lifetime. Capacity exhaustion follows the configured unavailable
120
- policy, which denies access by default.
121
-
122
- ### Cold-start latency
123
-
124
- The impersonation guard's scope policy is fetched lazily on first use, so the
125
- first request handled by a freshly started worker process pays for that fetch
126
- synchronously (and any request racing it gets a `503
127
- IMPERSONATION_POLICY_UNAVAILABLE` rather than waiting). If your deployment
128
- runs multiple worker processes (gunicorn, uWSGI, etc.), pass
129
- `prefetch_scope_config=True` to start that fetch in a background thread as soon
130
- as `devora_sdk(...)` is constructed, so the cache is usually warm by the first
131
- request:
132
-
133
- ```python
134
- sdk = devora_sdk(
135
- api_key=os.environ["DEVORA_API_KEY"],
136
- secret_key=os.environ["DEVORA_SECRET_KEY"],
137
- org_id=os.environ["DEVORA_ORG_ID"],
138
- replay_store=RedisReplayStore(),
139
- prefetch_scope_config=True,
140
- )
141
- ```
142
-
143
- Options, error codes and the guard are documented in the
144
- [Python reference](https://docs.devora.sh/reference/python).
@@ -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