fix: fix org scoped access for resources (#517)

* fix: fix org scoped access for resources

* Fix auth and config validation regressions

* fix: track org config validation timestamp

* fix: backfill org model configuration v2 from legacy user rows

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: align config tests with org-level v2 resolution

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: helm example values tweaks

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Abhishek 2026-07-09 23:04:33 +05:30 committed by GitHub
parent 041c31a613
commit fb4038a969
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 3531 additions and 517 deletions

View file

@ -92,6 +92,7 @@ async def agent_stream_websocket(
quota_result = await authorize_workflow_run_start(
workflow_id=workflow.id,
organization_id=workflow.organization_id,
workflow_run_id=workflow_run.id,
)
if not quota_result.has_quota:

View file

@ -351,9 +351,12 @@ async def create_campaign(
) -> CampaignResponse:
"""Create a new campaign"""
# Verify workflow exists and belongs to organization
workflow_name = await db_client.get_workflow_name(request.workflow_id, user.id)
if not workflow_name:
workflow = await db_client.get_workflow(
request.workflow_id, organization_id=user.selected_organization_id
)
if not workflow:
raise HTTPException(status_code=404, detail="Workflow not found")
workflow_name = workflow.name
# Validate source data (phone_number column and format)
sync_service = get_sync_service(request.source_type)
@ -364,9 +367,6 @@ async def create_campaign(
raise HTTPException(status_code=400, detail=validation_result.error.message)
# Validate template variables against source data columns
workflow = await db_client.get_workflow(
request.workflow_id, organization_id=user.selected_organization_id
)
if workflow:
from api.services.workflow.dto import ReactFlowDTO
from api.services.workflow.workflow_graph import WorkflowGraph
@ -512,7 +512,9 @@ async def get_campaign(
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
workflow_name = await db_client.get_workflow_name(
campaign.workflow_id, organization_id=user.selected_organization_id
)
executed, total = await _get_campaign_stats(campaign.id)
cfg_name = await _get_telephony_configuration_name(
@ -552,6 +554,7 @@ async def start_campaign(
# model_overrides so we evaluate the keys this campaign will use).
quota_result = await authorize_workflow_run_start(
workflow_id=campaign.workflow_id,
organization_id=user.selected_organization_id,
actor_user=user,
)
if not quota_result.has_quota:
@ -565,7 +568,9 @@ async def start_campaign(
# Get updated campaign
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
workflow_name = await db_client.get_workflow_name(
campaign.workflow_id, organization_id=user.selected_organization_id
)
executed, total = await _get_campaign_stats(campaign.id)
cfg_name = await _get_telephony_configuration_name(
@ -599,7 +604,9 @@ async def pause_campaign(
# Get updated campaign
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
workflow_name = await db_client.get_workflow_name(
campaign.workflow_id, organization_id=user.selected_organization_id
)
executed, total = await _get_campaign_stats(campaign.id)
cfg_name = await _get_telephony_configuration_name(
@ -669,7 +676,9 @@ async def update_campaign(
# Re-fetch to return updated data
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
workflow_name = await db_client.get_workflow_name(
campaign.workflow_id, organization_id=user.selected_organization_id
)
executed, total = await _get_campaign_stats(campaign.id)
cfg_name = await _get_telephony_configuration_name(
@ -838,7 +847,9 @@ async def redial_campaign(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
workflow_name = await db_client.get_workflow_name(child.workflow_id, user.id)
workflow_name = await db_client.get_workflow_name(
child.workflow_id, organization_id=user.selected_organization_id
)
executed, total = await _get_campaign_stats(child.id)
cfg_name = await _get_telephony_configuration_name(
child.telephony_configuration_id, user.selected_organization_id
@ -877,6 +888,7 @@ async def resume_campaign(
# model_overrides so we evaluate the keys this campaign will use).
quota_result = await authorize_workflow_run_start(
workflow_id=campaign.workflow_id,
organization_id=user.selected_organization_id,
actor_user=user,
)
if not quota_result.has_quota:
@ -890,7 +902,9 @@ async def resume_campaign(
# Get updated campaign
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
workflow_name = await db_client.get_workflow_name(
campaign.workflow_id, organization_id=user.selected_organization_id
)
executed, total = await _get_campaign_stats(campaign.id)
cfg_name = await _get_telephony_configuration_name(

View file

@ -375,9 +375,8 @@ async def search_chunks(
)
from api.services.gen_ai import build_embedding_service
# Try to get user's embeddings configuration
# Try to get the organization's embeddings configuration
resolved_config = await get_resolved_ai_model_configuration(
user_id=user.id,
organization_id=user.selected_organization_id,
)
effective_config = resolved_config.effective

View file

@ -243,7 +243,6 @@ async def _model_configuration_v2_response(
configuration: OrganizationAIModelConfigurationV2 | None = None,
) -> OrganizationAIModelConfigurationResponse:
resolved = await get_resolved_ai_model_configuration(
user_id=user.id,
organization_id=user.selected_organization_id,
)
raw_configuration = (

View file

@ -512,7 +512,6 @@ async def get_daily_usage_breakdown(
start_date,
end_date,
org.price_per_second_usd,
user_id=user.id,
)
return breakdown

View file

@ -285,6 +285,7 @@ async def _execute_resolved_target(
# the MPS correlation id before the provider starts the call.
quota_result = await authorize_workflow_run_start(
workflow_id=target.workflow.id,
organization_id=target.organization_id,
workflow_run_id=workflow_run.id,
)
if not quota_result.has_quota:

View file

@ -309,11 +309,11 @@ async def initialize_embed_session(
workflow_id=embed_token.workflow_id,
mode=WorkflowRunMode.SMALLWEBRTC.value,
user_id=embed_token.created_by, # Use token creator as run owner
organization_id=embed_token.organization_id,
initial_context={
**(init_request.context_variables or {}),
"provider": WorkflowRunMode.SMALLWEBRTC.value,
},
organization_id=embed_token.organization_id,
)
except Exception as e:
logger.error(f"Failed to create workflow run: {e}")

View file

@ -223,6 +223,7 @@ async def initiate_call(
# the MPS correlation id before initiating the call.
quota_result = await authorize_workflow_run_start(
workflow_id=workflow.id,
organization_id=user.selected_organization_id,
workflow_run_id=workflow_run_id,
actor_user=user,
)
@ -450,11 +451,11 @@ async def _validate_inbound_request(
async def _create_inbound_workflow_run(
workflow_id: int,
user_id: int,
organization_id: int,
provider: str,
normalized_data,
telephony_configuration_id: int,
from_phone_number_id: Optional[int] = None,
organization_id: int | None = None,
) -> int:
"""Create workflow run for inbound call and return run ID"""
call_id = normalized_data.call_id
@ -593,6 +594,20 @@ async def _handle_telephony_websocket(
logger.error(f"Workflow {workflow_id} not found")
await websocket.close(code=4404, reason="Workflow not found")
return
if workflow_run.workflow_id != workflow.id:
logger.error(
f"Workflow run {workflow_run_id} belongs to workflow "
f"{workflow_run.workflow_id}, not {workflow.id}"
)
await websocket.close(code=4400, reason="workflow_run_workflow_mismatch")
return
if workflow.user_id != user_id:
logger.error(
f"Telephony websocket user mismatch for workflow {workflow.id}: "
f"got {user_id}, expected {workflow.user_id}"
)
await websocket.close(code=4400, reason="workflow_user_mismatch")
return
# Check workflow run state - only allow 'initialized' state
if workflow_run.state != WorkflowRunState.INITIALIZED.value:
@ -814,16 +829,17 @@ async def handle_inbound_run(request: Request):
workflow_run_id = await _create_inbound_workflow_run(
workflow_id,
user_id,
config.organization_id,
provider_class.PROVIDER_NAME,
normalized_data,
telephony_configuration_id=telephony_configuration_id,
from_phone_number_id=phone_row.id,
organization_id=config.organization_id,
)
await call_concurrency.bind_workflow_run(concurrency_slot, workflow_run_id)
quota_result = await authorize_workflow_run_start(
workflow_id=workflow_id,
organization_id=config.organization_id,
workflow_run_id=workflow_run_id,
)
if not quota_result.has_quota:
@ -977,18 +993,19 @@ async def handle_inbound_telephony(
workflow_run_id = await _create_inbound_workflow_run(
workflow_id,
workflow_context["user_id"],
organization_id,
workflow_context["provider"],
normalized_data,
telephony_configuration_id=workflow_context[
"telephony_configuration_id"
],
from_phone_number_id=workflow_context.get("from_phone_number_id"),
organization_id=organization_id,
)
await call_concurrency.bind_workflow_run(concurrency_slot, workflow_run_id)
quota_result = await authorize_workflow_run_start(
workflow_id=workflow_id,
organization_id=organization_id,
workflow_run_id=workflow_run_id,
)
if not quota_result.has_quota:

View file

@ -16,7 +16,10 @@ from api.schemas.workflow_configurations import (
)
from api.services.auth.depends import get_user
from api.services.configuration.ai_model_configuration import (
convert_legacy_ai_model_configuration_to_v2,
get_resolved_ai_model_configuration,
update_organization_ai_model_configuration_last_validated_at,
upsert_organization_ai_model_configuration_v2,
)
from api.services.configuration.check_validity import (
APIKeyStatusResponse,
@ -105,12 +108,29 @@ class UserConfigurationRequestResponseSchema(BaseModel):
organization_pricing: dict[str, Union[float, str, bool]] | None = None
def _is_validation_cache_stale(
last_validated_at: datetime | None,
validity_ttl_seconds: int,
) -> bool:
if last_validated_at is None:
return True
has_timezone = (
last_validated_at.tzinfo is not None
and last_validated_at.utcoffset() is not None
)
if has_timezone:
now = datetime.now(last_validated_at.tzinfo)
else:
now = datetime.now()
return last_validated_at < now - timedelta(seconds=validity_ttl_seconds)
@router.get("/configurations/user")
async def get_user_configurations(
user: UserModel = Depends(get_user),
) -> UserConfigurationRequestResponseSchema:
resolved_config = await get_resolved_ai_model_configuration(
user_id=user.id,
organization_id=user.selected_organization_id,
)
masked_config = mask_user_config(resolved_config.effective)
@ -139,7 +159,11 @@ async def update_user_configurations(
request: UserConfigurationRequestResponseSchema,
user: UserModel = Depends(get_user),
) -> UserConfigurationRequestResponseSchema:
existing_config = await db_client.get_user_configurations(user.id)
existing_config = (
await get_resolved_ai_model_configuration(
organization_id=user.selected_organization_id,
)
).effective
incoming_dict = request.model_dump(exclude_none=True)
@ -152,6 +176,9 @@ async def update_user_configurations(
}
if incoming_dict:
if not user.selected_organization_id:
raise HTTPException(status_code=400, detail="No organization selected")
# Merge via helper
try:
user_configurations = merge_user_configurations(
@ -175,8 +202,16 @@ async def update_user_configurations(
except ValueError as e:
raise HTTPException(status_code=422, detail=e.args[0])
user_configurations = await db_client.update_user_configuration(
user.id, user_configurations
try:
organization_configuration = convert_legacy_ai_model_configuration_to_v2(
user_configurations
)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
await upsert_organization_ai_model_configuration_v2(
user.selected_organization_id,
organization_configuration,
)
else:
user_configurations = existing_config
@ -235,15 +270,13 @@ async def validate_user_configurations(
user: UserModel = Depends(get_user),
) -> APIKeyStatusResponse:
resolved_config = await get_resolved_ai_model_configuration(
user_id=user.id,
organization_id=user.selected_organization_id,
)
configurations = resolved_config.effective
if (
configurations.last_validated_at
and configurations.last_validated_at
< datetime.now() - timedelta(seconds=validity_ttl_seconds)
if _is_validation_cache_stale(
configurations.last_validated_at,
validity_ttl_seconds,
):
validator = UserConfigurationValidator()
try:
@ -252,7 +285,13 @@ async def validate_user_configurations(
organization_id=user.selected_organization_id,
created_by=user.provider_id,
)
await db_client.update_user_configuration_last_validated_at(user.id)
if (
resolved_config.source == "organization_v2"
and user.selected_organization_id is not None
):
await update_organization_ai_model_configuration_last_validated_at(
user.selected_organization_id
)
return status
except ValueError as e:
raise HTTPException(status_code=422, detail=e.args[0])

View file

@ -326,6 +326,7 @@ class SignalingManager:
workflow_id: int,
workflow_run_id: int,
user: UserModel,
organization_id: int,
enforce_call_concurrency: bool = False,
call_concurrency_source: str = "webrtc",
):
@ -344,6 +345,7 @@ class SignalingManager:
workflow_id,
workflow_run_id,
user,
organization_id,
connection_key,
enforce_call_concurrency,
call_concurrency_source,
@ -387,6 +389,7 @@ class SignalingManager:
workflow_id: int,
workflow_run_id: int,
user: UserModel,
organization_id: int,
connection_key: str,
enforce_call_concurrency: bool,
call_concurrency_source: str = "webrtc",
@ -402,6 +405,7 @@ class SignalingManager:
workflow_id,
workflow_run_id,
user,
organization_id,
connection_key,
enforce_call_concurrency,
call_concurrency_source,
@ -418,6 +422,7 @@ class SignalingManager:
workflow_id: int,
workflow_run_id: int,
user: UserModel,
organization_id: int,
connection_key: str,
enforce_call_concurrency: bool,
call_concurrency_source: str = "webrtc",
@ -440,14 +445,13 @@ class SignalingManager:
# Set run context for logging and tracing. org_id must be set before
# pc.initialize() so that aiortc's internal tasks inherit it.
set_current_run_id(workflow_run_id)
org_id = await db_client.get_workflow_organization_id(workflow_id)
if org_id:
set_current_org_id(org_id)
set_current_org_id(organization_id)
# Check Dograh quota before initiating the call (apply per-workflow
# model_overrides so we evaluate the keys this workflow will use).
quota_result = await authorize_workflow_run_start(
workflow_id=workflow_id,
organization_id=organization_id,
workflow_run_id=workflow_run_id,
actor_user=user,
)
@ -497,21 +501,9 @@ class SignalingManager:
pipeline_started = False
pc = None
if enforce_call_concurrency:
if org_id is None:
await ws.send_json(
{
"type": "error",
"payload": {
"error_type": "organization_not_found",
"message": "Workflow organization not found",
},
}
)
return
try:
concurrency_slot = await call_concurrency.acquire_org_slot(
org_id,
organization_id,
source=call_concurrency_source,
timeout=0,
)
@ -592,6 +584,7 @@ class SignalingManager:
user.id,
call_context_vars,
user_provider_id=str(user.provider_id),
organization_id=organization_id,
)
)
pipeline_started = True
@ -716,26 +709,31 @@ async def signaling_websocket(
user: UserModel = Depends(get_user_ws),
):
"""WebSocket endpoint for WebRTC signaling with ICE trickling."""
workflow_run = await db_client.get_workflow_run(workflow_run_id, user.id)
if not workflow_run:
logger.warning(f"workflow run {workflow_run_id} not found for user {user.id}")
raise HTTPException(status_code=400, detail="Bad workflow_run_id")
if not user.selected_organization_id:
raise HTTPException(status_code=400, detail="No organization selected")
# The URL's workflow_id drives org resolution, quota, and concurrency
# accounting downstream — reject a workflow_id that doesn't own this run
# so a caller can't charge their call to an unrelated workflow/org.
if workflow_run.workflow_id != workflow_id:
workflow_run = await db_client.get_workflow_run(
workflow_run_id, organization_id=user.selected_organization_id
)
if not workflow_run:
logger.warning(
f"workflow run {workflow_run_id} does not belong to workflow "
f"{workflow_id} (belongs to {workflow_run.workflow_id})"
f"workflow run {workflow_run_id} not found for org "
f"{user.selected_organization_id}"
)
raise HTTPException(status_code=400, detail="Bad workflow_run_id")
if workflow_run.workflow_id != workflow_id:
logger.warning(
f"workflow run {workflow_run_id} belongs to workflow "
f"{workflow_run.workflow_id}, not {workflow_id}"
)
raise HTTPException(status_code=400, detail="workflow_run_workflow_mismatch")
await signaling_manager.handle_websocket(
websocket,
workflow_id,
workflow_run_id,
user,
user.selected_organization_id,
enforce_call_concurrency=True,
call_concurrency_source="webrtc",
)
@ -771,6 +769,17 @@ async def public_signaling_websocket(
await websocket.close(code=1008, reason="Invalid embed token")
return
workflow_run = await db_client.get_workflow_run(
embed_session.workflow_run_id,
organization_id=embed_token.organization_id,
)
if not workflow_run:
await websocket.close(code=1008, reason="Invalid workflow run")
return
if workflow_run.workflow_id != embed_token.workflow_id:
await websocket.close(code=1008, reason="workflow_run_workflow_mismatch")
return
# Enforce the embed token's allowed-domain policy on the public signaling
# path, mirroring the HTTP embed endpoints (issue #330). Without this a
# leaked or replayed session token could attach from an arbitrary origin.
@ -798,6 +807,7 @@ async def public_signaling_websocket(
embed_token.workflow_id,
embed_session.workflow_run_id,
user,
embed_token.organization_id,
enforce_call_concurrency=True,
call_concurrency_source="public_embed",
)

View file

@ -1090,7 +1090,6 @@ async def update_workflow(
)
if existing_v2_override_config is None:
resolved_config = await get_resolved_ai_model_configuration(
user_id=user.id,
organization_id=user.selected_organization_id,
)
v2_override = merge_ai_model_configuration_v2_secrets(
@ -1133,7 +1132,6 @@ async def update_workflow(
existing_configs,
)
resolved_config = await get_resolved_ai_model_configuration(
user_id=user.id,
organization_id=user.selected_organization_id,
)
effective_config = resolved_config.effective

View file

@ -103,6 +103,7 @@ async def _ensure_text_chat_quota(
) -> None:
quota_result = await authorize_workflow_run_start(
workflow_id=workflow_id,
organization_id=user.selected_organization_id,
workflow_run_id=workflow_run_id,
actor_user=user,
)