fix: scope workflow run to org rather than user

This commit is contained in:
Abhishek Kumar 2026-07-10 16:52:43 +05:30
parent 4989bab1e9
commit 43737c67dc
29 changed files with 218 additions and 158 deletions

View file

@ -354,7 +354,6 @@ class CampaignCallDispatcher:
webhook_url = (
f"{backend_endpoint}/api/v1/telephony/{webhook_endpoint}"
f"?workflow_id={campaign.workflow_id}"
f"&user_id={campaign.created_by}"
f"&workflow_run_id={workflow_run.id}"
f"&organization_id={campaign.organization_id}"
)
@ -365,7 +364,7 @@ class CampaignCallDispatcher:
workflow_run_id=workflow_run.id,
from_number=from_number,
workflow_id=campaign.workflow_id,
user_id=campaign.created_by,
organization_id=campaign.organization_id,
)
# Store provider type and metadata in gathered_context

View file

@ -254,7 +254,7 @@ async def run_pipeline_telephony(
provider_name: str,
workflow_id: int,
workflow_run_id: int,
user_id: int,
organization_id: int,
call_id: str,
transport_kwargs: dict,
) -> None:
@ -268,7 +268,7 @@ async def run_pipeline_telephony(
provider_name=provider_name,
workflow_id=workflow_id,
workflow_run_id=workflow_run_id,
user_id=user_id,
organization_id=organization_id,
call_id=call_id,
transport_kwargs=transport_kwargs,
)
@ -285,7 +285,7 @@ async def _run_pipeline_telephony_impl(
provider_name: str,
workflow_id: int,
workflow_run_id: int,
user_id: int,
organization_id: int,
call_id: str,
transport_kwargs: dict,
) -> None:
@ -300,7 +300,9 @@ async def _run_pipeline_telephony_impl(
provider_name: Stable identifier of the provider (registry key).
workflow_id: Workflow being executed.
workflow_run_id: Workflow run row.
user_id: Owner of the workflow.
organization_id: Tenant owning the workflow and the run. Every lookup
below is scoped by it; the workflow owner is read off the workflow
row and used only for attribution.
call_id: Provider call identifier.
transport_kwargs: Provider-specific kwargs forwarded to the transport
factory (e.g. stream_sid + call_sid for Twilio).
@ -308,12 +310,15 @@ async def _run_pipeline_telephony_impl(
logger.debug(f"Running {provider_name} pipeline for workflow_run {workflow_run_id}")
set_current_run_id(workflow_run_id)
workflow = await db_client.get_workflow(workflow_id, user_id)
if workflow:
set_current_org_id(workflow.organization_id)
workflow = await db_client.get_workflow(
workflow_id, organization_id=organization_id
)
if not workflow:
raise HTTPException(status_code=404, detail="Workflow not found")
set_current_org_id(workflow.organization_id)
ambient_noise_config = None
if workflow and workflow.workflow_configurations:
if workflow.workflow_configurations:
ambient_noise_config = workflow.workflow_configurations.get(
"ambient_noise_configuration"
)
@ -322,9 +327,16 @@ async def _run_pipeline_telephony_impl(
# (test call, campaign dispatch, inbound). Transports use it to load creds
# from the right config row. Falls back to None for legacy runs (transports
# then resolve the org's default config).
workflow_run = await db_client.get_workflow_run(workflow_run_id)
workflow_run = await db_client.get_workflow_run(
workflow_run_id, organization_id=organization_id
)
if not workflow_run:
raise HTTPException(status_code=404, detail="Workflow run not found")
if workflow_run.workflow_id != workflow_id:
raise HTTPException(status_code=400, detail="workflow_run_workflow_mismatch")
telephony_configuration_id = None
if workflow_run and workflow_run.initial_context:
if workflow_run.initial_context:
telephony_configuration_id = workflow_run.initial_context.get(
"telephony_configuration_id"
)
@ -336,11 +348,9 @@ async def _run_pipeline_telephony_impl(
get_effective_ai_model_configuration_for_workflow,
)
run_configs = (
(workflow_run.definition.workflow_configurations or {}) if workflow_run else {}
)
run_configs = workflow_run.definition.workflow_configurations or {}
user_config = await get_effective_ai_model_configuration_for_workflow(
organization_id=workflow.organization_id if workflow else None,
organization_id=workflow.organization_id,
workflow_configurations=run_configs,
)
is_realtime = bool(user_config.is_realtime and user_config.realtime is not None)
@ -364,10 +374,12 @@ async def _run_pipeline_telephony_impl(
transport,
workflow_id,
workflow_run_id,
user_id,
# Attribution only — scoping is driven by organization_id below.
workflow.user_id,
audio_config=audio_config,
workflow_run=workflow_run,
resolved_user_config=user_config,
organization_id=organization_id,
)
except Exception as e:
logger.error(

View file

@ -77,7 +77,7 @@ class TelephonyProvider(ABC):
async def verify_webhook_signature(self, url: str, params: Dict[str, Any], signature: str) -> bool
@abstractmethod
async def get_webhook_response(self, workflow_id: int, user_id: int, workflow_run_id: int) -> str
async def get_webhook_response(self, workflow_id: int, organization_id: int, workflow_run_id: int) -> str
```
## Configuration Loading

View file

@ -353,19 +353,18 @@ class ARIConnection:
workflow_run_id = args_dict.get("workflow_run_id")
workflow_id = args_dict.get("workflow_id")
user_id = args_dict.get("user_id")
if not workflow_run_id or not workflow_id or not user_id:
if not workflow_run_id or not workflow_id:
logger.warning(
f"[ARI org={self.organization_id}] StasisStart missing required args: "
f"workflow_run_id={workflow_run_id}, workflow_id={workflow_id}, user_id={user_id}"
f"workflow_run_id={workflow_run_id}, workflow_id={workflow_id}"
)
return
# Start pipeline connection in background task
asyncio.create_task(
self._handle_stasis_start(
channel_id, channel_state, workflow_run_id, workflow_id, user_id
channel_id, channel_state, workflow_run_id, workflow_id
)
)
@ -448,7 +447,6 @@ class ARIConnection:
async def _create_external_media(
self,
workflow_id: str,
user_id: str,
workflow_run_id: str,
channel_id: Optional[str] = None,
) -> str:
@ -464,10 +462,10 @@ class ARIConnection:
the POST and avoid racing against the StasisStart event.
"""
# v() appends URI query params to the websocket_client.conf URL
# e.g. wss://api.dograh.com/ws/ari?workflow_id=1&user_id=2&workflow_run_id=3
# e.g. wss://api.dograh.com/ws/ari?workflow_id=1&organization_id=2&workflow_run_id=3
transport_data = (
f"v(workflow_id={workflow_id},"
f"user_id={user_id},"
f"organization_id={self.organization_id},"
f"workflow_run_id={workflow_run_id})"
)
@ -646,7 +644,6 @@ class ARIConnection:
channel_state,
str(workflow_run.id),
str(inbound_workflow_id),
str(user_id),
)
except Exception as e:
if workflow_run:
@ -668,7 +665,6 @@ class ARIConnection:
channel_state: str,
workflow_run_id: str,
workflow_id: str,
user_id: str,
):
"""Set up external media for a caller channel that has entered Stasis.
@ -712,7 +708,6 @@ class ARIConnection:
# 3. Create the ext media channel with the id we just registered.
created_id = await self._create_external_media(
workflow_id,
user_id,
workflow_run_id,
channel_id=ext_channel_id,
)

View file

@ -150,14 +150,15 @@ class TelephonyProvider(ABC):
@abstractmethod
async def get_webhook_response(
self, workflow_id: int, user_id: int, workflow_run_id: int
self, workflow_id: int, organization_id: int, workflow_run_id: int
) -> str:
"""
Generate the initial webhook response for starting a call session.
Args:
workflow_id: The workflow ID
user_id: The user ID
organization_id: The organization owning the workflow; providers
embed it in the media websocket URL they hand back
workflow_run_id: The workflow run ID
Returns:
@ -223,7 +224,7 @@ class TelephonyProvider(ABC):
self,
websocket: "WebSocket",
workflow_id: int,
user_id: int,
organization_id: int,
workflow_run_id: int,
) -> None:
"""
@ -232,10 +233,14 @@ class TelephonyProvider(ABC):
This method encapsulates all provider-specific WebSocket handshake and
message routing logic, keeping the main websocket endpoint clean.
``organization_id`` is the tenant that every workflow/run lookup must be
scoped by. The workflow owner is deliberately not passed in derive it
from the workflow row where it's needed for attribution.
Args:
websocket: The WebSocket connection
workflow_id: The workflow ID
user_id: The user ID
organization_id: The organization owning the workflow and run
workflow_run_id: The workflow run ID
"""
pass
@ -355,7 +360,6 @@ class TelephonyProvider(ABC):
*,
organization_id: int,
workflow_id: int,
user_id: int,
workflow_run_id: int,
params: Dict[str, str],
) -> None:

View file

@ -99,7 +99,6 @@ class ARIProvider(TelephonyProvider):
[
f"workflow_run_id={workflow_run_id}",
f"workflow_id={kwargs.get('workflow_id', '')}",
f"user_id={kwargs.get('user_id', '')}",
],
)
),
@ -179,7 +178,7 @@ class ARIProvider(TelephonyProvider):
return True
async def get_webhook_response(
self, workflow_id: int, user_id: int, workflow_run_id: int
self, workflow_id: int, organization_id: int, workflow_run_id: int
) -> str:
"""ARI does not use webhook responses - call control is via REST API."""
logger.warning(
@ -241,7 +240,7 @@ class ARIProvider(TelephonyProvider):
self,
websocket: "WebSocket",
workflow_id: int,
user_id: int,
organization_id: int,
workflow_run_id: int,
) -> None:
"""
@ -253,7 +252,9 @@ class ARIProvider(TelephonyProvider):
from api.services.pipecat.run_pipeline import run_pipeline_telephony
# Get channel_id from workflow run context
workflow_run = await db_client.get_workflow_run(workflow_run_id, user_id)
workflow_run = await db_client.get_workflow_run(
workflow_run_id, organization_id=organization_id
)
channel_id = ""
if workflow_run and workflow_run.gathered_context:
channel_id = workflow_run.gathered_context.get("call_id", "")
@ -267,7 +268,7 @@ class ARIProvider(TelephonyProvider):
provider_name=self.PROVIDER_NAME,
workflow_id=workflow_id,
workflow_run_id=workflow_run_id,
user_id=user_id,
organization_id=organization_id,
call_id=channel_id,
transport_kwargs={"channel_id": channel_id},
)

View file

@ -120,7 +120,10 @@ class CloudonixProvider(TelephonyProvider):
logger.info(
f"Selected phone number {from_number} for outbound call to {to_number}"
)
workflow_id, user_id = kwargs["workflow_id"], kwargs["user_id"]
workflow_id, organization_id = (
kwargs["workflow_id"],
kwargs["organization_id"],
)
# Prepare call data using Cloudonix callObject schema
# Note: 'caller-id' is REQUIRED by Cloudonix API
@ -130,7 +133,7 @@ class CloudonixProvider(TelephonyProvider):
"cxml": f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Connect>
<Stream url="{wss_backend_endpoint}/api/v1/telephony/ws/{workflow_id}/{user_id}/{workflow_run_id}"></Stream>
<Stream url="{wss_backend_endpoint}/api/v1/telephony/ws/{workflow_id}/{organization_id}/{workflow_run_id}"></Stream>
</Connect>
<Pause length="40"/>
</Response>""",
@ -408,7 +411,7 @@ class CloudonixProvider(TelephonyProvider):
}
async def get_webhook_response(
self, workflow_id: int, user_id: int, workflow_run_id: int
self, workflow_id: int, organization_id: int, workflow_run_id: int
) -> str:
"""
Dummy implementation - Cloudonix doesn't use webhook responses.
@ -430,7 +433,7 @@ class CloudonixProvider(TelephonyProvider):
self,
websocket: "WebSocket",
workflow_id: int,
user_id: int,
organization_id: int,
workflow_run_id: int,
) -> None:
"""
@ -502,7 +505,7 @@ class CloudonixProvider(TelephonyProvider):
provider_name=self.PROVIDER_NAME,
workflow_id=workflow_id,
workflow_run_id=workflow_run_id,
user_id=user_id,
organization_id=organization_id,
call_id=call_id,
transport_kwargs={"call_id": call_id, "stream_sid": stream_sid},
)
@ -517,7 +520,6 @@ class CloudonixProvider(TelephonyProvider):
*,
organization_id: int,
workflow_id: int,
user_id: int,
workflow_run_id: int,
params: Dict[str, str],
) -> None:
@ -671,7 +673,7 @@ class CloudonixProvider(TelephonyProvider):
provider_name=self.PROVIDER_NAME,
workflow_id=workflow_id,
workflow_run_id=workflow_run_id,
user_id=user_id,
organization_id=organization_id,
call_id=call_session,
transport_kwargs={
"call_id": call_session,

View file

@ -238,13 +238,13 @@ class PlivoProvider(TelephonyProvider):
return any(hmac.compare_digest(computed, candidate) for candidate in candidates)
async def get_webhook_response(
self, workflow_id: int, user_id: int, workflow_run_id: int
self, workflow_id: int, organization_id: int, workflow_run_id: int
) -> str:
_, wss_backend_endpoint = await get_backend_endpoints()
return f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Stream bidirectional="true" keepCallAlive="true" contentType="audio/x-mulaw;rate=8000">{wss_backend_endpoint}/api/v1/telephony/ws/{workflow_id}/{user_id}/{workflow_run_id}</Stream>
<Stream bidirectional="true" keepCallAlive="true" contentType="audio/x-mulaw;rate=8000">{wss_backend_endpoint}/api/v1/telephony/ws/{workflow_id}/{organization_id}/{workflow_run_id}</Stream>
</Response>"""
async def get_call_cost(self, call_id: str) -> Dict[str, Any]:
@ -309,7 +309,7 @@ class PlivoProvider(TelephonyProvider):
self,
websocket: "WebSocket",
workflow_id: int,
user_id: int,
organization_id: int,
workflow_run_id: int,
) -> None:
from api.services.pipecat.run_pipeline import run_pipeline_telephony
@ -348,7 +348,7 @@ class PlivoProvider(TelephonyProvider):
provider_name=self.PROVIDER_NAME,
workflow_id=workflow_id,
workflow_run_id=workflow_run_id,
user_id=user_id,
organization_id=organization_id,
call_id=call_id,
transport_kwargs={"stream_id": stream_id, "call_id": call_id},
)

View file

@ -74,7 +74,6 @@ async def _handle_plivo_status_callback(
@router.post("/plivo-xml", include_in_schema=False)
async def handle_plivo_xml_webhook(
workflow_id: int,
user_id: int,
workflow_run_id: int,
organization_id: int,
request: Request,
@ -110,7 +109,7 @@ async def handle_plivo_xml_webhook(
)
response_content = await provider.get_webhook_response(
workflow_id, user_id, workflow_run_id
workflow_id, organization_id, workflow_run_id
)
return HTMLResponse(content=response_content, media_type="application/xml")

View file

@ -101,10 +101,10 @@ class TelnyxProvider(TelephonyProvider):
# Build the WebSocket stream URL for inline audio streaming
workflow_id = kwargs.get("workflow_id")
user_id = kwargs.get("user_id")
organization_id = kwargs.get("organization_id")
stream_url = (
f"{wss_backend_endpoint}/api/v1/telephony/ws"
f"/{workflow_id}/{user_id}/{workflow_run_id}"
f"/{workflow_id}/{organization_id}/{workflow_run_id}"
)
# Build the webhook URL for status callbacks
@ -267,7 +267,7 @@ class TelnyxProvider(TelephonyProvider):
return False
async def get_webhook_response(
self, workflow_id: int, user_id: int, workflow_run_id: int
self, workflow_id: int, organization_id: int, workflow_run_id: int
) -> str:
"""Not used for Telnyx — streaming is inline with the dial request."""
return ""
@ -338,7 +338,7 @@ class TelnyxProvider(TelephonyProvider):
self,
websocket: "WebSocket",
workflow_id: int,
user_id: int,
organization_id: int,
workflow_run_id: int,
) -> None:
"""Handle Telnyx WebSocket connection for real-time audio.
@ -406,7 +406,7 @@ class TelnyxProvider(TelephonyProvider):
provider_name=self.PROVIDER_NAME,
workflow_id=workflow_id,
workflow_run_id=workflow_run_id,
user_id=user_id,
organization_id=organization_id,
call_id=call_control_id,
transport_kwargs={
"stream_id": stream_id,

View file

@ -166,7 +166,7 @@ class TwilioProvider(TelephonyProvider):
return validator.validate(url, params, signature)
async def get_webhook_response(
self, workflow_id: int, user_id: int, workflow_run_id: int
self, workflow_id: int, organization_id: int, workflow_run_id: int
) -> str:
"""
Generate TwiML response for starting a call session.
@ -176,7 +176,7 @@ class TwilioProvider(TelephonyProvider):
twiml_content = f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Connect>
<Stream url="{wss_backend_endpoint}/api/v1/telephony/ws/{workflow_id}/{user_id}/{workflow_run_id}"></Stream>
<Stream url="{wss_backend_endpoint}/api/v1/telephony/ws/{workflow_id}/{organization_id}/{workflow_run_id}"></Stream>
</Connect>
<Pause length="40"/>
</Response>"""
@ -274,7 +274,7 @@ class TwilioProvider(TelephonyProvider):
self,
websocket: "WebSocket",
workflow_id: int,
user_id: int,
organization_id: int,
workflow_run_id: int,
) -> None:
"""
@ -325,7 +325,7 @@ class TwilioProvider(TelephonyProvider):
provider_name=self.PROVIDER_NAME,
workflow_id=workflow_id,
workflow_run_id=workflow_run_id,
user_id=user_id,
organization_id=organization_id,
call_id=call_sid,
transport_kwargs={"stream_sid": stream_sid, "call_sid": call_sid},
)

View file

@ -47,7 +47,6 @@ async def _persist_amd_result_if_present(
@router.post("/twiml", include_in_schema=False)
async def handle_twiml_webhook(
workflow_id: int,
user_id: int,
workflow_run_id: int,
organization_id: int,
request: Request,
@ -79,7 +78,7 @@ async def handle_twiml_webhook(
)
response_content = await provider.get_webhook_response(
workflow_id, user_id, workflow_run_id
workflow_id, organization_id, workflow_run_id
)
return HTMLResponse(content=response_content, media_type="application/xml")

View file

@ -255,7 +255,7 @@ class VobizProvider(TelephonyProvider):
return is_valid
async def get_webhook_response(
self, workflow_id: int, user_id: int, workflow_run_id: int
self, workflow_id: int, organization_id: int, workflow_run_id: int
) -> str:
"""
Generate Vobiz XML response for starting a call session.
@ -269,7 +269,7 @@ class VobizProvider(TelephonyProvider):
vobiz_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Stream bidirectional="true" keepCallAlive="true" contentType="audio/x-mulaw;rate=8000">{wss_backend_endpoint}/api/v1/telephony/ws/{workflow_id}/{user_id}/{workflow_run_id}</Stream>
<Stream bidirectional="true" keepCallAlive="true" contentType="audio/x-mulaw;rate=8000">{wss_backend_endpoint}/api/v1/telephony/ws/{workflow_id}/{organization_id}/{workflow_run_id}</Stream>
</Response>"""
return vobiz_xml
@ -350,7 +350,7 @@ class VobizProvider(TelephonyProvider):
self,
websocket: "WebSocket",
workflow_id: int,
user_id: int,
organization_id: int,
workflow_run_id: int,
) -> None:
"""
@ -394,7 +394,7 @@ class VobizProvider(TelephonyProvider):
provider_name=self.PROVIDER_NAME,
workflow_id=workflow_id,
workflow_run_id=workflow_run_id,
user_id=user_id,
organization_id=organization_id,
call_id=call_id,
transport_kwargs={"stream_id": stream_id, "call_id": call_id},
)

View file

@ -54,7 +54,7 @@ async def _verify_vobiz_callback(
@router.post("/vobiz-xml", include_in_schema=False)
async def handle_vobiz_xml_webhook(
workflow_id: int, user_id: int, workflow_run_id: int, organization_id: int
workflow_id: int, workflow_run_id: int, organization_id: int
):
"""
Handle initial webhook from Vobiz when call is answered.
@ -65,7 +65,7 @@ async def handle_vobiz_xml_webhook(
set_current_run_id(workflow_run_id)
logger.info(
f"[run {workflow_run_id}] Vobiz XML webhook called - "
f"workflow_id={workflow_id}, user_id={user_id}, org_id={organization_id}"
f"workflow_id={workflow_id}, org_id={organization_id}"
)
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
@ -74,7 +74,7 @@ async def handle_vobiz_xml_webhook(
logger.debug(f"[run {workflow_run_id}] Using provider: {provider.PROVIDER_NAME}")
response_content = await provider.get_webhook_response(
workflow_id, user_id, workflow_run_id
workflow_id, organization_id, workflow_run_id
)
logger.debug(

View file

@ -207,7 +207,7 @@ class VonageProvider(TelephonyProvider):
return False
async def get_webhook_response(
self, workflow_id: int, user_id: int, workflow_run_id: int
self, workflow_id: int, organization_id: int, workflow_run_id: int
) -> str:
"""
Generate NCCO response for starting a call session.
@ -222,7 +222,7 @@ class VonageProvider(TelephonyProvider):
"endpoint": [
{
"type": "websocket",
"uri": f"{wss_backend_endpoint}/api/v1/telephony/ws/{workflow_id}/{user_id}/{workflow_run_id}",
"uri": f"{wss_backend_endpoint}/api/v1/telephony/ws/{workflow_id}/{organization_id}/{workflow_run_id}",
"content-type": "audio/l16;rate=16000", # 16kHz Linear PCM
"headers": {},
}
@ -323,7 +323,7 @@ class VonageProvider(TelephonyProvider):
self,
websocket: "WebSocket",
workflow_id: int,
user_id: int,
organization_id: int,
workflow_run_id: int,
) -> None:
"""
@ -338,14 +338,17 @@ class VonageProvider(TelephonyProvider):
try:
# Get workflow run to extract call UUID
workflow_run = await db_client.get_workflow_run(workflow_run_id)
workflow_run = await db_client.get_workflow_run(
workflow_run_id, organization_id=organization_id
)
if not workflow_run:
logger.error(f"Workflow run {workflow_run_id} not found")
await websocket.close(code=4404, reason="Workflow run not found")
return
# Get workflow for organization info
workflow = await db_client.get_workflow(workflow_id, user_id)
workflow = await db_client.get_workflow(
workflow_id, organization_id=organization_id
)
if not workflow:
logger.error(f"Workflow {workflow_id} not found")
await websocket.close(code=4404, reason="Workflow not found")
@ -392,7 +395,7 @@ class VonageProvider(TelephonyProvider):
provider_name=self.PROVIDER_NAME,
workflow_id=workflow_id,
workflow_run_id=workflow_run_id,
user_id=user_id,
organization_id=organization_id,
call_id=call_uuid,
transport_kwargs={"call_uuid": call_uuid},
)

View file

@ -5,7 +5,6 @@ provider registry — see ProviderSpec.router.
"""
import json
from typing import Optional
from fastapi import APIRouter, HTTPException, Request
from loguru import logger
@ -20,9 +19,8 @@ router = APIRouter()
@router.get("/ncco", include_in_schema=False)
async def handle_ncco_webhook(
workflow_id: int,
user_id: int,
workflow_run_id: int,
organization_id: Optional[int] = None,
organization_id: int,
):
"""Handle NCCO (Nexmo Call Control Objects) webhook for Vonage.
@ -30,12 +28,10 @@ async def handle_ncco_webhook(
"""
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
provider = await get_telephony_provider_for_run(
workflow_run, organization_id or user_id
)
provider = await get_telephony_provider_for_run(workflow_run, organization_id)
response_content = await provider.get_webhook_response(
workflow_id, user_id, workflow_run_id
workflow_id, organization_id, workflow_run_id
)
return json.loads(response_content)