recce 1.34.0__py3-none-any.whl → 1.34.1__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.
- recce/VERSION +1 -1
- recce/apis/check_events_api.py +15 -2
- recce/apis/check_func.py +13 -0
- recce/data/404/index.html +2 -2
- recce/data/404.html +2 -2
- recce/data/__next.@lineage.!KHNsb3Qp.__PAGE__.txt +1 -1
- recce/data/__next.@lineage.!KHNsb3Qp.txt +1 -1
- recce/data/__next.__PAGE__.txt +1 -1
- recce/data/__next._full.txt +2 -2
- recce/data/__next._head.txt +1 -1
- recce/data/__next._index.txt +2 -2
- recce/data/__next._tree.txt +2 -2
- recce/data/_next/static/chunks/{f6d41978531cd9db.css → b83721c72340cd2d.css} +1 -1
- recce/data/_not-found/__next._full.txt +2 -2
- recce/data/_not-found/__next._head.txt +1 -1
- recce/data/_not-found/__next._index.txt +2 -2
- recce/data/_not-found/__next._not-found.__PAGE__.txt +1 -1
- recce/data/_not-found/__next._not-found.txt +1 -1
- recce/data/_not-found/__next._tree.txt +2 -2
- recce/data/_not-found/index.html +2 -2
- recce/data/_not-found/index.txt +2 -2
- recce/data/checks/__next.@lineage.__DEFAULT__.txt +1 -1
- recce/data/checks/__next._full.txt +2 -2
- recce/data/checks/__next._head.txt +1 -1
- recce/data/checks/__next._index.txt +2 -2
- recce/data/checks/__next._tree.txt +2 -2
- recce/data/checks/__next.checks.__PAGE__.txt +1 -1
- recce/data/checks/__next.checks.txt +1 -1
- recce/data/checks/index.html +2 -2
- recce/data/checks/index.txt +2 -2
- recce/data/index.html +2 -2
- recce/data/index.txt +2 -2
- recce/data/lineage/__next.@lineage.__DEFAULT__.txt +1 -1
- recce/data/lineage/__next._full.txt +2 -2
- recce/data/lineage/__next._head.txt +1 -1
- recce/data/lineage/__next._index.txt +2 -2
- recce/data/lineage/__next._tree.txt +2 -2
- recce/data/lineage/__next.lineage.__PAGE__.txt +1 -1
- recce/data/lineage/__next.lineage.txt +1 -1
- recce/data/lineage/index.html +2 -2
- recce/data/lineage/index.txt +2 -2
- recce/data/query/__next.@lineage.__DEFAULT__.txt +1 -1
- recce/data/query/__next._full.txt +2 -2
- recce/data/query/__next._head.txt +1 -1
- recce/data/query/__next._index.txt +2 -2
- recce/data/query/__next._tree.txt +2 -2
- recce/data/query/__next.query.__PAGE__.txt +1 -1
- recce/data/query/__next.query.txt +1 -1
- recce/data/query/index.html +2 -2
- recce/data/query/index.txt +2 -2
- recce/models/check.py +21 -2
- recce/models/websocket.py +66 -0
- recce/server.py +125 -8
- recce/util/cloud/base.py +14 -1
- recce/util/cloud/check_events.py +22 -5
- recce/util/cloud/checks.py +7 -3
- recce/websocket.py +166 -0
- {recce-1.34.0.dist-info → recce-1.34.1.dist-info}/METADATA +1 -1
- {recce-1.34.0.dist-info → recce-1.34.1.dist-info}/RECORD +65 -63
- /recce/data/_next/static/{OyDG3WjH0168IzuuFgRC6 → rRopaMiU51gUmORexSTEw}/_buildManifest.js +0 -0
- /recce/data/_next/static/{OyDG3WjH0168IzuuFgRC6 → rRopaMiU51gUmORexSTEw}/_clientMiddlewareManifest.json +0 -0
- /recce/data/_next/static/{OyDG3WjH0168IzuuFgRC6 → rRopaMiU51gUmORexSTEw}/_ssgManifest.js +0 -0
- {recce-1.34.0.dist-info → recce-1.34.1.dist-info}/WHEEL +0 -0
- {recce-1.34.0.dist-info → recce-1.34.1.dist-info}/entry_points.txt +0 -0
- {recce-1.34.0.dist-info → recce-1.34.1.dist-info}/licenses/LICENSE +0 -0
recce/data/query/index.txt
CHANGED
|
@@ -9,13 +9,13 @@
|
|
|
9
9
|
9:I[159525,["/_next/static/chunks/a86a0aa30d03f88d.js","/_next/static/chunks/2bc5c51da5e54973.js"],"ClientPageRoot"]
|
|
10
10
|
a:I[884916,["/_next/static/chunks/1bbfc841a3bbdeab.js","/_next/static/chunks/9469a701a796ba64.js","/_next/static/chunks/ee2093563445f2b0.js","/_next/static/chunks/7bb921acbf2d3876.js","/_next/static/chunks/dafbfe5da3f2c3e7.js","/_next/static/chunks/a30fe1ec4d0d0199.js","/_next/static/chunks/f7f40fdbc2f80591.js","/_next/static/chunks/c141122216926f40.js"],"default"]
|
|
11
11
|
11:I[253348,["/_next/static/chunks/5d86ee6b69e79ebd.js","/_next/static/chunks/962acc699ca0cfa9.js"],"default"]
|
|
12
|
-
:HL["/_next/static/chunks/
|
|
12
|
+
:HL["/_next/static/chunks/b83721c72340cd2d.css","style"]
|
|
13
13
|
:HL["/_next/static/chunks/43e0b34d1dad593f.css","style"]
|
|
14
14
|
:HL["/_next/static/chunks/923964f18c87d0f1.css","style"]
|
|
15
15
|
:HL["/_next/static/chunks/1e4334d18b8a3acd.css","style"]
|
|
16
16
|
:HL["/_next/static/chunks/8a5bd6fe3abc8091.css","style"]
|
|
17
17
|
:HC["/",""]
|
|
18
|
-
0:{"P":null,"b":"
|
|
18
|
+
0:{"P":null,"b":"rRopaMiU51gUmORexSTEw","c":["","query",""],"q":"","i":false,"f":[[["",{"children":["query",{"children":["__PAGE__",{}]}],"lineage":["__DEFAULT__",{}]},"$undefined","$undefined",true],[["$","$L1","c",{"notFound":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/b83721c72340cd2d.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","link","1",{"rel":"stylesheet","href":"/_next/static/chunks/43e0b34d1dad593f.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","link","2",{"rel":"stylesheet","href":"/_next/static/chunks/923964f18c87d0f1.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","link","3",{"rel":"stylesheet","href":"/_next/static/chunks/1e4334d18b8a3acd.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/1bbfc841a3bbdeab.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/_next/static/chunks/9469a701a796ba64.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/_next/static/chunks/ee2093563445f2b0.js","async":true,"nonce":"$undefined"}],["$","script","script-3",{"src":"/_next/static/chunks/7bb921acbf2d3876.js","async":true,"nonce":"$undefined"}],["$","script","script-4",{"src":"/_next/static/chunks/dafbfe5da3f2c3e7.js","async":true,"nonce":"$undefined"}],["$","script","script-5",{"src":"/_next/static/chunks/a30fe1ec4d0d0199.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","suppressHydrationWarning":true,"children":[["$","$L2",null,{"gtmId":"GTM-M2HVJQFD"}],["$","$L3",null,{"dangerouslySetInnerHTML":{"__html":"\n (function() {\n const hash = window.location.hash;\n if (hash.startsWith('#!')) {\n const newLocation = window.location.origin + window.location.pathname;\n window.location.assign(newLocation);\n }\n })();\n "}}],["$","body",null,{"children":["$","$L4",null,{"lineage":"$undefined","children":[[],[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"$0:f:0:1:0:props:notFound:1:props:children:2:props:children:props:children:0"]]}]}]]}]],"children":["$0:f:0:1:0:props:notFound:0",["$","html",null,{"lang":"en","suppressHydrationWarning":true,"children":[["$","$L2",null,{"gtmId":"GTM-M2HVJQFD"}],["$","$L3",null,{"dangerouslySetInnerHTML":{"__html":"\n (function() {\n const hash = window.location.hash;\n if (hash.startsWith('#!')) {\n const newLocation = window.location.origin + window.location.pathname;\n window.location.assign(newLocation);\n }\n })();\n "}}],["$","body",null,{"children":["$","$L4",null,{"lineage":["$","$L5",null,{"parallelRouterKey":"lineage","error":"$6","errorStyles":[],"errorScripts":[["$","script","script-0",{"src":"/_next/static/chunks/123e829f169c0100.js","async":true}],["$","script","script-1",{"src":"/_next/static/chunks/962acc699ca0cfa9.js","async":true}]],"template":["$","$L7",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}],"children":["$","$L5",null,{"parallelRouterKey":"children","error":"$6","errorStyles":"$0:f:0:1:0:props:children:1:props:children:2:props:children:props:lineage:props:errorStyles","errorScripts":"$0:f:0:1:0:props:children:1:props:children:2:props:children:props:lineage:props:errorScripts","template":["$","$L7",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$0:f:0:1:0:props:notFound:1:props:children:2:props:children:props:children:1","forbidden":"$undefined","unauthorized":"$undefined"}]}]}]]}]]}],{"children":[["$","$8","c",{"children":[null,["$","$L5",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L7",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":[["$","$8","c",{"children":[["$","$L9",null,{"Component":"$a","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@b","$@c"]}}],[["$","script","script-0",{"src":"/_next/static/chunks/f7f40fdbc2f80591.js","async":true,"nonce":"$undefined"}],"$Ld"],"$Le"]}],{},null,false,false]},null,false,false],"lineage":["$Lf",{},null,false,false]},null,false,false],"$L10",false]],"m":"$undefined","G":["$11",[]],"S":true}
|
|
19
19
|
12:I[612109,["/_next/static/chunks/a86a0aa30d03f88d.js","/_next/static/chunks/2bc5c51da5e54973.js"],"OutletBoundary"]
|
|
20
20
|
13:"$Sreact.suspense"
|
|
21
21
|
15:I[670337,["/_next/static/chunks/1bbfc841a3bbdeab.js","/_next/static/chunks/9469a701a796ba64.js","/_next/static/chunks/ee2093563445f2b0.js","/_next/static/chunks/7bb921acbf2d3876.js","/_next/static/chunks/dafbfe5da3f2c3e7.js","/_next/static/chunks/a30fe1ec4d0d0199.js","/_next/static/chunks/c89352f5e745d873.js","/_next/static/chunks/b491ab151a29aba2.js","/_next/static/chunks/49dd9a35f25a3c78.js"],"default"]
|
recce/models/check.py
CHANGED
|
@@ -225,8 +225,16 @@ class CheckDAO:
|
|
|
225
225
|
org_id, project_id, session_id = self._get_session_info()
|
|
226
226
|
cloud_client = self._get_cloud_client()
|
|
227
227
|
|
|
228
|
+
# Get cloud user context for attribution (shared instance scenario)
|
|
229
|
+
from recce.websocket import get_current_cloud_user
|
|
230
|
+
|
|
231
|
+
cloud_user = get_current_cloud_user()
|
|
232
|
+
acting_user_id = str(cloud_user.user_id) if cloud_user else None
|
|
233
|
+
|
|
228
234
|
check_data = self._check_to_cloud_format(check)
|
|
229
|
-
cloud_check = cloud_client.create_check(
|
|
235
|
+
cloud_check = cloud_client.create_check(
|
|
236
|
+
org_id, project_id, session_id, check_data, acting_user_id=acting_user_id
|
|
237
|
+
)
|
|
230
238
|
new_check = self._cloud_to_check(cloud_check)
|
|
231
239
|
|
|
232
240
|
logger.debug(f"Created check {new_check.check_id} in cloud")
|
|
@@ -288,9 +296,20 @@ class CheckDAO:
|
|
|
288
296
|
org_id, project_id, session_id = self._get_session_info()
|
|
289
297
|
cloud_client = self._get_cloud_client()
|
|
290
298
|
|
|
299
|
+
# Get cloud user context for attribution (shared instance scenario)
|
|
300
|
+
from recce.websocket import get_current_cloud_user
|
|
301
|
+
|
|
302
|
+
cloud_user = get_current_cloud_user()
|
|
303
|
+
acting_user_id = str(cloud_user.user_id) if cloud_user else None
|
|
304
|
+
|
|
291
305
|
# Directly send the patch object to the cloud API
|
|
292
306
|
cloud_data = cloud_client.update_check(
|
|
293
|
-
org_id,
|
|
307
|
+
org_id,
|
|
308
|
+
project_id,
|
|
309
|
+
session_id,
|
|
310
|
+
str(check_id),
|
|
311
|
+
patch.model_dump(exclude_unset=True),
|
|
312
|
+
acting_user_id=acting_user_id,
|
|
294
313
|
)
|
|
295
314
|
|
|
296
315
|
logger.debug(f"Updated check {check_id} in cloud")
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WebSocket message types and user context models.
|
|
3
|
+
|
|
4
|
+
This module defines the data structures for WebSocket messages,
|
|
5
|
+
particularly the cloud_user_context message sent from Recce Cloud
|
|
6
|
+
when proxying connections to shared instances.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Dict, Optional
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class CloudUserContext:
|
|
18
|
+
"""
|
|
19
|
+
User context received from Recce Cloud for shared instance connections.
|
|
20
|
+
|
|
21
|
+
This context identifies the authenticated user who is accessing the
|
|
22
|
+
shared instance through Recce Cloud's proxy.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
user_id: str
|
|
26
|
+
user_login: str
|
|
27
|
+
user_email: Optional[str] = None
|
|
28
|
+
received_at: datetime = field(default_factory=datetime.utcnow)
|
|
29
|
+
|
|
30
|
+
def to_dict(self) -> Dict:
|
|
31
|
+
"""Convert to dictionary for API responses or logging."""
|
|
32
|
+
return {
|
|
33
|
+
"user_id": self.user_id,
|
|
34
|
+
"user_login": self.user_login,
|
|
35
|
+
"user_email": self.user_email,
|
|
36
|
+
"received_at": self.received_at.isoformat() if self.received_at else None,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CloudUserContextMessage(BaseModel):
|
|
41
|
+
"""
|
|
42
|
+
Pydantic model for parsing cloud_user_context WebSocket messages.
|
|
43
|
+
|
|
44
|
+
Example message:
|
|
45
|
+
{
|
|
46
|
+
"type": "cloud_user_context",
|
|
47
|
+
"version": 1,
|
|
48
|
+
"user_id": "uuid-string",
|
|
49
|
+
"user_login": "username",
|
|
50
|
+
"user_email": "user@example.com"
|
|
51
|
+
}
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
type: str
|
|
55
|
+
version: int
|
|
56
|
+
user_id: str
|
|
57
|
+
user_login: str
|
|
58
|
+
user_email: Optional[str] = None
|
|
59
|
+
|
|
60
|
+
def to_context(self) -> CloudUserContext:
|
|
61
|
+
"""Convert the message to a CloudUserContext instance."""
|
|
62
|
+
return CloudUserContext(
|
|
63
|
+
user_id=self.user_id,
|
|
64
|
+
user_login=self.user_login,
|
|
65
|
+
user_email=self.user_email,
|
|
66
|
+
)
|
recce/server.py
CHANGED
|
@@ -47,9 +47,15 @@ from .event import get_recce_api_token, log_api_event, log_single_env_event
|
|
|
47
47
|
from .exceptions import RecceException
|
|
48
48
|
from .github import is_github_codespace
|
|
49
49
|
from .models.types import CllData
|
|
50
|
+
from .models.websocket import CloudUserContextMessage
|
|
50
51
|
from .run import load_preset_checks
|
|
51
52
|
from .state import RecceShareStateManager, RecceStateLoader
|
|
52
53
|
from .util.startup_perf import track_timing
|
|
54
|
+
from .websocket import (
|
|
55
|
+
extract_cloud_user_from_headers,
|
|
56
|
+
get_connection_manager,
|
|
57
|
+
set_current_cloud_user,
|
|
58
|
+
)
|
|
53
59
|
|
|
54
60
|
logger = logging.getLogger("uvicorn")
|
|
55
61
|
|
|
@@ -316,8 +322,6 @@ def dbt_env_updated_callback():
|
|
|
316
322
|
asyncio.run(broadcast(payload))
|
|
317
323
|
|
|
318
324
|
|
|
319
|
-
clients = set()
|
|
320
|
-
|
|
321
325
|
origins = [
|
|
322
326
|
"http://localhost:3000",
|
|
323
327
|
"http://localhost:3001",
|
|
@@ -358,6 +362,36 @@ async def track_activity_for_idle_timeout(request: Request, call_next):
|
|
|
358
362
|
return response
|
|
359
363
|
|
|
360
364
|
|
|
365
|
+
@app.middleware("http")
|
|
366
|
+
async def extract_cloud_user_context(request: Request, call_next):
|
|
367
|
+
"""
|
|
368
|
+
Extract cloud user context from HTTP headers sent by Recce Cloud proxy.
|
|
369
|
+
|
|
370
|
+
When Recce Cloud proxies HTTP requests to a shared instance, it can include
|
|
371
|
+
headers identifying the authenticated user. This middleware extracts those
|
|
372
|
+
headers and sets the user context for the duration of the request.
|
|
373
|
+
|
|
374
|
+
Headers:
|
|
375
|
+
X-Recce-User-Id: The user's UUID
|
|
376
|
+
X-Recce-User-Login: The user's login/username
|
|
377
|
+
X-Recce-User-Email: The user's email (optional)
|
|
378
|
+
"""
|
|
379
|
+
# Extract user context from headers
|
|
380
|
+
cloud_user = extract_cloud_user_from_headers(dict(request.headers))
|
|
381
|
+
|
|
382
|
+
if cloud_user:
|
|
383
|
+
# Set the context for this request
|
|
384
|
+
set_current_cloud_user(cloud_user)
|
|
385
|
+
|
|
386
|
+
try:
|
|
387
|
+
response = await call_next(request)
|
|
388
|
+
return response
|
|
389
|
+
finally:
|
|
390
|
+
# Clear the context after the request completes
|
|
391
|
+
if cloud_user:
|
|
392
|
+
set_current_cloud_user(None)
|
|
393
|
+
|
|
394
|
+
|
|
361
395
|
@app.middleware("http")
|
|
362
396
|
async def set_context_by_cookie(request: Request, call_next):
|
|
363
397
|
response = await call_next(request)
|
|
@@ -824,19 +858,102 @@ async def version():
|
|
|
824
858
|
@app.websocket("/api/ws")
|
|
825
859
|
async def websocket_endpoint(websocket: WebSocket):
|
|
826
860
|
await websocket.accept()
|
|
827
|
-
|
|
861
|
+
manager = get_connection_manager()
|
|
862
|
+
manager.connect(websocket)
|
|
828
863
|
try:
|
|
829
864
|
while True:
|
|
830
865
|
data = await websocket.receive_text()
|
|
831
|
-
|
|
832
|
-
await websocket.send_text("pong")
|
|
866
|
+
await _handle_websocket_message(websocket, data, manager)
|
|
833
867
|
except WebSocketDisconnect:
|
|
834
|
-
|
|
868
|
+
manager.disconnect(websocket)
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
async def _handle_websocket_message(websocket: WebSocket, data: str, manager) -> None:
|
|
872
|
+
"""
|
|
873
|
+
Handle incoming WebSocket messages.
|
|
874
|
+
|
|
875
|
+
Supports:
|
|
876
|
+
- Plain text "ping" messages (backward compatibility)
|
|
877
|
+
- JSON messages with "type" field for routing
|
|
878
|
+
"""
|
|
879
|
+
# Handle plain text ping for backward compatibility
|
|
880
|
+
if data == "ping":
|
|
881
|
+
await websocket.send_text("pong")
|
|
882
|
+
return
|
|
883
|
+
|
|
884
|
+
# Try to parse as JSON
|
|
885
|
+
try:
|
|
886
|
+
message = json.loads(data)
|
|
887
|
+
except json.JSONDecodeError:
|
|
888
|
+
logger.warning(f"Received non-JSON WebSocket message: {data[:100]}")
|
|
889
|
+
return
|
|
890
|
+
|
|
891
|
+
# Route based on message type
|
|
892
|
+
message_type = message.get("type")
|
|
893
|
+
|
|
894
|
+
if message_type == "cloud_user_context":
|
|
895
|
+
await _handle_cloud_user_context(websocket, message, manager)
|
|
896
|
+
elif message_type == "ping":
|
|
897
|
+
# JSON ping format
|
|
898
|
+
await websocket.send_text(json.dumps({"type": "pong"}))
|
|
899
|
+
else:
|
|
900
|
+
logger.debug(f"Unhandled WebSocket message type: {message_type}")
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
async def _handle_cloud_user_context(websocket: WebSocket, message: dict, manager) -> None:
|
|
904
|
+
"""
|
|
905
|
+
Handle cloud_user_context message from Recce Cloud.
|
|
906
|
+
|
|
907
|
+
This message is sent when Recce Cloud proxies a WebSocket connection
|
|
908
|
+
to identify the authenticated user.
|
|
909
|
+
"""
|
|
910
|
+
try:
|
|
911
|
+
# Validate and parse the message
|
|
912
|
+
context_message = CloudUserContextMessage(**message)
|
|
913
|
+
|
|
914
|
+
# Check version compatibility
|
|
915
|
+
if context_message.version > 1:
|
|
916
|
+
logger.warning(
|
|
917
|
+
f"Received cloud_user_context with unsupported version: "
|
|
918
|
+
f"{context_message.version}. Some fields may be ignored."
|
|
919
|
+
)
|
|
920
|
+
|
|
921
|
+
# Store the user context
|
|
922
|
+
user_context = context_message.to_context()
|
|
923
|
+
manager.set_user_context(websocket, user_context)
|
|
924
|
+
|
|
925
|
+
# Send acknowledgment
|
|
926
|
+
await websocket.send_text(
|
|
927
|
+
json.dumps(
|
|
928
|
+
{
|
|
929
|
+
"type": "cloud_user_context_ack",
|
|
930
|
+
"status": "ok",
|
|
931
|
+
"user_login": user_context.user_login,
|
|
932
|
+
}
|
|
933
|
+
)
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
except Exception as e:
|
|
937
|
+
logger.error(f"Failed to process cloud_user_context message: {e}")
|
|
938
|
+
await websocket.send_text(
|
|
939
|
+
json.dumps(
|
|
940
|
+
{
|
|
941
|
+
"type": "cloud_user_context_ack",
|
|
942
|
+
"status": "error",
|
|
943
|
+
"error": str(e),
|
|
944
|
+
}
|
|
945
|
+
)
|
|
946
|
+
)
|
|
835
947
|
|
|
836
948
|
|
|
837
949
|
async def broadcast(data: str):
|
|
838
|
-
|
|
839
|
-
|
|
950
|
+
"""Broadcast a message to all connected WebSocket clients."""
|
|
951
|
+
manager = get_connection_manager()
|
|
952
|
+
for client in manager.clients:
|
|
953
|
+
try:
|
|
954
|
+
await client.send_text(data)
|
|
955
|
+
except Exception as e:
|
|
956
|
+
logger.debug(f"Failed to send to client: {e}")
|
|
840
957
|
|
|
841
958
|
|
|
842
959
|
@app.post("/api/connect")
|
recce/util/cloud/base.py
CHANGED
|
@@ -49,7 +49,14 @@ class CloudBase:
|
|
|
49
49
|
self.base_url = f"{RECCE_CLOUD_API_HOST}/api/v1"
|
|
50
50
|
self.base_url_v2 = f"{RECCE_CLOUD_API_HOST}/api/v2"
|
|
51
51
|
|
|
52
|
-
def _request(
|
|
52
|
+
def _request(
|
|
53
|
+
self,
|
|
54
|
+
method: str,
|
|
55
|
+
url: str,
|
|
56
|
+
headers: Optional[Dict] = None,
|
|
57
|
+
acting_user_id: Optional[str] = None,
|
|
58
|
+
**kwargs,
|
|
59
|
+
):
|
|
53
60
|
"""
|
|
54
61
|
Make an authenticated HTTP request to Recce Cloud API.
|
|
55
62
|
|
|
@@ -57,6 +64,7 @@ class CloudBase:
|
|
|
57
64
|
method: HTTP method (GET, POST, PATCH, DELETE, etc.)
|
|
58
65
|
url: Full URL for the request
|
|
59
66
|
headers: Optional additional headers
|
|
67
|
+
acting_user_id: Optional user ID to act on behalf of (for shared instances)
|
|
60
68
|
**kwargs: Additional arguments passed to requests.request
|
|
61
69
|
|
|
62
70
|
Returns:
|
|
@@ -66,6 +74,11 @@ class CloudBase:
|
|
|
66
74
|
**(headers or {}),
|
|
67
75
|
"Authorization": f"Bearer {self.token}",
|
|
68
76
|
}
|
|
77
|
+
|
|
78
|
+
# Include acting user header for shared instance user attribution
|
|
79
|
+
if acting_user_id:
|
|
80
|
+
headers["X-Recce-Acting-User-Id"] = acting_user_id
|
|
81
|
+
|
|
69
82
|
url = self._replace_localhost_with_docker_internal(url)
|
|
70
83
|
return requests.request(method, url, headers=headers, **kwargs)
|
|
71
84
|
|
recce/util/cloud/check_events.py
CHANGED
|
@@ -5,7 +5,7 @@ This module provides methods for managing check events (timeline/conversation) i
|
|
|
5
5
|
including CRUD operations for comments and retrieving state change events.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
from typing import Dict, List
|
|
8
|
+
from typing import Dict, List, Optional
|
|
9
9
|
|
|
10
10
|
from recce.util.cloud.base import CloudBase
|
|
11
11
|
|
|
@@ -94,7 +94,15 @@ class CheckEventsCloud(CloudBase):
|
|
|
94
94
|
# Response is wrapped: {"event": {...}}
|
|
95
95
|
return data.get("event", {})
|
|
96
96
|
|
|
97
|
-
def create_comment(
|
|
97
|
+
def create_comment(
|
|
98
|
+
self,
|
|
99
|
+
org_id: str,
|
|
100
|
+
project_id: str,
|
|
101
|
+
session_id: str,
|
|
102
|
+
check_id: str,
|
|
103
|
+
content: str,
|
|
104
|
+
acting_user_id: Optional[str] = None,
|
|
105
|
+
) -> Dict:
|
|
98
106
|
"""
|
|
99
107
|
Create a new comment on a check.
|
|
100
108
|
|
|
@@ -104,6 +112,7 @@ class CheckEventsCloud(CloudBase):
|
|
|
104
112
|
session_id: Session ID
|
|
105
113
|
check_id: Check ID
|
|
106
114
|
content: Comment content (plain text or markdown)
|
|
115
|
+
acting_user_id: Optional user ID to attribute the comment to (for shared instances)
|
|
107
116
|
|
|
108
117
|
Returns:
|
|
109
118
|
Created event dictionary
|
|
@@ -119,7 +128,7 @@ class CheckEventsCloud(CloudBase):
|
|
|
119
128
|
>>> print(f"Created comment with ID: {event['id']}")
|
|
120
129
|
"""
|
|
121
130
|
api_url = self._build_events_url(org_id, project_id, session_id, check_id)
|
|
122
|
-
response = self._request("POST", api_url, json={"content": content})
|
|
131
|
+
response = self._request("POST", api_url, json={"content": content}, acting_user_id=acting_user_id)
|
|
123
132
|
|
|
124
133
|
self._raise_for_status(
|
|
125
134
|
response,
|
|
@@ -131,7 +140,14 @@ class CheckEventsCloud(CloudBase):
|
|
|
131
140
|
return data.get("event", {})
|
|
132
141
|
|
|
133
142
|
def update_comment(
|
|
134
|
-
self,
|
|
143
|
+
self,
|
|
144
|
+
org_id: str,
|
|
145
|
+
project_id: str,
|
|
146
|
+
session_id: str,
|
|
147
|
+
check_id: str,
|
|
148
|
+
event_id: str,
|
|
149
|
+
content: str,
|
|
150
|
+
acting_user_id: Optional[str] = None,
|
|
135
151
|
) -> Dict:
|
|
136
152
|
"""
|
|
137
153
|
Update an existing comment.
|
|
@@ -145,6 +161,7 @@ class CheckEventsCloud(CloudBase):
|
|
|
145
161
|
check_id: Check ID
|
|
146
162
|
event_id: Event ID of the comment to update
|
|
147
163
|
content: New comment content
|
|
164
|
+
acting_user_id: Optional user ID performing the update (for shared instances)
|
|
148
165
|
|
|
149
166
|
Returns:
|
|
150
167
|
Updated event dictionary
|
|
@@ -153,7 +170,7 @@ class CheckEventsCloud(CloudBase):
|
|
|
153
170
|
RecceCloudException: If the request fails or user is not authorized
|
|
154
171
|
"""
|
|
155
172
|
api_url = f"{self._build_events_url(org_id, project_id, session_id, check_id)}/{event_id}"
|
|
156
|
-
response = self._request("PATCH", api_url, json={"content": content})
|
|
173
|
+
response = self._request("PATCH", api_url, json={"content": content}, acting_user_id=acting_user_id)
|
|
157
174
|
|
|
158
175
|
self._raise_for_status(
|
|
159
176
|
response,
|
recce/util/cloud/checks.py
CHANGED
|
@@ -5,7 +5,7 @@ This module provides methods for managing checks (validation operations) in Recc
|
|
|
5
5
|
including CRUD operations for checks within sessions.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
from typing import Dict, List
|
|
8
|
+
from typing import Dict, List, Optional
|
|
9
9
|
|
|
10
10
|
from recce.util.cloud.base import CloudBase
|
|
11
11
|
|
|
@@ -60,6 +60,7 @@ class ChecksCloud(CloudBase):
|
|
|
60
60
|
project_id: str,
|
|
61
61
|
session_id: str,
|
|
62
62
|
check_data: Dict,
|
|
63
|
+
acting_user_id: Optional[str] = None,
|
|
63
64
|
) -> Dict:
|
|
64
65
|
"""
|
|
65
66
|
Create a new check in a session.
|
|
@@ -69,6 +70,7 @@ class ChecksCloud(CloudBase):
|
|
|
69
70
|
project_id: Project ID
|
|
70
71
|
session_id: Session ID
|
|
71
72
|
check_data: Check data to create (should include check type, params, etc.)
|
|
73
|
+
acting_user_id: Optional user ID to act on behalf of (for shared instances)
|
|
72
74
|
|
|
73
75
|
Returns:
|
|
74
76
|
Created check dictionary
|
|
@@ -86,7 +88,7 @@ class ChecksCloud(CloudBase):
|
|
|
86
88
|
>>> print(f"Created check with ID: {check['id']}")
|
|
87
89
|
"""
|
|
88
90
|
api_url = f"{self.base_url_v2}/organizations/{org_id}/projects/{project_id}/sessions/{session_id}/checks"
|
|
89
|
-
response = self._request("POST", api_url, json=check_data)
|
|
91
|
+
response = self._request("POST", api_url, json=check_data, acting_user_id=acting_user_id)
|
|
90
92
|
|
|
91
93
|
self._raise_for_status(
|
|
92
94
|
response,
|
|
@@ -142,6 +144,7 @@ class ChecksCloud(CloudBase):
|
|
|
142
144
|
session_id: str,
|
|
143
145
|
check_id: str,
|
|
144
146
|
check_data: Dict,
|
|
147
|
+
acting_user_id: Optional[str] = None,
|
|
145
148
|
) -> Dict:
|
|
146
149
|
"""
|
|
147
150
|
Update an existing check.
|
|
@@ -152,6 +155,7 @@ class ChecksCloud(CloudBase):
|
|
|
152
155
|
session_id: Session ID
|
|
153
156
|
check_id: Check ID
|
|
154
157
|
check_data: Updated check data (partial updates supported)
|
|
158
|
+
acting_user_id: Optional user ID to act on behalf of (for shared instances)
|
|
155
159
|
|
|
156
160
|
Returns:
|
|
157
161
|
Updated check dictionary
|
|
@@ -170,7 +174,7 @@ class ChecksCloud(CloudBase):
|
|
|
170
174
|
api_url = (
|
|
171
175
|
f"{self.base_url_v2}/organizations/{org_id}/projects/{project_id}/sessions/{session_id}/checks/{check_id}"
|
|
172
176
|
)
|
|
173
|
-
response = self._request("PATCH", api_url, json=check_data)
|
|
177
|
+
response = self._request("PATCH", api_url, json=check_data, acting_user_id=acting_user_id)
|
|
174
178
|
|
|
175
179
|
self._raise_for_status(
|
|
176
180
|
response,
|
recce/websocket.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WebSocket connection manager with per-connection user context storage.
|
|
3
|
+
|
|
4
|
+
This module provides thread-safe storage and retrieval of user context
|
|
5
|
+
for WebSocket connections, particularly for shared instances where
|
|
6
|
+
Recce Cloud proxies authenticated user information.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from contextvars import ContextVar
|
|
11
|
+
from threading import Lock
|
|
12
|
+
from typing import Dict, Optional, Set
|
|
13
|
+
|
|
14
|
+
from fastapi import WebSocket
|
|
15
|
+
|
|
16
|
+
from recce.models.websocket import CloudUserContext
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("uvicorn")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class WebSocketConnectionManager:
|
|
22
|
+
"""
|
|
23
|
+
Thread-safe manager for WebSocket connections and their associated user contexts.
|
|
24
|
+
|
|
25
|
+
This class maintains:
|
|
26
|
+
- A set of active WebSocket connections (for broadcasting)
|
|
27
|
+
- A mapping of connections to user contexts (for attribution)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self):
|
|
31
|
+
self._clients: Set[WebSocket] = set()
|
|
32
|
+
self._user_contexts: Dict[int, CloudUserContext] = {} # Use id(websocket) as key
|
|
33
|
+
self._lock = Lock()
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def clients(self) -> Set[WebSocket]:
|
|
37
|
+
"""Get a copy of the current client set."""
|
|
38
|
+
with self._lock:
|
|
39
|
+
return set(self._clients)
|
|
40
|
+
|
|
41
|
+
def connect(self, websocket: WebSocket) -> None:
|
|
42
|
+
"""Register a new WebSocket connection."""
|
|
43
|
+
with self._lock:
|
|
44
|
+
self._clients.add(websocket)
|
|
45
|
+
logger.debug(f"WebSocket connected. Total clients: {len(self._clients)}")
|
|
46
|
+
|
|
47
|
+
def disconnect(self, websocket: WebSocket) -> None:
|
|
48
|
+
"""
|
|
49
|
+
Remove a WebSocket connection and its associated user context.
|
|
50
|
+
|
|
51
|
+
This method is safe to call even if the websocket was never registered.
|
|
52
|
+
"""
|
|
53
|
+
with self._lock:
|
|
54
|
+
self._clients.discard(websocket)
|
|
55
|
+
self._user_contexts.pop(id(websocket), None)
|
|
56
|
+
logger.debug(f"WebSocket disconnected. Total clients: {len(self._clients)}")
|
|
57
|
+
|
|
58
|
+
def set_user_context(self, websocket: WebSocket, context: CloudUserContext) -> None:
|
|
59
|
+
"""
|
|
60
|
+
Associate a user context with a WebSocket connection.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
websocket: The WebSocket connection
|
|
64
|
+
context: The user context from Recce Cloud
|
|
65
|
+
"""
|
|
66
|
+
with self._lock:
|
|
67
|
+
self._user_contexts[id(websocket)] = context
|
|
68
|
+
logger.info(
|
|
69
|
+
f"User context set for WebSocket: user_login={context.user_login}, " f"user_id={context.user_id}"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def get_user_context(self, websocket: WebSocket) -> Optional[CloudUserContext]:
|
|
73
|
+
"""
|
|
74
|
+
Get the user context for a WebSocket connection.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
websocket: The WebSocket connection
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
The user context if set, None otherwise
|
|
81
|
+
"""
|
|
82
|
+
with self._lock:
|
|
83
|
+
return self._user_contexts.get(id(websocket))
|
|
84
|
+
|
|
85
|
+
def has_user_context(self, websocket: WebSocket) -> bool:
|
|
86
|
+
"""Check if a WebSocket has an associated user context."""
|
|
87
|
+
with self._lock:
|
|
88
|
+
return id(websocket) in self._user_contexts
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# Global connection manager instance
|
|
92
|
+
connection_manager = WebSocketConnectionManager()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_connection_manager() -> WebSocketConnectionManager:
|
|
96
|
+
"""Get the global connection manager instance."""
|
|
97
|
+
return connection_manager
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# Context variable to store the current user context during request processing
|
|
101
|
+
_current_user_context: ContextVar[Optional[CloudUserContext]] = ContextVar("current_user_context", default=None)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def get_current_cloud_user() -> Optional[CloudUserContext]:
|
|
105
|
+
"""
|
|
106
|
+
Get the current cloud user context.
|
|
107
|
+
|
|
108
|
+
This returns the user context set for the current async context,
|
|
109
|
+
which is typically set when processing actions from a WebSocket
|
|
110
|
+
connection with an associated user.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
The CloudUserContext if available, None otherwise
|
|
114
|
+
"""
|
|
115
|
+
return _current_user_context.get()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def set_current_cloud_user(context: Optional[CloudUserContext]) -> None:
|
|
119
|
+
"""
|
|
120
|
+
Set the current cloud user context.
|
|
121
|
+
|
|
122
|
+
This should be called when processing actions that need user attribution.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
context: The user context or None to clear
|
|
126
|
+
"""
|
|
127
|
+
_current_user_context.set(context)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# HTTP header names for cloud user context
|
|
131
|
+
CLOUD_USER_ID_HEADER = "X-Recce-User-Id"
|
|
132
|
+
CLOUD_USER_LOGIN_HEADER = "X-Recce-User-Login"
|
|
133
|
+
CLOUD_USER_EMAIL_HEADER = "X-Recce-User-Email"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def extract_cloud_user_from_headers(headers: dict) -> Optional[CloudUserContext]:
|
|
137
|
+
"""
|
|
138
|
+
Extract cloud user context from HTTP request headers.
|
|
139
|
+
|
|
140
|
+
This is used by middleware to set user context for HTTP requests
|
|
141
|
+
when Recce Cloud proxies requests with user identification headers.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
headers: Dictionary of HTTP headers (case-insensitive keys)
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
CloudUserContext if required headers are present, None otherwise
|
|
148
|
+
"""
|
|
149
|
+
# Headers may be case-insensitive, so normalize to lowercase for lookup
|
|
150
|
+
normalized = {k.lower(): v for k, v in headers.items()}
|
|
151
|
+
|
|
152
|
+
user_id = normalized.get(CLOUD_USER_ID_HEADER.lower())
|
|
153
|
+
user_login = normalized.get(CLOUD_USER_LOGIN_HEADER.lower())
|
|
154
|
+
user_email = normalized.get(CLOUD_USER_EMAIL_HEADER.lower())
|
|
155
|
+
|
|
156
|
+
# Both user_id and user_login are required
|
|
157
|
+
if not user_id or not user_login:
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
context = CloudUserContext(
|
|
161
|
+
user_id=user_id,
|
|
162
|
+
user_login=user_login,
|
|
163
|
+
user_email=user_email,
|
|
164
|
+
)
|
|
165
|
+
logger.debug(f"Extracted cloud user from headers: user_login={user_login}, user_id={user_id}")
|
|
166
|
+
return context
|