fix: fix org scoped access for resources

This commit is contained in:
Abhishek Kumar 2026-07-09 16:39:34 +05:30
parent f3bcf24370
commit e4b53f78e9
47 changed files with 2667 additions and 400 deletions

View file

@ -94,6 +94,7 @@ def test_trigger_route_executes_as_workflow_owner():
assert response.status_code == 200
quota_mock.assert_awaited_once_with(
workflow_id=workflow.id,
organization_id=workflow.organization_id,
workflow_run_id=501,
)
mock_db.get_workflow.assert_awaited_once_with(workflow.id, organization_id=11)

View file

@ -25,6 +25,7 @@ _ACTIVE_TOKEN = SimpleNamespace(
expires_at=None,
allowed_domains=[],
workflow_id=1,
organization_id=11,
created_by=7,
usage_limit=None,
usage_count=0,
@ -37,6 +38,7 @@ _RESTRICTED_TOKEN = SimpleNamespace(
expires_at=None,
allowed_domains=["allowed.example.com"],
workflow_id=2,
organization_id=11,
created_by=7,
usage_limit=None,
usage_count=0,
@ -49,6 +51,7 @@ _LOCALHOST_TOKEN = SimpleNamespace(
expires_at=None,
allowed_domains=["localhost:3000", "localhost:3020"],
workflow_id=3,
organization_id=11,
created_by=7,
usage_limit=None,
usage_count=0,

View file

@ -9,6 +9,8 @@ from api.services.configuration.registry import ServiceProviders
from api.services.managed_model_services import MPS_CORRELATION_ID_CONTEXT_KEY
from api.services.quota_service import QuotaCheckResult
_UNSET = object()
def _dograh_config(
api_key: str = "mps_sk_12345678",
@ -58,11 +60,17 @@ def _actor():
)
def _patch_workflow_context(monkeypatch, *, workflow=None, owner=None):
def _patch_workflow_context(monkeypatch, *, workflow=_UNSET, owner=None):
workflow_value = _workflow() if workflow is _UNSET else workflow
monkeypatch.setattr(
quota_service.db_client,
"get_workflow",
AsyncMock(return_value=workflow_value),
)
monkeypatch.setattr(
quota_service.db_client,
"get_workflow_by_id",
AsyncMock(return_value=workflow or _workflow()),
AsyncMock(side_effect=AssertionError("quota must not use unscoped workflow")),
)
monkeypatch.setattr(
quota_service.db_client,
@ -103,11 +111,14 @@ async def test_authorize_workflow_run_uses_workflow_org_for_hosted_v2(
check_usage,
)
result = await quota_service.authorize_workflow_run_start(workflow_id=7)
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=42,
)
assert result.has_quota is True
quota_service.db_client.get_workflow.assert_awaited_once_with(7, organization_id=42)
get_config.assert_awaited_once_with(
user_id=123,
organization_id=42,
workflow_configurations={"model_overrides": {}},
)
@ -156,7 +167,10 @@ async def test_authorize_workflow_run_v2_insufficient_credits_prompts_billing(
check_usage,
)
result = await quota_service.authorize_workflow_run_start(workflow_id=7)
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=42,
)
assert result.has_quota is False
assert result.error_code == "insufficient_credits"
@ -189,11 +203,14 @@ async def test_authorize_workflow_run_oss_exhausted_key_blocks_run(
check_usage,
)
result = await quota_service.authorize_workflow_run_start(workflow_id=7)
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=42,
)
assert result.has_quota is False
assert result.error_code == "quota_exceeded"
assert "founders@dograh.com" in result.error_message
assert "app.dograh.com" in result.error_message
assert "/billing" not in result.error_message
check_usage.assert_awaited_once_with(api_key)
@ -247,6 +264,7 @@ async def test_authorize_workflow_run_managed_v2_stores_hosted_correlation(
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=42,
workflow_run_id=88,
)
@ -314,6 +332,7 @@ async def test_authorize_workflow_run_service_token_from_wrong_org_prompts_new_t
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=42,
workflow_run_id=88,
)
@ -383,6 +402,7 @@ async def test_authorize_workflow_run_oss_uses_key_paths_not_workflow_org(
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=42,
workflow_run_id=88,
)
@ -411,6 +431,7 @@ async def test_authorize_workflow_run_rejects_actor_not_a_member(monkeypatch):
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=42,
actor_user=SimpleNamespace(id=456, selected_organization_id=999),
)
@ -430,6 +451,7 @@ async def test_authorize_workflow_run_membership_lookup_error_fails_closed(monke
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=42,
actor_user=SimpleNamespace(id=456, selected_organization_id=42),
)
@ -464,6 +486,7 @@ async def test_authorize_workflow_run_allows_invited_member(monkeypatch):
# but is_user_member_of_organization returns True so the run should be allowed.
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=42,
actor_user=SimpleNamespace(id=456, selected_organization_id=999),
)
@ -471,16 +494,9 @@ async def test_authorize_workflow_run_allows_invited_member(monkeypatch):
@pytest.mark.asyncio
async def test_authorize_workflow_run_allows_personal_workflow_with_actor(monkeypatch):
"""Personal/legacy workflows (organization_id=None) bypass membership check."""
personal_workflow = SimpleNamespace(
id=7,
user_id=123,
organization_id=None,
workflow_configurations={"model_overrides": {}},
)
async def test_authorize_workflow_run_requires_organization_scope(monkeypatch):
monkeypatch.setattr(quota_service, "DEPLOYMENT_MODE", "saas")
_patch_workflow_context(monkeypatch, workflow=personal_workflow)
_patch_workflow_context(monkeypatch)
is_member_mock = AsyncMock()
monkeypatch.setattr(
quota_service.db_client,
@ -500,8 +516,35 @@ async def test_authorize_workflow_run_allows_personal_workflow_with_actor(monkey
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=None,
actor_user=SimpleNamespace(id=456),
)
assert result.has_quota is True
assert result.has_quota is False
assert result.error_code == "workflow_not_found"
quota_service.db_client.get_workflow.assert_not_awaited()
is_member_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_authorize_workflow_run_rejects_workflow_outside_org(monkeypatch):
monkeypatch.setattr(quota_service, "DEPLOYMENT_MODE", "saas")
_patch_workflow_context(monkeypatch, workflow=None)
is_member_mock = AsyncMock()
monkeypatch.setattr(
quota_service.db_client,
"is_user_member_of_organization",
is_member_mock,
)
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=99,
actor_user=SimpleNamespace(id=456),
)
assert result.has_quota is False
assert result.error_code == "workflow_not_found"
quota_service.db_client.get_workflow.assert_awaited_once_with(7, organization_id=99)
is_member_mock.assert_not_awaited()
quota_service.db_client.get_user_by_id.assert_not_awaited()

View file

@ -1,8 +1,8 @@
from api.services.pipecat.realtime_feedback_events import (
build_bot_text_event,
build_function_call_end_event,
build_user_transcription_event,
build_node_transition_event,
build_user_transcription_event,
realtime_feedback_event_sort_key,
stamp_realtime_feedback_event,
)

View file

@ -1,5 +1,5 @@
from types import SimpleNamespace
import re
from types import SimpleNamespace
import pytest
from pipecat.frames.frames import (

View file

@ -92,6 +92,7 @@ def test_initiate_call_executes_as_workflow_owner_for_shared_org_workflow():
assert response.status_code == 200
quota_mock.assert_awaited_once_with(
workflow_id=workflow.id,
organization_id=workflow.organization_id,
workflow_run_id=501,
actor_user=ANY,
)
@ -230,7 +231,7 @@ async def test_smallwebrtc_run_reaching_telephony_websocket_closes_without_runni
initial_context={},
gathered_context={},
)
workflow = SimpleNamespace(id=33, organization_id=11)
workflow = SimpleNamespace(id=33, organization_id=11, user_id=99)
provider_lookup = AsyncMock()
with (

View file

@ -5,8 +5,12 @@ from unittest.mock import AsyncMock, patch
import pytest
from pipecat.processors.aggregators.llm_context import LLMSpecificMessage
from api.db.models import OrganizationModel, UserModel
from api.db.models import OrganizationModel, UserModel, organization_users_association
from api.enums import OrganizationConfigurationKey
from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration
from api.services.configuration.ai_model_configuration import (
convert_legacy_ai_model_configuration_to_v2,
)
from api.services.workflow.text_chat_runner import (
_deserialize_text_chat_checkpoint_messages,
_serialize_text_chat_checkpoint_messages,
@ -84,10 +88,23 @@ async def _create_user_and_workflow(
)
async_session.add(user)
await async_session.flush()
await async_session.execute(
organization_users_association.insert().values(
user_id=user.id,
organization_id=org.id,
)
)
await db_session.update_user_configuration(
user_id=user.id,
configuration=EffectiveAIModelConfiguration.model_validate(USER_CONFIGURATION),
user_configuration = EffectiveAIModelConfiguration.model_validate(
USER_CONFIGURATION
)
await db_session.upsert_configuration(
org.id,
OrganizationConfigurationKey.MODEL_CONFIGURATION_V2.value,
convert_legacy_ai_model_configuration_to_v2(user_configuration).model_dump(
mode="json",
exclude_none=True,
),
)
workflow = await db_session.create_workflow(
@ -1079,10 +1096,23 @@ async def test_text_chat_session_creation_requires_selected_org_scope(
)
async_session.add(user)
await async_session.flush()
await async_session.execute(
organization_users_association.insert().values(
user_id=user.id,
organization_id=org_a.id,
)
)
await db_session.update_user_configuration(
user_id=user.id,
configuration=EffectiveAIModelConfiguration.model_validate(USER_CONFIGURATION),
user_configuration = EffectiveAIModelConfiguration.model_validate(
USER_CONFIGURATION
)
await db_session.upsert_configuration(
org_a.id,
OrganizationConfigurationKey.MODEL_CONFIGURATION_V2.value,
convert_legacy_ai_model_configuration_to_v2(user_configuration).model_dump(
mode="json",
exclude_none=True,
),
)
workflow = await db_session.create_workflow(

View file

@ -42,7 +42,9 @@ def test_create_xai_tts_service_uses_pipeline_compatible_audio_format(
transport_in_sample_rate=16000,
)
with patch("api.services.pipecat.service_factory.XAIHttpTTSService") as mock_service:
with patch(
"api.services.pipecat.service_factory.XAIHttpTTSService"
) as mock_service:
create_tts_service(user_config, audio_config)
assert mock_service.call_count == 1
@ -69,7 +71,9 @@ def test_create_xai_tts_service_converts_language():
transport_in_sample_rate=16000,
)
with patch("api.services.pipecat.service_factory.XAIHttpTTSService") as mock_service:
with patch(
"api.services.pipecat.service_factory.XAIHttpTTSService"
) as mock_service:
create_tts_service(user_config, audio_config)
kwargs = mock_service.call_args.kwargs
@ -91,7 +95,9 @@ def test_create_xai_tts_service_falls_back_to_english_for_unknown_language():
transport_in_sample_rate=16000,
)
with patch("api.services.pipecat.service_factory.XAIHttpTTSService") as mock_service:
with patch(
"api.services.pipecat.service_factory.XAIHttpTTSService"
) as mock_service:
create_tts_service(user_config, audio_config)
kwargs = mock_service.call_args.kwargs
@ -113,7 +119,9 @@ def test_create_xai_tts_service_preserves_auto_language():
transport_in_sample_rate=16000,
)
with patch("api.services.pipecat.service_factory.XAIHttpTTSService") as mock_service:
with patch(
"api.services.pipecat.service_factory.XAIHttpTTSService"
) as mock_service:
create_tts_service(user_config, audio_config)
kwargs = mock_service.call_args.kwargs
@ -127,25 +135,20 @@ def test_xai_is_registered_for_key_validation():
def test_xai_key_validation_accepts_valid_key():
validator = UserConfigurationValidator()
with patch(
"api.services.configuration.check_validity.httpx.get"
) as mock_get:
with patch("api.services.configuration.check_validity.httpx.get") as mock_get:
mock_get.return_value.status_code = 200
assert validator._check_xai_api_key("xai", "xai-valid-key") is True
# Validates against the TTS-scoped voices endpoint, not /v1/models.
called_url = mock_get.call_args.args[0]
assert called_url == "https://api.x.ai/v1/tts/voices"
assert (
mock_get.call_args.kwargs["headers"]["Authorization"]
== "Bearer xai-valid-key"
mock_get.call_args.kwargs["headers"]["Authorization"] == "Bearer xai-valid-key"
)
def test_xai_key_validation_rejects_bad_key():
validator = UserConfigurationValidator()
with patch(
"api.services.configuration.check_validity.httpx.get"
) as mock_get:
with patch("api.services.configuration.check_validity.httpx.get") as mock_get:
mock_get.return_value.status_code = 401
with pytest.raises(ValueError):
validator._check_xai_api_key("xai", "bad-key")
@ -153,8 +156,6 @@ def test_xai_key_validation_rejects_bad_key():
def test_xai_key_validation_allows_scoped_key_without_voice_list_access():
validator = UserConfigurationValidator()
with patch(
"api.services.configuration.check_validity.httpx.get"
) as mock_get:
with patch("api.services.configuration.check_validity.httpx.get") as mock_get:
mock_get.return_value.status_code = 403
assert validator._check_xai_api_key("xai", "tts-scoped-key") is True