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.
Files changed (65) hide show
  1. recce/VERSION +1 -1
  2. recce/apis/check_events_api.py +15 -2
  3. recce/apis/check_func.py +13 -0
  4. recce/data/404/index.html +2 -2
  5. recce/data/404.html +2 -2
  6. recce/data/__next.@lineage.!KHNsb3Qp.__PAGE__.txt +1 -1
  7. recce/data/__next.@lineage.!KHNsb3Qp.txt +1 -1
  8. recce/data/__next.__PAGE__.txt +1 -1
  9. recce/data/__next._full.txt +2 -2
  10. recce/data/__next._head.txt +1 -1
  11. recce/data/__next._index.txt +2 -2
  12. recce/data/__next._tree.txt +2 -2
  13. recce/data/_next/static/chunks/{f6d41978531cd9db.css → b83721c72340cd2d.css} +1 -1
  14. recce/data/_not-found/__next._full.txt +2 -2
  15. recce/data/_not-found/__next._head.txt +1 -1
  16. recce/data/_not-found/__next._index.txt +2 -2
  17. recce/data/_not-found/__next._not-found.__PAGE__.txt +1 -1
  18. recce/data/_not-found/__next._not-found.txt +1 -1
  19. recce/data/_not-found/__next._tree.txt +2 -2
  20. recce/data/_not-found/index.html +2 -2
  21. recce/data/_not-found/index.txt +2 -2
  22. recce/data/checks/__next.@lineage.__DEFAULT__.txt +1 -1
  23. recce/data/checks/__next._full.txt +2 -2
  24. recce/data/checks/__next._head.txt +1 -1
  25. recce/data/checks/__next._index.txt +2 -2
  26. recce/data/checks/__next._tree.txt +2 -2
  27. recce/data/checks/__next.checks.__PAGE__.txt +1 -1
  28. recce/data/checks/__next.checks.txt +1 -1
  29. recce/data/checks/index.html +2 -2
  30. recce/data/checks/index.txt +2 -2
  31. recce/data/index.html +2 -2
  32. recce/data/index.txt +2 -2
  33. recce/data/lineage/__next.@lineage.__DEFAULT__.txt +1 -1
  34. recce/data/lineage/__next._full.txt +2 -2
  35. recce/data/lineage/__next._head.txt +1 -1
  36. recce/data/lineage/__next._index.txt +2 -2
  37. recce/data/lineage/__next._tree.txt +2 -2
  38. recce/data/lineage/__next.lineage.__PAGE__.txt +1 -1
  39. recce/data/lineage/__next.lineage.txt +1 -1
  40. recce/data/lineage/index.html +2 -2
  41. recce/data/lineage/index.txt +2 -2
  42. recce/data/query/__next.@lineage.__DEFAULT__.txt +1 -1
  43. recce/data/query/__next._full.txt +2 -2
  44. recce/data/query/__next._head.txt +1 -1
  45. recce/data/query/__next._index.txt +2 -2
  46. recce/data/query/__next._tree.txt +2 -2
  47. recce/data/query/__next.query.__PAGE__.txt +1 -1
  48. recce/data/query/__next.query.txt +1 -1
  49. recce/data/query/index.html +2 -2
  50. recce/data/query/index.txt +2 -2
  51. recce/models/check.py +21 -2
  52. recce/models/websocket.py +66 -0
  53. recce/server.py +125 -8
  54. recce/util/cloud/base.py +14 -1
  55. recce/util/cloud/check_events.py +22 -5
  56. recce/util/cloud/checks.py +7 -3
  57. recce/websocket.py +166 -0
  58. {recce-1.34.0.dist-info → recce-1.34.1.dist-info}/METADATA +1 -1
  59. {recce-1.34.0.dist-info → recce-1.34.1.dist-info}/RECORD +65 -63
  60. /recce/data/_next/static/{OyDG3WjH0168IzuuFgRC6 → rRopaMiU51gUmORexSTEw}/_buildManifest.js +0 -0
  61. /recce/data/_next/static/{OyDG3WjH0168IzuuFgRC6 → rRopaMiU51gUmORexSTEw}/_clientMiddlewareManifest.json +0 -0
  62. /recce/data/_next/static/{OyDG3WjH0168IzuuFgRC6 → rRopaMiU51gUmORexSTEw}/_ssgManifest.js +0 -0
  63. {recce-1.34.0.dist-info → recce-1.34.1.dist-info}/WHEEL +0 -0
  64. {recce-1.34.0.dist-info → recce-1.34.1.dist-info}/entry_points.txt +0 -0
  65. {recce-1.34.0.dist-info → recce-1.34.1.dist-info}/licenses/LICENSE +0 -0
@@ -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/f6d41978531cd9db.css","style"]
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":"OyDG3WjH0168IzuuFgRC6","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/f6d41978531cd9db.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}
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(org_id, project_id, session_id, check_data)
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, project_id, session_id, str(check_id), patch.model_dump(exclude_unset=True)
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
- clients.add(websocket)
861
+ manager = get_connection_manager()
862
+ manager.connect(websocket)
828
863
  try:
829
864
  while True:
830
865
  data = await websocket.receive_text()
831
- if data == "ping":
832
- await websocket.send_text("pong")
866
+ await _handle_websocket_message(websocket, data, manager)
833
867
  except WebSocketDisconnect:
834
- clients.remove(websocket)
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
- for client in clients:
839
- await client.send_text(data)
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(self, method: str, url: str, headers: Optional[Dict] = None, **kwargs):
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
 
@@ -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(self, org_id: str, project_id: str, session_id: str, check_id: str, content: str) -> Dict:
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, org_id: str, project_id: str, session_id: str, check_id: str, event_id: str, content: str
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,
@@ -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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: recce
3
- Version: 1.34.0
3
+ Version: 1.34.1
4
4
  Summary: Environment diff tool for dbt
5
5
  Project-URL: Bug Tracker, https://github.com/InfuseAI/recce/issues
6
6
  Project-URL: Homepage, https://github.com/InfuseAI/recce