devora-fastapi 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: devora-fastapi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: FastAPI 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,fastapi,impersonation,sdk,security
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Framework :: FastAPI
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: <4.0,>=3.10
|
|
23
|
+
Requires-Dist: anyio>=4.14.2
|
|
24
|
+
Requires-Dist: devora-python<0.2.0,>=0.1.0
|
|
25
|
+
Requires-Dist: fastapi<1.0.0,>=0.133.0
|
|
26
|
+
Requires-Dist: idna>=3.15
|
|
27
|
+
Requires-Dist: starlette>=1.3.1
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# devora-fastapi
|
|
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
|
+
FastAPI adapter for the Devora Python backend SDK.
|
|
39
|
+
|
|
40
|
+
## Requirements
|
|
41
|
+
|
|
42
|
+
- Python `>=3.10,<4.0`
|
|
43
|
+
- FastAPI `>=0.100.0,<1.0.0`
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install devora-python devora-fastapi
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Quick Start
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from fastapi import FastAPI
|
|
55
|
+
from devora_sdk import DEVORA_ENDPOINTS, devora_sdk
|
|
56
|
+
from devora_sdk_fastapi import fastapi_router
|
|
57
|
+
|
|
58
|
+
sdk = devora_sdk(
|
|
59
|
+
api_key="pk_server_live_...",
|
|
60
|
+
secret_key="sk_server_live_...",
|
|
61
|
+
org_id="org_...",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@sdk.register(DEVORA_ENDPOINTS.USER_SEARCH)
|
|
66
|
+
async def search_users(req):
|
|
67
|
+
return {"users": await search_customer_users(req.query.get("term", ""))}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
app = FastAPI()
|
|
71
|
+
app.include_router(fastapi_router(sdk), prefix="/devora")
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
For protected application routes, install the guard middleware and extract a
|
|
75
|
+
trusted impersonation context from authenticated request state.
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from devora_sdk import ImpersonationContext
|
|
79
|
+
from devora_sdk_fastapi import DevoraImpersonationGuardMiddleware
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_impersonation_context(request) -> ImpersonationContext | None:
|
|
83
|
+
devora = getattr(request.state, "devora", None)
|
|
84
|
+
if not devora:
|
|
85
|
+
return None
|
|
86
|
+
return ImpersonationContext(
|
|
87
|
+
is_impersonation=devora["isImpersonation"],
|
|
88
|
+
scope=devora["scope"],
|
|
89
|
+
session_id=devora["sessionId"],
|
|
90
|
+
expires_at=devora["expiresAt"],
|
|
91
|
+
actor=devora["actor"],
|
|
92
|
+
subject=devora["subject"],
|
|
93
|
+
auth_method=devora["authMethod"],
|
|
94
|
+
authorization_source=devora["authorizationSource"],
|
|
95
|
+
recording_allowed=devora["recordingAllowed"],
|
|
96
|
+
impersonator=devora.get("impersonator"),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
app.add_middleware(
|
|
101
|
+
DevoraImpersonationGuardMiddleware,
|
|
102
|
+
sdk=sdk,
|
|
103
|
+
get_impersonation_context=get_impersonation_context,
|
|
104
|
+
)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Register your own authentication middleware **after** this call — Starlette
|
|
108
|
+
runs middleware in the reverse of its registration order, so it must be the
|
|
109
|
+
outer layer that runs first for `request.state` to carry verified claims by
|
|
110
|
+
the time the guard reads them.
|
|
111
|
+
|
|
112
|
+
Resolve method overrides and route rewrites **before** the guard as well
|
|
113
|
+
(register that middleware after this call too, so it runs first). The guard
|
|
114
|
+
also judges every `X-HTTP-Method-Override`, `X-HTTP-Method` and
|
|
115
|
+
`X-Method-Override` value and any `_method` query parameter, but it cannot see a
|
|
116
|
+
`_method` field inside a request body, and it judges the path it receives.
|
|
117
|
+
|
|
118
|
+
`expires_at` must be a Unix timestamp in milliseconds. If your JWT stores Unix
|
|
119
|
+
seconds, multiply by `1000` when building the impersonation context. `actor`,
|
|
120
|
+
`subject`, `auth_method`, `authorization_source`, and `recording_allowed` are
|
|
121
|
+
all required — the guard rejects the context as invalid without them, even
|
|
122
|
+
though the dataclass marks them optional for construction convenience.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
devora_sdk_fastapi/__init__.py,sha256=9IpGyO7wOWZA8LMn4HtLrPQERyWYH8x6Y_w8ChibCa8,336
|
|
2
|
+
devora_sdk_fastapi/adapter.py,sha256=_cdjN_w_xZStRAeqCZL0W_SfOrAsCqBGYpcbitmaTLQ,15963
|
|
3
|
+
devora_sdk_fastapi/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
4
|
+
devora_fastapi-0.1.0.dist-info/METADATA,sha256=MpK8yOtaGYDvyUPUzTfXYhKVXlGhSZOosT58l0yxTKU,4280
|
|
5
|
+
devora_fastapi-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
6
|
+
devora_fastapi-0.1.0.dist-info/licenses/LICENSE,sha256=ZrqlZJezuSZhSOx4R8S5JOwwQ-sLmssj2XW7-3coRjA,1063
|
|
7
|
+
devora_fastapi-0.1.0.dist-info/RECORD,,
|
|
@@ -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,17 @@
|
|
|
1
|
+
from .adapter import (
|
|
2
|
+
browser_session_router,
|
|
3
|
+
DevoraImpersonationGuardMiddleware,
|
|
4
|
+
FastAPIAdapterOptions,
|
|
5
|
+
fastapi_adapter,
|
|
6
|
+
fastapi_route_path,
|
|
7
|
+
fastapi_router,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"browser_session_router",
|
|
12
|
+
"DevoraImpersonationGuardMiddleware",
|
|
13
|
+
"FastAPIAdapterOptions",
|
|
14
|
+
"fastapi_adapter",
|
|
15
|
+
"fastapi_route_path",
|
|
16
|
+
"fastapi_router",
|
|
17
|
+
]
|
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import warnings
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, Awaitable, Callable, Mapping, Optional, Union
|
|
10
|
+
|
|
11
|
+
from devora_sdk.guard import _validate_context
|
|
12
|
+
from devora_sdk import (
|
|
13
|
+
AdapterRequest,
|
|
14
|
+
ImpersonationContext,
|
|
15
|
+
InvalidImpersonationContext,
|
|
16
|
+
ProcessRequestOptions,
|
|
17
|
+
route_relative_path,
|
|
18
|
+
strict_encode,
|
|
19
|
+
SessionLivenessChecker,
|
|
20
|
+
async_process_request,
|
|
21
|
+
coerce_impersonation_context,
|
|
22
|
+
evaluate_impersonation_guard,
|
|
23
|
+
policy_methods,
|
|
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
|
+
from starlette.requests import Request as _StarletteRequest
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _private_json_response(*args: Any, **kwargs: Any) -> Any:
|
|
32
|
+
from fastapi.responses import JSONResponse
|
|
33
|
+
|
|
34
|
+
response = JSONResponse(*args, **kwargs)
|
|
35
|
+
response.headers["Cache-Control"] = "private, no-store"
|
|
36
|
+
return response
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class FastAPIAdapterOptions:
|
|
41
|
+
timestamp_tolerance: Optional[int] = None
|
|
42
|
+
max_body_size: Optional[int] = None
|
|
43
|
+
|
|
44
|
+
def __post_init__(self) -> None:
|
|
45
|
+
validate_timestamp_tolerance(self.timestamp_tolerance)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
ContextGetter = Callable[[Any], Union[Optional[ImpersonationContext], Awaitable[Optional[ImpersonationContext]]]]
|
|
49
|
+
BlockedCallback = Callable[[Any, ImpersonationContext], Union[None, Awaitable[None]]]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def fastapi_router(sdk: Any, options: Optional[FastAPIAdapterOptions] = None) -> Any:
|
|
53
|
+
from fastapi import APIRouter
|
|
54
|
+
|
|
55
|
+
adapter_options = options or FastAPIAdapterOptions()
|
|
56
|
+
routes = sdk.get_routes()
|
|
57
|
+
router = APIRouter()
|
|
58
|
+
|
|
59
|
+
for route in routes:
|
|
60
|
+
router.add_api_route(
|
|
61
|
+
fastapi_route_path(route.path),
|
|
62
|
+
_endpoint_for_route(sdk, routes, route, adapter_options),
|
|
63
|
+
methods=[route.method],
|
|
64
|
+
include_in_schema=False,
|
|
65
|
+
name=_route_name(route),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return router
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def fastapi_adapter(sdk: Any, options: Optional[FastAPIAdapterOptions] = None) -> Any:
|
|
72
|
+
return fastapi_router(sdk, options)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def fastapi_route_path(path: str) -> str:
|
|
76
|
+
return re.sub(r":([A-Za-z_][A-Za-z0-9_]*)", r"{\1}", path)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _endpoint_for_route(
|
|
80
|
+
sdk: Any,
|
|
81
|
+
routes: list[SDKRoute],
|
|
82
|
+
route: SDKRoute,
|
|
83
|
+
options: FastAPIAdapterOptions,
|
|
84
|
+
) -> Callable[[Any], Awaitable[Any]]:
|
|
85
|
+
from fastapi import Request
|
|
86
|
+
from fastapi.responses import JSONResponse
|
|
87
|
+
|
|
88
|
+
async def endpoint(request: Any) -> Any:
|
|
89
|
+
try:
|
|
90
|
+
max_body_size = (
|
|
91
|
+
options.max_body_size
|
|
92
|
+
if options.max_body_size is not None
|
|
93
|
+
else ProcessRequestOptions.max_body_size
|
|
94
|
+
)
|
|
95
|
+
content_length = request.headers.get("content-length")
|
|
96
|
+
if content_length is not None:
|
|
97
|
+
try:
|
|
98
|
+
declared_size = int(content_length)
|
|
99
|
+
except ValueError:
|
|
100
|
+
declared_size = None
|
|
101
|
+
if declared_size is not None and declared_size > max_body_size:
|
|
102
|
+
return _private_json_response(
|
|
103
|
+
create_error_response("Request body too large", "BODY_TOO_LARGE"),
|
|
104
|
+
status_code=get_error_status_code("BODY_TOO_LARGE"),
|
|
105
|
+
)
|
|
106
|
+
path = route_relative_path(_wire_path(request), route.path)
|
|
107
|
+
if path is None:
|
|
108
|
+
return _private_json_response(create_error_response("Not found", "NOT_FOUND"), status_code=404)
|
|
109
|
+
raw_query = request.scope.get("query_string", b"")
|
|
110
|
+
adapter_request = AdapterRequest(
|
|
111
|
+
method=request.method,
|
|
112
|
+
path=path,
|
|
113
|
+
query=bytes(raw_query).decode("latin-1"),
|
|
114
|
+
body=await _read_bounded_body(request, max_body_size),
|
|
115
|
+
# Raw pairs keep duplicate headers visible to the verifier.
|
|
116
|
+
headers=list(request.headers.raw),
|
|
117
|
+
)
|
|
118
|
+
response = await async_process_request(
|
|
119
|
+
sdk,
|
|
120
|
+
routes,
|
|
121
|
+
adapter_request,
|
|
122
|
+
ProcessRequestOptions(
|
|
123
|
+
timestamp_tolerance=options.timestamp_tolerance,
|
|
124
|
+
max_body_size=max_body_size,
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
status_code = 200 if response.get("success") else get_error_status_code(response.get("errorCode"))
|
|
128
|
+
return _private_json_response(response, status_code=status_code)
|
|
129
|
+
except _BodyReadError as error:
|
|
130
|
+
return _private_json_response(create_error_response(str(error), error.code), status_code=error.status)
|
|
131
|
+
except Exception:
|
|
132
|
+
return _private_json_response(
|
|
133
|
+
{
|
|
134
|
+
"success": False,
|
|
135
|
+
"errorCode": "INTERNAL_ERROR",
|
|
136
|
+
"error": "An unexpected error occurred",
|
|
137
|
+
},
|
|
138
|
+
status_code=500,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
endpoint.__annotations__ = {"request": Request, "return": JSONResponse}
|
|
142
|
+
return endpoint
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class _GuardOptions:
|
|
147
|
+
sdk: Any
|
|
148
|
+
get_impersonation_context: ContextGetter
|
|
149
|
+
on_blocked: Optional[BlockedCallback] = None
|
|
150
|
+
blocked_response: Mapping[str, Any] = field(default_factory=dict)
|
|
151
|
+
show_warnings: bool = False
|
|
152
|
+
enforce_liveness: bool = True
|
|
153
|
+
liveness_cache_ttl_ms: int = 5_000
|
|
154
|
+
on_liveness_unavailable: str = "deny"
|
|
155
|
+
is_impersonation_allowed: Optional[Callable[[Any, ImpersonationContext], Union[bool, Awaitable[bool]]]] = None
|
|
156
|
+
bridge_path: Optional[str] = None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class DevoraImpersonationGuardMiddleware:
|
|
160
|
+
"""Starlette/FastAPI middleware enforcing the Devora impersonation policy.
|
|
161
|
+
|
|
162
|
+
``get_impersonation_context`` may be sync or async and returns ``None`` for
|
|
163
|
+
ordinary traffic, or a dict / ``ImpersonationContext``. Any other value fails
|
|
164
|
+
closed with a 500. Policy patterns match the full client-visible path
|
|
165
|
+
(``scope["path"]``, including ``root_path``); deny rules also match the path
|
|
166
|
+
relative to ``root_path``. ``bridge_path`` enables the read-scope allowance
|
|
167
|
+
for a mounted browser-session bridge (off by default).
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
def __init__(
|
|
171
|
+
self,
|
|
172
|
+
app: Any,
|
|
173
|
+
sdk: Any,
|
|
174
|
+
get_impersonation_context: ContextGetter,
|
|
175
|
+
on_blocked: Optional[BlockedCallback] = None,
|
|
176
|
+
blocked_response: Optional[Mapping[str, Any]] = None,
|
|
177
|
+
show_warnings: bool = False,
|
|
178
|
+
enforce_liveness: bool = True,
|
|
179
|
+
liveness_cache_ttl_ms: int = 5_000,
|
|
180
|
+
on_liveness_unavailable: str = "deny",
|
|
181
|
+
is_impersonation_allowed: Optional[
|
|
182
|
+
Callable[[Any, ImpersonationContext], Union[bool, Awaitable[bool]]]
|
|
183
|
+
] = None,
|
|
184
|
+
bridge_path: Optional[str] = None,
|
|
185
|
+
) -> None:
|
|
186
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
187
|
+
|
|
188
|
+
self._middleware = BaseHTTPMiddleware(
|
|
189
|
+
app,
|
|
190
|
+
dispatch=self.dispatch,
|
|
191
|
+
)
|
|
192
|
+
self.options = _GuardOptions(
|
|
193
|
+
sdk=sdk,
|
|
194
|
+
get_impersonation_context=get_impersonation_context,
|
|
195
|
+
on_blocked=on_blocked,
|
|
196
|
+
blocked_response=blocked_response or {},
|
|
197
|
+
show_warnings=show_warnings,
|
|
198
|
+
enforce_liveness=enforce_liveness,
|
|
199
|
+
liveness_cache_ttl_ms=liveness_cache_ttl_ms,
|
|
200
|
+
on_liveness_unavailable=on_liveness_unavailable,
|
|
201
|
+
is_impersonation_allowed=is_impersonation_allowed,
|
|
202
|
+
bridge_path=bridge_path,
|
|
203
|
+
)
|
|
204
|
+
self._liveness = (
|
|
205
|
+
SessionLivenessChecker(sdk, liveness_cache_ttl_ms, on_liveness_unavailable)
|
|
206
|
+
if enforce_liveness
|
|
207
|
+
else None
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
|
|
211
|
+
await self._middleware(scope, receive, send)
|
|
212
|
+
|
|
213
|
+
async def dispatch(self, request: Any, call_next: Callable[[Any], Awaitable[Any]]) -> Any:
|
|
214
|
+
from fastapi.responses import JSONResponse
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
blocked = await self._decide(request)
|
|
218
|
+
except Exception:
|
|
219
|
+
# Extractor, policy and hook failures fail closed.
|
|
220
|
+
blocked = _private_json_response(
|
|
221
|
+
create_error_response("Impersonation guard failed", "INTERNAL_ERROR"), status_code=500
|
|
222
|
+
)
|
|
223
|
+
return blocked if blocked is not None else await call_next(request)
|
|
224
|
+
|
|
225
|
+
async def _decide(self, request: Any) -> Optional[Any]:
|
|
226
|
+
"""Return a blocking JSONResponse, or None to continue."""
|
|
227
|
+
from fastapi.responses import JSONResponse
|
|
228
|
+
from starlette.concurrency import run_in_threadpool
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
context = coerce_impersonation_context(
|
|
232
|
+
await _maybe_await(self.options.get_impersonation_context(request))
|
|
233
|
+
)
|
|
234
|
+
except InvalidImpersonationContext:
|
|
235
|
+
return _private_json_response(
|
|
236
|
+
create_error_response("Invalid impersonation context", "INVALID_IMPERSONATION_CONTEXT"),
|
|
237
|
+
status_code=500,
|
|
238
|
+
)
|
|
239
|
+
# Non-impersonation traffic passes through without policy or liveness lookups.
|
|
240
|
+
if not context or not context.is_impersonation:
|
|
241
|
+
return None
|
|
242
|
+
session_live: Optional[bool] = None
|
|
243
|
+
liveness_unavailable = False
|
|
244
|
+
if self._liveness and isinstance(context.session_id, str) and context.session_id:
|
|
245
|
+
session_live, liveness_unavailable = await run_in_threadpool(
|
|
246
|
+
self._liveness.check, context.session_id
|
|
247
|
+
)
|
|
248
|
+
policy = await run_in_threadpool(self.options.sdk.get_scope_config)
|
|
249
|
+
# The customer hook is semantic authorization for otherwise-valid, live
|
|
250
|
+
# sessions. Never invoke it for an invalid/expired context, an ended or
|
|
251
|
+
# unverifiable session, or while policy is unavailable.
|
|
252
|
+
semantic_allowed = True
|
|
253
|
+
if (
|
|
254
|
+
self.options.is_impersonation_allowed
|
|
255
|
+
and _validate_context(context)[0]
|
|
256
|
+
and session_live is not False
|
|
257
|
+
and not liveness_unavailable
|
|
258
|
+
and policy is not None
|
|
259
|
+
):
|
|
260
|
+
semantic_allowed = await _maybe_await(self.options.is_impersonation_allowed(request, context))
|
|
261
|
+
path, aliases = _guard_paths(request)
|
|
262
|
+
decision = evaluate_impersonation_guard(
|
|
263
|
+
request.method,
|
|
264
|
+
path,
|
|
265
|
+
context,
|
|
266
|
+
policy,
|
|
267
|
+
session_live=session_live,
|
|
268
|
+
liveness_unavailable=liveness_unavailable,
|
|
269
|
+
is_impersonation_allowed=(lambda _context: semantic_allowed),
|
|
270
|
+
bridge_path=self.options.bridge_path,
|
|
271
|
+
raw_path=_raw_request_path(request),
|
|
272
|
+
alias_paths=aliases,
|
|
273
|
+
methods=policy_methods(request.method, request.headers, request.url.query),
|
|
274
|
+
)
|
|
275
|
+
if decision.allowed:
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
# Notify only for actual blocks (scope/endpoint/liveness), not for
|
|
279
|
+
# temporary policy unavailability (503) — matching the Node guard.
|
|
280
|
+
notify_blocked = decision.status_code == 403 or (decision.body or {}).get(
|
|
281
|
+
"errorCode"
|
|
282
|
+
) == "IMPERSONATION_SESSION_ENDED"
|
|
283
|
+
if self.options.on_blocked and notify_blocked:
|
|
284
|
+
try:
|
|
285
|
+
await _maybe_await(self.options.on_blocked(request, context))
|
|
286
|
+
except Exception:
|
|
287
|
+
pass # Audit callbacks cannot change the decision.
|
|
288
|
+
if self.options.show_warnings:
|
|
289
|
+
warnings.warn(
|
|
290
|
+
f"[Devora] Blocked {request.method} {path} (session: {context.session_id})",
|
|
291
|
+
RuntimeWarning,
|
|
292
|
+
stacklevel=2,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
status_code, body = _guard_response(
|
|
296
|
+
decision.status_code,
|
|
297
|
+
decision.body,
|
|
298
|
+
self.options.blocked_response,
|
|
299
|
+
)
|
|
300
|
+
return _private_json_response(body, status_code=status_code)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _route_name(route: SDKRoute) -> str:
|
|
304
|
+
name = route.path.strip("/").replace("/", "_").replace(":", "")
|
|
305
|
+
return f"devora_{route.method.lower()}_{name or 'root'}"
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _raw_request_path(request: Any) -> Optional[str]:
|
|
309
|
+
"""The still-percent-encoded path, straight from the ASGI scope.
|
|
310
|
+
|
|
311
|
+
Devora signs path params with ``encodeURIComponent``; Starlette's decoded
|
|
312
|
+
``request.url.path`` can no longer match that signature for values containing
|
|
313
|
+
reserved characters (``@``, ``+``, space, ...).
|
|
314
|
+
"""
|
|
315
|
+
scope = getattr(request, "scope", None)
|
|
316
|
+
if isinstance(scope, Mapping):
|
|
317
|
+
raw = scope.get("raw_path")
|
|
318
|
+
if isinstance(raw, (bytes, bytearray)):
|
|
319
|
+
try:
|
|
320
|
+
return bytes(raw).decode("ascii")
|
|
321
|
+
except UnicodeDecodeError:
|
|
322
|
+
return bytes(raw).decode("latin-1")
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _routed_request_path(request: Any) -> str:
|
|
327
|
+
"""The decoded path Starlette's router actually dispatches on.
|
|
328
|
+
|
|
329
|
+
``request.url.path`` is re-parsed as a WHATWG URL, which silently strips tab,
|
|
330
|
+
line feed and carriage return; the router matches ``scope["path"]`` where
|
|
331
|
+
those bytes survive. The guard must judge the path the router judges, or a
|
|
332
|
+
``%09`` inside a whitelisted read path lets a write reach a parameterised
|
|
333
|
+
handler.
|
|
334
|
+
"""
|
|
335
|
+
scope = getattr(request, "scope", None)
|
|
336
|
+
if isinstance(scope, Mapping):
|
|
337
|
+
path = scope.get("path")
|
|
338
|
+
if isinstance(path, str):
|
|
339
|
+
return path
|
|
340
|
+
return str(request.url.path)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _guard_paths(request: Any) -> tuple[str, list[str]]:
|
|
344
|
+
"""The full client-visible routed path, plus the root_path-relative view."""
|
|
345
|
+
path = _routed_request_path(request)
|
|
346
|
+
aliases: list[str] = []
|
|
347
|
+
scope = getattr(request, "scope", None)
|
|
348
|
+
root_path = scope.get("root_path", "") if isinstance(scope, Mapping) else ""
|
|
349
|
+
if isinstance(root_path, str) and root_path and path.startswith(root_path):
|
|
350
|
+
aliases.append(path[len(root_path) :] or "/")
|
|
351
|
+
return path, aliases
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _wire_path(request: Any) -> str:
|
|
355
|
+
"""ASGI ``raw_path`` as sent; otherwise the strict re-encoded decoded path,
|
|
356
|
+
which reproduces what Devora's strict signer sent and fails closed otherwise."""
|
|
357
|
+
raw = _raw_request_path(request)
|
|
358
|
+
if raw is not None:
|
|
359
|
+
return raw
|
|
360
|
+
return "/".join(strict_encode(part) if part else "" for part in _routed_request_path(request).split("/"))
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
class _BodyReadError(ValueError):
|
|
364
|
+
def __init__(self, code: str, status: int, message: str):
|
|
365
|
+
super().__init__(message)
|
|
366
|
+
self.code = code
|
|
367
|
+
self.status = status
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
async def _read_bounded_body(request: Any, max_bytes: int, timeout: float = 5.0) -> bytes:
|
|
371
|
+
if not isinstance(max_bytes, int) or max_bytes < 0 or timeout <= 0:
|
|
372
|
+
raise ValueError("Invalid body budget")
|
|
373
|
+
declared = request.headers.get("content-length")
|
|
374
|
+
if declared is not None:
|
|
375
|
+
if not declared.isascii() or not declared.isdigit():
|
|
376
|
+
raise _BodyReadError("INVALID_BODY", 400, "Invalid Content-Length")
|
|
377
|
+
if int(declared) > max_bytes:
|
|
378
|
+
raise _BodyReadError("BODY_TOO_LARGE", 413, "Request body too large")
|
|
379
|
+
|
|
380
|
+
async def read() -> bytes:
|
|
381
|
+
body = bytearray()
|
|
382
|
+
async for chunk in request.stream():
|
|
383
|
+
if len(body) + len(chunk) > max_bytes:
|
|
384
|
+
raise _BodyReadError("BODY_TOO_LARGE", 413, "Request body too large")
|
|
385
|
+
body.extend(chunk)
|
|
386
|
+
return bytes(body)
|
|
387
|
+
|
|
388
|
+
try:
|
|
389
|
+
return await asyncio.wait_for(read(), timeout=timeout)
|
|
390
|
+
except asyncio.TimeoutError as error:
|
|
391
|
+
raise _BodyReadError("BODY_TIMEOUT", 408, "Request body timed out") from error
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
async def _maybe_await(value: Any) -> Any:
|
|
395
|
+
if inspect.isawaitable(value):
|
|
396
|
+
return await value
|
|
397
|
+
return value
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _guard_response(
|
|
401
|
+
status_code: int,
|
|
402
|
+
body: Optional[dict[str, object]],
|
|
403
|
+
blocked_response: Mapping[str, Any],
|
|
404
|
+
) -> tuple[int, dict[str, object]]:
|
|
405
|
+
response_body = dict(body or {})
|
|
406
|
+
if status_code == 403:
|
|
407
|
+
status_code = int(blocked_response.get("status", status_code))
|
|
408
|
+
if response_body.get("errorCode") == "IMPERSONATION_SCOPE_VIOLATION":
|
|
409
|
+
response_body["error"] = blocked_response.get(
|
|
410
|
+
"message", response_body.get("error", "This action is blocked during impersonation")
|
|
411
|
+
)
|
|
412
|
+
response_body["errorCode"] = blocked_response.get(
|
|
413
|
+
"errorCode", response_body.get("errorCode", "IMPERSONATION_SCOPE_VIOLATION")
|
|
414
|
+
)
|
|
415
|
+
return status_code, response_body
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def browser_session_router(
|
|
419
|
+
sdk: Any,
|
|
420
|
+
get_impersonation_context: Callable[[Any], Any],
|
|
421
|
+
path: str = "/api/devora/browser-session",
|
|
422
|
+
allowed_origins: Optional[list[str]] = None,
|
|
423
|
+
) -> Any:
|
|
424
|
+
"""Router exposing ``POST <path>`` for the browser-session bridge.
|
|
425
|
+
|
|
426
|
+
Include it on the app that already authenticates requests; the context
|
|
427
|
+
callable is the same one given to the guard middleware.
|
|
428
|
+
"""
|
|
429
|
+
from fastapi import APIRouter
|
|
430
|
+
from fastapi.responses import JSONResponse
|
|
431
|
+
from starlette.concurrency import run_in_threadpool
|
|
432
|
+
|
|
433
|
+
from devora_sdk import resolve_browser_session
|
|
434
|
+
|
|
435
|
+
router = APIRouter()
|
|
436
|
+
|
|
437
|
+
# NOTE: the parameter annotation must resolve from module globals because
|
|
438
|
+
# this module uses `from __future__ import annotations`; a function-local
|
|
439
|
+
# `Request` import would make FastAPI treat it as a query parameter.
|
|
440
|
+
@router.post(path)
|
|
441
|
+
async def browser_session(request: _StarletteRequest) -> JSONResponse:
|
|
442
|
+
try:
|
|
443
|
+
body = json.loads(await _read_bounded_body(request, 4096))
|
|
444
|
+
except _BodyReadError as error:
|
|
445
|
+
return _private_json_response(create_error_response(str(error), error.code), status_code=error.status)
|
|
446
|
+
except (ValueError, UnicodeDecodeError):
|
|
447
|
+
return _private_json_response({"success": False, "error": "Invalid JSON body"}, status_code=400)
|
|
448
|
+
try:
|
|
449
|
+
context = coerce_impersonation_context(await _maybe_await(get_impersonation_context(request)))
|
|
450
|
+
except InvalidImpersonationContext:
|
|
451
|
+
return _private_json_response({"status": "blocked", "reason": "session_invalid"}, status_code=500)
|
|
452
|
+
status, payload = await run_in_threadpool(
|
|
453
|
+
resolve_browser_session,
|
|
454
|
+
sdk,
|
|
455
|
+
context,
|
|
456
|
+
(body or {}).get("tabRef") if isinstance(body, dict) else None,
|
|
457
|
+
request.headers.get("origin"),
|
|
458
|
+
allowed_origins,
|
|
459
|
+
)
|
|
460
|
+
return _private_json_response(payload, status_code=status, headers={"Cache-Control": "private, no-store"})
|
|
461
|
+
|
|
462
|
+
return router
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|