devora-django 0.1.0__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.
- devora_django-0.1.0/.gitignore +14 -0
- devora_django-0.1.0/LICENSE +21 -0
- devora_django-0.1.0/PKG-INFO +141 -0
- devora_django-0.1.0/README.md +112 -0
- devora_django-0.1.0/pyproject.toml +46 -0
- devora_django-0.1.0/src/devora_sdk_django/__init__.py +21 -0
- devora_django-0.1.0/src/devora_sdk_django/adapter.py +559 -0
- devora_django-0.1.0/src/devora_sdk_django/py.typed +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Devora
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: devora-django
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Django adapter for the Devora Python backend SDK
|
|
5
|
+
Project-URL: Documentation, https://docs.devora.sh
|
|
6
|
+
Project-URL: Repository, https://github.com/getdevora/devora-sdks
|
|
7
|
+
Project-URL: Issues, https://github.com/getdevora/devora-sdks/issues
|
|
8
|
+
Author: Devora
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: devora,django,impersonation,sdk,security
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Framework :: Django
|
|
14
|
+
Classifier: Framework :: Django :: 5.2
|
|
15
|
+
Classifier: Framework :: Django :: 6.0
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: <4.0,>=3.10
|
|
25
|
+
Requires-Dist: devora-python<0.2.0,>=0.1.0
|
|
26
|
+
Requires-Dist: django!=6.0.0,!=6.0.1,!=6.0.2,!=6.0.3,!=6.0.4,!=6.0.5,!=6.0.6,!=6.0.7,<7.0,>=5.2.17
|
|
27
|
+
Requires-Dist: sqlparse>=0.6.0
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# devora-django
|
|
31
|
+
|
|
32
|
+
Recording, masking and activity preferences are configured in Devora Settings.
|
|
33
|
+
SDK initialization overrides are ignored. New sessions retain the server's policy
|
|
34
|
+
snapshot across exchange and resume. Developer privacy labels take effect only
|
|
35
|
+
when selected in Settings; sensitive-field protection remains mandatory.
|
|
36
|
+
See [migration details](https://github.com/getdevora/devora-sdks/blob/main/SETTINGS.md).
|
|
37
|
+
|
|
38
|
+
Django adapter for the Devora Python backend SDK.
|
|
39
|
+
|
|
40
|
+
## Requirements
|
|
41
|
+
|
|
42
|
+
- Python `>=3.10,<4.0`
|
|
43
|
+
- Django `>=5.2,<7.0`
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install devora-python devora-django
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Quick Start
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
# devora_integration.py
|
|
55
|
+
from devora_sdk import DEVORA_ENDPOINTS, devora_sdk
|
|
56
|
+
|
|
57
|
+
sdk = devora_sdk(
|
|
58
|
+
api_key="pk_server_live_...",
|
|
59
|
+
secret_key="sk_server_live_...",
|
|
60
|
+
org_id="org_...",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@sdk.register(DEVORA_ENDPOINTS.USER_SEARCH)
|
|
65
|
+
def search_users(req):
|
|
66
|
+
return {"users": search_customer_users(req.query.get("term", ""))}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
# urls.py
|
|
71
|
+
from django.urls import include, path
|
|
72
|
+
from devora_sdk_django import django_urlpatterns
|
|
73
|
+
from .devora_integration import sdk
|
|
74
|
+
|
|
75
|
+
urlpatterns = [
|
|
76
|
+
path("devora/", include(django_urlpatterns(sdk))),
|
|
77
|
+
]
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Use `async_django_urlpatterns(sdk)` instead when your exposed handlers are
|
|
81
|
+
async functions.
|
|
82
|
+
|
|
83
|
+
For protected application routes, install the guard middleware and extract a
|
|
84
|
+
trusted impersonation context from your authenticated request state.
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from devora_sdk import ImpersonationContext
|
|
88
|
+
from devora_sdk_django import create_impersonation_guard
|
|
89
|
+
from .devora_integration import sdk
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_impersonation_context(request):
|
|
93
|
+
devora = getattr(request.user, "devora", None)
|
|
94
|
+
if not devora:
|
|
95
|
+
return None
|
|
96
|
+
return ImpersonationContext(
|
|
97
|
+
is_impersonation=devora["isImpersonation"],
|
|
98
|
+
scope=devora["scope"],
|
|
99
|
+
session_id=devora["sessionId"],
|
|
100
|
+
expires_at=devora["expiresAt"],
|
|
101
|
+
actor=devora["actor"],
|
|
102
|
+
subject=devora["subject"],
|
|
103
|
+
auth_method=devora["authMethod"],
|
|
104
|
+
authorization_source=devora["authorizationSource"],
|
|
105
|
+
recording_allowed=devora["recordingAllowed"],
|
|
106
|
+
impersonator=devora.get("impersonator"),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
DevoraGuardMiddleware = create_impersonation_guard(
|
|
111
|
+
sdk=sdk,
|
|
112
|
+
get_impersonation_context=get_impersonation_context,
|
|
113
|
+
)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Add `"yourapp.devora_integration.DevoraGuardMiddleware"` to `settings.MIDDLEWARE`,
|
|
117
|
+
after your own authentication middleware.
|
|
118
|
+
|
|
119
|
+
`expires_at` must be a Unix timestamp in milliseconds. If your JWT stores Unix
|
|
120
|
+
seconds, multiply by `1000` when building the impersonation context. `actor`,
|
|
121
|
+
`subject`, `auth_method`, `authorization_source`, and `recording_allowed` are
|
|
122
|
+
all required — the guard rejects the context as invalid without them, even
|
|
123
|
+
though the dataclass marks them optional for construction convenience.
|
|
124
|
+
|
|
125
|
+
## Cross-Origin-Opener-Policy
|
|
126
|
+
|
|
127
|
+
Django's `SecurityMiddleware` sends `Cross-Origin-Opener-Policy: same-origin`
|
|
128
|
+
by default. That cuts the page opened by a Devora impersonation link off from
|
|
129
|
+
the dashboard tab that opened it, so the link cannot be redeemed. Set
|
|
130
|
+
`SECURE_CROSS_ORIGIN_OPENER_POLICY = None`, or send `unsafe-none` on the
|
|
131
|
+
route your Devora links land on.
|
|
132
|
+
|
|
133
|
+
## Request body limits
|
|
134
|
+
|
|
135
|
+
SDK views read at most `max_body_size + 1` bytes before parsing (1 MiB by default)
|
|
136
|
+
and reject oversized declared lengths without reading. The browser-session view
|
|
137
|
+
uses a 4 KiB limit. Async SDK views perform this bounded read off the event loop.
|
|
138
|
+
Set `DATA_UPLOAD_MAX_MEMORY_SIZE = 1024 * 1024` and keep earlier middleware from
|
|
139
|
+
buffering larger SDK bodies. WSGI/ASGI servers and reverse proxies also need
|
|
140
|
+
request-size and read-timeout limits: Django's ASGI handler can spool a request
|
|
141
|
+
before a view runs, and the SDK cannot bound that prior allocation.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# devora-django
|
|
2
|
+
|
|
3
|
+
Recording, masking and activity preferences are configured in Devora Settings.
|
|
4
|
+
SDK initialization overrides are ignored. New sessions retain the server's policy
|
|
5
|
+
snapshot across exchange and resume. Developer privacy labels take effect only
|
|
6
|
+
when selected in Settings; sensitive-field protection remains mandatory.
|
|
7
|
+
See [migration details](https://github.com/getdevora/devora-sdks/blob/main/SETTINGS.md).
|
|
8
|
+
|
|
9
|
+
Django adapter for the Devora Python backend SDK.
|
|
10
|
+
|
|
11
|
+
## Requirements
|
|
12
|
+
|
|
13
|
+
- Python `>=3.10,<4.0`
|
|
14
|
+
- Django `>=5.2,<7.0`
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install devora-python devora-django
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
# devora_integration.py
|
|
26
|
+
from devora_sdk import DEVORA_ENDPOINTS, devora_sdk
|
|
27
|
+
|
|
28
|
+
sdk = devora_sdk(
|
|
29
|
+
api_key="pk_server_live_...",
|
|
30
|
+
secret_key="sk_server_live_...",
|
|
31
|
+
org_id="org_...",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@sdk.register(DEVORA_ENDPOINTS.USER_SEARCH)
|
|
36
|
+
def search_users(req):
|
|
37
|
+
return {"users": search_customer_users(req.query.get("term", ""))}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
# urls.py
|
|
42
|
+
from django.urls import include, path
|
|
43
|
+
from devora_sdk_django import django_urlpatterns
|
|
44
|
+
from .devora_integration import sdk
|
|
45
|
+
|
|
46
|
+
urlpatterns = [
|
|
47
|
+
path("devora/", include(django_urlpatterns(sdk))),
|
|
48
|
+
]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Use `async_django_urlpatterns(sdk)` instead when your exposed handlers are
|
|
52
|
+
async functions.
|
|
53
|
+
|
|
54
|
+
For protected application routes, install the guard middleware and extract a
|
|
55
|
+
trusted impersonation context from your authenticated request state.
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from devora_sdk import ImpersonationContext
|
|
59
|
+
from devora_sdk_django import create_impersonation_guard
|
|
60
|
+
from .devora_integration import sdk
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_impersonation_context(request):
|
|
64
|
+
devora = getattr(request.user, "devora", None)
|
|
65
|
+
if not devora:
|
|
66
|
+
return None
|
|
67
|
+
return ImpersonationContext(
|
|
68
|
+
is_impersonation=devora["isImpersonation"],
|
|
69
|
+
scope=devora["scope"],
|
|
70
|
+
session_id=devora["sessionId"],
|
|
71
|
+
expires_at=devora["expiresAt"],
|
|
72
|
+
actor=devora["actor"],
|
|
73
|
+
subject=devora["subject"],
|
|
74
|
+
auth_method=devora["authMethod"],
|
|
75
|
+
authorization_source=devora["authorizationSource"],
|
|
76
|
+
recording_allowed=devora["recordingAllowed"],
|
|
77
|
+
impersonator=devora.get("impersonator"),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
DevoraGuardMiddleware = create_impersonation_guard(
|
|
82
|
+
sdk=sdk,
|
|
83
|
+
get_impersonation_context=get_impersonation_context,
|
|
84
|
+
)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Add `"yourapp.devora_integration.DevoraGuardMiddleware"` to `settings.MIDDLEWARE`,
|
|
88
|
+
after your own authentication middleware.
|
|
89
|
+
|
|
90
|
+
`expires_at` must be a Unix timestamp in milliseconds. If your JWT stores Unix
|
|
91
|
+
seconds, multiply by `1000` when building the impersonation context. `actor`,
|
|
92
|
+
`subject`, `auth_method`, `authorization_source`, and `recording_allowed` are
|
|
93
|
+
all required — the guard rejects the context as invalid without them, even
|
|
94
|
+
though the dataclass marks them optional for construction convenience.
|
|
95
|
+
|
|
96
|
+
## Cross-Origin-Opener-Policy
|
|
97
|
+
|
|
98
|
+
Django's `SecurityMiddleware` sends `Cross-Origin-Opener-Policy: same-origin`
|
|
99
|
+
by default. That cuts the page opened by a Devora impersonation link off from
|
|
100
|
+
the dashboard tab that opened it, so the link cannot be redeemed. Set
|
|
101
|
+
`SECURE_CROSS_ORIGIN_OPENER_POLICY = None`, or send `unsafe-none` on the
|
|
102
|
+
route your Devora links land on.
|
|
103
|
+
|
|
104
|
+
## Request body limits
|
|
105
|
+
|
|
106
|
+
SDK views read at most `max_body_size + 1` bytes before parsing (1 MiB by default)
|
|
107
|
+
and reject oversized declared lengths without reading. The browser-session view
|
|
108
|
+
uses a 4 KiB limit. Async SDK views perform this bounded read off the event loop.
|
|
109
|
+
Set `DATA_UPLOAD_MAX_MEMORY_SIZE = 1024 * 1024` and keep earlier middleware from
|
|
110
|
+
buffering larger SDK bodies. WSGI/ASGI servers and reverse proxies also need
|
|
111
|
+
request-size and read-timeout limits: Django's ASGI handler can spool a request
|
|
112
|
+
before a view runs, and the SDK cannot bound that prior allocation.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "devora-django"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Django adapter for the Devora Python backend SDK"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10,<4.0"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Devora" }]
|
|
14
|
+
keywords = ["devora", "sdk", "django", "impersonation", "security"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 5 - Production/Stable",
|
|
17
|
+
"Framework :: Django",
|
|
18
|
+
"Framework :: Django :: 5.2",
|
|
19
|
+
"Framework :: Django :: 6.0",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Programming Language :: Python :: 3.14",
|
|
27
|
+
"Typing :: Typed",
|
|
28
|
+
]
|
|
29
|
+
dependencies = [
|
|
30
|
+
"devora-python>=0.1.0,<0.2.0",
|
|
31
|
+
# Security floors: the lowest versions without known advisories (audited 2026-09-28).
|
|
32
|
+
"Django>=5.2.17,<7.0,!=6.0.0,!=6.0.1,!=6.0.2,!=6.0.3,!=6.0.4,!=6.0.5,!=6.0.6,!=6.0.7",
|
|
33
|
+
"sqlparse>=0.6.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Documentation = "https://docs.devora.sh"
|
|
38
|
+
Repository = "https://github.com/getdevora/devora-sdks"
|
|
39
|
+
Issues = "https://github.com/getdevora/devora-sdks/issues"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/devora_sdk_django"]
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.sdist]
|
|
45
|
+
# Tests and local tooling stay in the repository; the sdist carries the package.
|
|
46
|
+
include = ["src", "README.md", "LICENSE", "pyproject.toml"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from .adapter import (
|
|
2
|
+
browser_session_view,
|
|
3
|
+
DjangoAdapterOptions,
|
|
4
|
+
DjangoImpersonationGuardOptions,
|
|
5
|
+
async_django_urlpatterns,
|
|
6
|
+
create_impersonation_guard,
|
|
7
|
+
django_adapter,
|
|
8
|
+
django_route_pattern,
|
|
9
|
+
django_urlpatterns,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"browser_session_view",
|
|
14
|
+
"DjangoAdapterOptions",
|
|
15
|
+
"DjangoImpersonationGuardOptions",
|
|
16
|
+
"async_django_urlpatterns",
|
|
17
|
+
"create_impersonation_guard",
|
|
18
|
+
"django_adapter",
|
|
19
|
+
"django_route_pattern",
|
|
20
|
+
"django_urlpatterns",
|
|
21
|
+
]
|
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import warnings
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Callable, Mapping, Optional
|
|
9
|
+
|
|
10
|
+
from asgiref.sync import iscoroutinefunction, markcoroutinefunction
|
|
11
|
+
from devora_sdk import (
|
|
12
|
+
AdapterRequest,
|
|
13
|
+
ImpersonationContext,
|
|
14
|
+
InvalidImpersonationContext,
|
|
15
|
+
route_relative_path,
|
|
16
|
+
strict_encode,
|
|
17
|
+
ProcessRequestOptions,
|
|
18
|
+
SessionLivenessChecker,
|
|
19
|
+
async_process_request,
|
|
20
|
+
coerce_impersonation_context,
|
|
21
|
+
evaluate_impersonation_guard,
|
|
22
|
+
policy_methods,
|
|
23
|
+
process_request,
|
|
24
|
+
validate_timestamp_tolerance,
|
|
25
|
+
)
|
|
26
|
+
from devora_sdk.models import SDKRoute
|
|
27
|
+
from devora_sdk.utils import create_error_response, get_error_status_code
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _private_json_response(*args: Any, **kwargs: Any) -> Any:
|
|
31
|
+
from django.http import JsonResponse
|
|
32
|
+
|
|
33
|
+
response = JsonResponse(*args, **kwargs)
|
|
34
|
+
# These routes authenticate with custom headers, so caches cannot infer that
|
|
35
|
+
# customer search/detail responses require authorization.
|
|
36
|
+
response["Cache-Control"] = "private, no-store"
|
|
37
|
+
return response
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class DjangoAdapterOptions:
|
|
42
|
+
timestamp_tolerance: Optional[int] = None
|
|
43
|
+
max_body_size: Optional[int] = None
|
|
44
|
+
trailing_slash: bool = False
|
|
45
|
+
|
|
46
|
+
def __post_init__(self) -> None:
|
|
47
|
+
validate_timestamp_tolerance(self.timestamp_tolerance)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class DjangoImpersonationGuardOptions:
|
|
52
|
+
sdk: Any
|
|
53
|
+
get_impersonation_context: Callable[[Any], Any]
|
|
54
|
+
on_blocked: Optional[Callable[[Any, ImpersonationContext], None]] = None
|
|
55
|
+
blocked_response: Mapping[str, Any] = field(default_factory=dict)
|
|
56
|
+
show_warnings: bool = False
|
|
57
|
+
enforce_liveness: bool = True
|
|
58
|
+
liveness_cache_ttl_ms: int = 5_000
|
|
59
|
+
on_liveness_unavailable: str = "deny"
|
|
60
|
+
is_impersonation_allowed: Optional[Callable[[Any, ImpersonationContext], bool]] = None
|
|
61
|
+
bridge_path: Optional[str] = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def django_urlpatterns(sdk: Any, options: Optional[DjangoAdapterOptions] = None) -> list[Any]:
|
|
65
|
+
from django.urls import re_path
|
|
66
|
+
|
|
67
|
+
adapter_options = options or DjangoAdapterOptions()
|
|
68
|
+
routes = sdk.get_routes()
|
|
69
|
+
patterns: list[Any] = []
|
|
70
|
+
|
|
71
|
+
for route in routes:
|
|
72
|
+
view = django_view(sdk, routes, route, adapter_options)
|
|
73
|
+
patterns.append(
|
|
74
|
+
re_path(
|
|
75
|
+
django_route_pattern(route.path, trailing_slash=adapter_options.trailing_slash),
|
|
76
|
+
view,
|
|
77
|
+
name=_route_name(route),
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return patterns
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def django_adapter(sdk: Any, options: Optional[DjangoAdapterOptions] = None) -> list[Any]:
|
|
85
|
+
return django_urlpatterns(sdk, options)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def async_django_urlpatterns(sdk: Any, options: Optional[DjangoAdapterOptions] = None) -> list[Any]:
|
|
89
|
+
from django.urls import re_path
|
|
90
|
+
|
|
91
|
+
adapter_options = options or DjangoAdapterOptions()
|
|
92
|
+
routes = sdk.get_routes()
|
|
93
|
+
patterns: list[Any] = []
|
|
94
|
+
|
|
95
|
+
for route in routes:
|
|
96
|
+
view = async_django_view(sdk, routes, route, adapter_options)
|
|
97
|
+
patterns.append(
|
|
98
|
+
re_path(
|
|
99
|
+
django_route_pattern(route.path, trailing_slash=adapter_options.trailing_slash),
|
|
100
|
+
view,
|
|
101
|
+
name=_route_name(route),
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return patterns
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def django_view(
|
|
109
|
+
sdk: Any,
|
|
110
|
+
routes: list[SDKRoute],
|
|
111
|
+
route: SDKRoute,
|
|
112
|
+
options: DjangoAdapterOptions,
|
|
113
|
+
) -> Callable[[Any], Any]:
|
|
114
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
115
|
+
|
|
116
|
+
body_limit = _body_limit(options)
|
|
117
|
+
|
|
118
|
+
@csrf_exempt
|
|
119
|
+
def view(request: Any, *args: Any, **kwargs: Any) -> Any:
|
|
120
|
+
try:
|
|
121
|
+
path = route_relative_path(_wire_path(request), route.path)
|
|
122
|
+
if path is None:
|
|
123
|
+
return _private_json_response(create_error_response("Not found", "NOT_FOUND"), status=404)
|
|
124
|
+
adapter_request = AdapterRequest(
|
|
125
|
+
method=request.method,
|
|
126
|
+
path=path,
|
|
127
|
+
query=request.META.get("QUERY_STRING", ""),
|
|
128
|
+
body=_raw_body(request, body_limit),
|
|
129
|
+
headers=_headers_from_request(request),
|
|
130
|
+
)
|
|
131
|
+
response = process_request(
|
|
132
|
+
sdk,
|
|
133
|
+
routes,
|
|
134
|
+
adapter_request,
|
|
135
|
+
ProcessRequestOptions(
|
|
136
|
+
timestamp_tolerance=options.timestamp_tolerance,
|
|
137
|
+
max_body_size=options.max_body_size
|
|
138
|
+
if options.max_body_size is not None
|
|
139
|
+
else ProcessRequestOptions.max_body_size,
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
status_code = 200 if response.get("success") else get_error_status_code(response.get("errorCode"))
|
|
143
|
+
return _private_json_response(response, status=status_code)
|
|
144
|
+
except _BodyReadError as error:
|
|
145
|
+
return _private_json_response({"success": False, "errorCode": error.code, "error": str(error)}, status=error.status)
|
|
146
|
+
except Exception:
|
|
147
|
+
return _private_json_response(
|
|
148
|
+
{
|
|
149
|
+
"success": False,
|
|
150
|
+
"errorCode": "INTERNAL_ERROR",
|
|
151
|
+
"error": "An unexpected error occurred",
|
|
152
|
+
},
|
|
153
|
+
status=500,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return view
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def async_django_view(
|
|
160
|
+
sdk: Any,
|
|
161
|
+
routes: list[SDKRoute],
|
|
162
|
+
route: SDKRoute,
|
|
163
|
+
options: DjangoAdapterOptions,
|
|
164
|
+
) -> Callable[[Any], Any]:
|
|
165
|
+
from asgiref.sync import sync_to_async
|
|
166
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
167
|
+
|
|
168
|
+
body_limit = _body_limit(options)
|
|
169
|
+
|
|
170
|
+
@csrf_exempt
|
|
171
|
+
async def view(request: Any, *args: Any, **kwargs: Any) -> Any:
|
|
172
|
+
try:
|
|
173
|
+
path = route_relative_path(_wire_path(request), route.path)
|
|
174
|
+
if path is None:
|
|
175
|
+
return _private_json_response(create_error_response("Not found", "NOT_FOUND"), status=404)
|
|
176
|
+
adapter_request = AdapterRequest(
|
|
177
|
+
method=request.method,
|
|
178
|
+
path=path,
|
|
179
|
+
query=request.META.get("QUERY_STRING", ""),
|
|
180
|
+
body=await sync_to_async(_raw_body, thread_sensitive=True)(request, body_limit),
|
|
181
|
+
headers=_headers_from_request(request),
|
|
182
|
+
)
|
|
183
|
+
response = await async_process_request(
|
|
184
|
+
sdk,
|
|
185
|
+
routes,
|
|
186
|
+
adapter_request,
|
|
187
|
+
ProcessRequestOptions(
|
|
188
|
+
timestamp_tolerance=options.timestamp_tolerance,
|
|
189
|
+
max_body_size=options.max_body_size
|
|
190
|
+
if options.max_body_size is not None
|
|
191
|
+
else ProcessRequestOptions.max_body_size,
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
status_code = 200 if response.get("success") else get_error_status_code(response.get("errorCode"))
|
|
195
|
+
return _private_json_response(response, status=status_code)
|
|
196
|
+
except _BodyReadError as error:
|
|
197
|
+
return _private_json_response({"success": False, "errorCode": error.code, "error": str(error)}, status=error.status)
|
|
198
|
+
except Exception:
|
|
199
|
+
return _private_json_response(
|
|
200
|
+
{
|
|
201
|
+
"success": False,
|
|
202
|
+
"errorCode": "INTERNAL_ERROR",
|
|
203
|
+
"error": "An unexpected error occurred",
|
|
204
|
+
},
|
|
205
|
+
status=500,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
return view
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def django_route_pattern(path: str, trailing_slash: bool = False) -> str:
|
|
212
|
+
parts = [part for part in path.split("/") if part]
|
|
213
|
+
regex_parts: list[str] = []
|
|
214
|
+
for part in parts:
|
|
215
|
+
if part.startswith(":"):
|
|
216
|
+
name = part[1:]
|
|
217
|
+
if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name):
|
|
218
|
+
raise ValueError(f"Invalid route parameter name: {name}")
|
|
219
|
+
regex_parts.append(f"(?P<{name}>[^/]+)")
|
|
220
|
+
else:
|
|
221
|
+
regex_parts.append(re.escape(part))
|
|
222
|
+
suffix = "/?$" if trailing_slash else "$"
|
|
223
|
+
return "^" + "/".join(regex_parts) + suffix
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def create_impersonation_guard(
|
|
227
|
+
sdk: Any,
|
|
228
|
+
get_impersonation_context: Callable[[Any], Any],
|
|
229
|
+
on_blocked: Optional[Callable[[Any, ImpersonationContext], None]] = None,
|
|
230
|
+
blocked_response: Optional[Mapping[str, Any]] = None,
|
|
231
|
+
show_warnings: bool = False,
|
|
232
|
+
enforce_liveness: bool = True,
|
|
233
|
+
liveness_cache_ttl_ms: int = 5_000,
|
|
234
|
+
on_liveness_unavailable: str = "deny",
|
|
235
|
+
is_impersonation_allowed: Optional[Callable[[Any, ImpersonationContext], bool]] = None,
|
|
236
|
+
bridge_path: Optional[str] = None,
|
|
237
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
238
|
+
"""Django middleware factory (also usable as a view decorator).
|
|
239
|
+
|
|
240
|
+
Works in sync and async stacks. ``get_impersonation_context`` returns ``None``
|
|
241
|
+
for ordinary traffic, or a dict / ``ImpersonationContext``; in an async stack
|
|
242
|
+
it may be ``async``. Any other value fails closed with a 500.
|
|
243
|
+
|
|
244
|
+
Policy patterns match the full client-visible path (``request.path``,
|
|
245
|
+
including ``SCRIPT_NAME``). Deny rules also match ``path_info`` and the path
|
|
246
|
+
with an ``i18n_patterns`` language prefix removed. ``bridge_path`` enables the
|
|
247
|
+
read-scope allowance for a mounted browser-session bridge (off by default).
|
|
248
|
+
"""
|
|
249
|
+
options = DjangoImpersonationGuardOptions(
|
|
250
|
+
sdk=sdk,
|
|
251
|
+
get_impersonation_context=get_impersonation_context,
|
|
252
|
+
on_blocked=on_blocked,
|
|
253
|
+
blocked_response=blocked_response or {},
|
|
254
|
+
show_warnings=show_warnings,
|
|
255
|
+
enforce_liveness=enforce_liveness,
|
|
256
|
+
liveness_cache_ttl_ms=liveness_cache_ttl_ms,
|
|
257
|
+
on_liveness_unavailable=on_liveness_unavailable,
|
|
258
|
+
is_impersonation_allowed=is_impersonation_allowed,
|
|
259
|
+
bridge_path=bridge_path,
|
|
260
|
+
)
|
|
261
|
+
liveness = (
|
|
262
|
+
SessionLivenessChecker(sdk, liveness_cache_ttl_ms, on_liveness_unavailable)
|
|
263
|
+
if enforce_liveness
|
|
264
|
+
else None
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def decide(request: Any, raw_context: Any) -> Optional[Any]:
|
|
268
|
+
"""Return a blocking JsonResponse, or None to continue."""
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
context = coerce_impersonation_context(raw_context)
|
|
272
|
+
except InvalidImpersonationContext:
|
|
273
|
+
return _private_json_response(
|
|
274
|
+
{
|
|
275
|
+
"success": False,
|
|
276
|
+
"error": "Invalid impersonation context",
|
|
277
|
+
"errorCode": "INVALID_IMPERSONATION_CONTEXT",
|
|
278
|
+
},
|
|
279
|
+
status=500,
|
|
280
|
+
)
|
|
281
|
+
# Non-impersonation traffic passes through without policy or liveness lookups.
|
|
282
|
+
if not context or not context.is_impersonation:
|
|
283
|
+
return None
|
|
284
|
+
session_live = None
|
|
285
|
+
liveness_unavailable = False
|
|
286
|
+
if liveness and isinstance(context.session_id, str) and context.session_id:
|
|
287
|
+
session_live, liveness_unavailable = liveness.check(context.session_id)
|
|
288
|
+
path, aliases = _guard_paths(request)
|
|
289
|
+
decision = evaluate_impersonation_guard(
|
|
290
|
+
request.method,
|
|
291
|
+
path,
|
|
292
|
+
context,
|
|
293
|
+
options.sdk.get_scope_config(),
|
|
294
|
+
session_live=session_live,
|
|
295
|
+
liveness_unavailable=liveness_unavailable,
|
|
296
|
+
is_impersonation_allowed=(
|
|
297
|
+
(lambda value: options.is_impersonation_allowed(request, value))
|
|
298
|
+
if options.is_impersonation_allowed
|
|
299
|
+
else None
|
|
300
|
+
),
|
|
301
|
+
bridge_path=options.bridge_path,
|
|
302
|
+
raw_path=_raw_request_path(request),
|
|
303
|
+
alias_paths=aliases,
|
|
304
|
+
methods=policy_methods(request.method, request.headers, request.META.get("QUERY_STRING", "")),
|
|
305
|
+
)
|
|
306
|
+
if decision.allowed:
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
# Notify only for actual blocks (scope/endpoint/liveness), not for
|
|
310
|
+
# temporary policy unavailability (503) — matching the Node guard.
|
|
311
|
+
notify_blocked = decision.status_code == 403 or (decision.body or {}).get(
|
|
312
|
+
"errorCode"
|
|
313
|
+
) == "IMPERSONATION_SESSION_ENDED"
|
|
314
|
+
if options.on_blocked and notify_blocked:
|
|
315
|
+
try:
|
|
316
|
+
options.on_blocked(request, context)
|
|
317
|
+
except Exception:
|
|
318
|
+
pass # Audit callbacks cannot change the decision.
|
|
319
|
+
if options.show_warnings:
|
|
320
|
+
warnings.warn(
|
|
321
|
+
f"[Devora] Blocked {request.method} {path} (session: {context.session_id})",
|
|
322
|
+
RuntimeWarning,
|
|
323
|
+
stacklevel=2,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
status_code, body = _guard_response(decision.status_code, decision.body, options.blocked_response)
|
|
327
|
+
return _private_json_response(body, status=status_code)
|
|
328
|
+
|
|
329
|
+
def guard_failed() -> Any:
|
|
330
|
+
|
|
331
|
+
return _private_json_response(
|
|
332
|
+
{"success": False, "error": "Impersonation guard failed", "errorCode": "INTERNAL_ERROR"},
|
|
333
|
+
status=500,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
def middleware(get_response: Callable[..., Any]) -> Callable[..., Any]:
|
|
337
|
+
if iscoroutinefunction(get_response):
|
|
338
|
+
|
|
339
|
+
async def async_wrapped(request: Any, *args: Any, **kwargs: Any) -> Any:
|
|
340
|
+
from asgiref.sync import sync_to_async
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
raw_context = options.get_impersonation_context(request)
|
|
344
|
+
if inspect.isawaitable(raw_context):
|
|
345
|
+
raw_context = await raw_context
|
|
346
|
+
blocked = await sync_to_async(decide, thread_sensitive=False)(request, raw_context)
|
|
347
|
+
except Exception:
|
|
348
|
+
return guard_failed()
|
|
349
|
+
return blocked if blocked is not None else await get_response(request, *args, **kwargs)
|
|
350
|
+
|
|
351
|
+
markcoroutinefunction(async_wrapped)
|
|
352
|
+
return async_wrapped
|
|
353
|
+
|
|
354
|
+
def wrapped(request: Any, *args: Any, **kwargs: Any) -> Any:
|
|
355
|
+
try:
|
|
356
|
+
blocked = decide(request, options.get_impersonation_context(request))
|
|
357
|
+
except Exception:
|
|
358
|
+
return guard_failed()
|
|
359
|
+
return blocked if blocked is not None else get_response(request, *args, **kwargs)
|
|
360
|
+
|
|
361
|
+
return wrapped
|
|
362
|
+
|
|
363
|
+
middleware.sync_capable = True # type: ignore[attr-defined]
|
|
364
|
+
middleware.async_capable = True # type: ignore[attr-defined]
|
|
365
|
+
return middleware
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _guard_paths(request: Any) -> tuple[str, list[str]]:
|
|
369
|
+
"""The full client-visible path, plus deny-only views of the same request."""
|
|
370
|
+
path = request.path or request.path_info or "/"
|
|
371
|
+
aliases: list[str] = []
|
|
372
|
+
path_info = request.path_info or "/"
|
|
373
|
+
if path_info != path:
|
|
374
|
+
aliases.append(path_info)
|
|
375
|
+
stripped = _strip_language_prefix(path_info)
|
|
376
|
+
if stripped is not None:
|
|
377
|
+
aliases.append(stripped)
|
|
378
|
+
script_name = path[: len(path) - len(path_info)] if path.endswith(path_info) else ""
|
|
379
|
+
if script_name:
|
|
380
|
+
aliases.append(script_name + stripped)
|
|
381
|
+
return path, aliases
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _strip_language_prefix(path_info: str) -> Optional[str]:
|
|
385
|
+
"""``path_info`` without an ``i18n_patterns`` language prefix, when it has one."""
|
|
386
|
+
try:
|
|
387
|
+
from django.conf import settings
|
|
388
|
+
from django.utils.translation import get_language_from_path
|
|
389
|
+
|
|
390
|
+
if not getattr(settings, "USE_I18N", False) or not get_language_from_path(path_info):
|
|
391
|
+
return None
|
|
392
|
+
except Exception:
|
|
393
|
+
return None
|
|
394
|
+
# get_language_from_path only matches a supported language as the first segment.
|
|
395
|
+
rest = path_info.split("/", 2)
|
|
396
|
+
return "/" + (rest[2] if len(rest) > 2 else "")
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _route_name(route: SDKRoute) -> str:
|
|
400
|
+
name = route.path.strip("/").replace("/", "_").replace(":", "")
|
|
401
|
+
return f"devora_{route.method.lower()}_{name or 'root'}"
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _raw_request_path(request: Any) -> Optional[str]:
|
|
405
|
+
"""The still-percent-encoded path, from the ASGI scope or a server-specific
|
|
406
|
+
WSGI environ key.
|
|
407
|
+
|
|
408
|
+
Devora signs path params with ``encodeURIComponent``; Django's decoded
|
|
409
|
+
``request.path``/``path_info`` can no longer match that signature for values
|
|
410
|
+
containing reserved characters (``@``, ``+``, space, ...).
|
|
411
|
+
"""
|
|
412
|
+
scope = getattr(request, "scope", None)
|
|
413
|
+
if isinstance(scope, Mapping):
|
|
414
|
+
raw = scope.get("raw_path")
|
|
415
|
+
if isinstance(raw, (bytes, bytearray)):
|
|
416
|
+
try:
|
|
417
|
+
return bytes(raw).decode("ascii")
|
|
418
|
+
except UnicodeDecodeError:
|
|
419
|
+
return bytes(raw).decode("latin-1")
|
|
420
|
+
environ = getattr(request, "environ", None)
|
|
421
|
+
if isinstance(environ, Mapping):
|
|
422
|
+
raw_uri = environ.get("RAW_URI") or environ.get("REQUEST_URI")
|
|
423
|
+
if raw_uri:
|
|
424
|
+
return raw_uri.split("?", 1)[0]
|
|
425
|
+
return None
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _wire_path(request: Any) -> str:
|
|
429
|
+
"""The request path as sent, still percent-encoded.
|
|
430
|
+
|
|
431
|
+
ASGI ``raw_path`` or WSGI ``RAW_URI``/``REQUEST_URI`` when the server provides
|
|
432
|
+
them. Otherwise (for example ``manage.py runserver``) the decoded
|
|
433
|
+
``PATH_INFO`` bytes are strict re-encoded, which reproduces exactly what
|
|
434
|
+
Devora's strict signer sent and fails closed for anything else.
|
|
435
|
+
"""
|
|
436
|
+
raw = _raw_request_path(request)
|
|
437
|
+
if raw is not None:
|
|
438
|
+
return raw
|
|
439
|
+
# request.path is Django's own consistently decoded full path, whatever the
|
|
440
|
+
# server's PATH_INFO encoding quirks.
|
|
441
|
+
return "/".join(strict_encode(part) if part else "" for part in (request.path or "/").split("/"))
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _headers_from_request(request: Any) -> Any:
|
|
445
|
+
# ASGI retains duplicate fields. Django's normalized header map may have
|
|
446
|
+
# joined or discarded them, so feed the original pairs to the verifier.
|
|
447
|
+
scope = getattr(request, "scope", None)
|
|
448
|
+
if isinstance(scope, dict) and "headers" in scope:
|
|
449
|
+
return list(scope["headers"])
|
|
450
|
+
return {key.lower(): value for key, value in request.headers.items()}
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
class _BodyReadError(ValueError):
|
|
454
|
+
def __init__(self, message: str, status: int = 413) -> None:
|
|
455
|
+
super().__init__(message)
|
|
456
|
+
self.status = status
|
|
457
|
+
self.code = "BODY_TOO_LARGE" if status == 413 else "INVALID_BODY"
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _body_limit(options: DjangoAdapterOptions) -> int:
|
|
461
|
+
limit = options.max_body_size if options.max_body_size is not None else ProcessRequestOptions.max_body_size
|
|
462
|
+
if not isinstance(limit, int) or isinstance(limit, bool) or limit <= 0:
|
|
463
|
+
raise ValueError("Invalid body size limit")
|
|
464
|
+
return limit
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _raw_body(request: Any, max_bytes: int = ProcessRequestOptions.max_body_size) -> bytes:
|
|
468
|
+
"""The exact request body bytes, bounded before allocation."""
|
|
469
|
+
declared = request.META.get("CONTENT_LENGTH", "")
|
|
470
|
+
if declared:
|
|
471
|
+
if not str(declared).isascii() or not str(declared).isdigit():
|
|
472
|
+
raise _BodyReadError("Invalid Content-Length", 400)
|
|
473
|
+
if int(declared) > max_bytes:
|
|
474
|
+
raise _BodyReadError("Request body too large")
|
|
475
|
+
# HttpRequest.read() bounds SDK allocation without eagerly filling .body.
|
|
476
|
+
# ASGI servers and any earlier middleware still need their own ingress cap.
|
|
477
|
+
if callable(getattr(request, "read", None)):
|
|
478
|
+
raw = bytearray()
|
|
479
|
+
while len(raw) <= max_bytes:
|
|
480
|
+
part = request.read(min(65536, max_bytes + 1 - len(raw)))
|
|
481
|
+
if not part:
|
|
482
|
+
break
|
|
483
|
+
raw.extend(part)
|
|
484
|
+
if len(raw) > max_bytes:
|
|
485
|
+
raise _BodyReadError("Request body too large")
|
|
486
|
+
return bytes(raw)
|
|
487
|
+
raw = request.body
|
|
488
|
+
if len(raw) > max_bytes:
|
|
489
|
+
raise _BodyReadError("Request body too large")
|
|
490
|
+
return bytes(raw)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _body_from_request(request: Any, max_bytes: int) -> Any:
|
|
494
|
+
"""Parsed JSON body for the (unsigned) browser-session bridge."""
|
|
495
|
+
raw = _raw_body(request, max_bytes)
|
|
496
|
+
if not raw:
|
|
497
|
+
return None
|
|
498
|
+
return json.loads(raw.decode("utf-8"))
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _guard_response(
|
|
502
|
+
status_code: int,
|
|
503
|
+
body: Optional[dict[str, object]],
|
|
504
|
+
blocked_response: Mapping[str, Any],
|
|
505
|
+
) -> tuple[int, dict[str, object]]:
|
|
506
|
+
response_body = dict(body or {})
|
|
507
|
+
if status_code == 403:
|
|
508
|
+
status_code = int(blocked_response.get("status", status_code))
|
|
509
|
+
if response_body.get("errorCode") == "IMPERSONATION_SCOPE_VIOLATION":
|
|
510
|
+
response_body["error"] = blocked_response.get(
|
|
511
|
+
"message", response_body.get("error", "This action is blocked during impersonation")
|
|
512
|
+
)
|
|
513
|
+
response_body["errorCode"] = blocked_response.get(
|
|
514
|
+
"errorCode", response_body.get("errorCode", "IMPERSONATION_SCOPE_VIOLATION")
|
|
515
|
+
)
|
|
516
|
+
return status_code, response_body
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def browser_session_view(
|
|
520
|
+
sdk: Any,
|
|
521
|
+
get_impersonation_context: Callable[[Any], Any],
|
|
522
|
+
allowed_origins: Optional[list[str]] = None,
|
|
523
|
+
) -> Callable[[Any], Any]:
|
|
524
|
+
"""Django view for ``POST /api/devora/browser-session`` (wrap with your auth decorator)."""
|
|
525
|
+
from devora_sdk import resolve_browser_session
|
|
526
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
527
|
+
|
|
528
|
+
# Exempt from Django's CSRF token check (the browser SDK cannot send one):
|
|
529
|
+
# the bridge authorizes by the session cookie AND an Origin that must be in
|
|
530
|
+
# ``allowed_origins``; a missing or foreign Origin never gets a resume code.
|
|
531
|
+
@csrf_exempt
|
|
532
|
+
def view(request: Any) -> Any:
|
|
533
|
+
|
|
534
|
+
if request.method != "POST":
|
|
535
|
+
return _private_json_response({"error": "Method not allowed"}, status=405)
|
|
536
|
+
try:
|
|
537
|
+
body = _body_from_request(request, 4096)
|
|
538
|
+
except _BodyReadError as error:
|
|
539
|
+
return _private_json_response({"success": False, "errorCode": error.code, "error": str(error)}, status=error.status)
|
|
540
|
+
except Exception:
|
|
541
|
+
body = {}
|
|
542
|
+
try:
|
|
543
|
+
context = coerce_impersonation_context(get_impersonation_context(request))
|
|
544
|
+
except InvalidImpersonationContext:
|
|
545
|
+
return _private_json_response(
|
|
546
|
+
{"status": "blocked", "reason": "session_invalid"}, status=500
|
|
547
|
+
)
|
|
548
|
+
status, payload = resolve_browser_session(
|
|
549
|
+
sdk,
|
|
550
|
+
context,
|
|
551
|
+
body.get("tabRef") if isinstance(body, dict) else None,
|
|
552
|
+
request.headers.get("Origin"),
|
|
553
|
+
allowed_origins,
|
|
554
|
+
)
|
|
555
|
+
response = _private_json_response(payload, status=status)
|
|
556
|
+
response["Cache-Control"] = "private, no-store"
|
|
557
|
+
return response
|
|
558
|
+
|
|
559
|
+
return view
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|