mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-25 12:01:04 +02:00
fix: fix org scoped access for resources
This commit is contained in:
parent
f3bcf24370
commit
e4b53f78e9
47 changed files with 2667 additions and 400 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from types import SimpleNamespace
|
||||
import re
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from pipecat.frames.frames import (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue