mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-13 11:22:14 +02:00
fix: fix superadmin impersonation
This commit is contained in:
parent
d1339970a5
commit
e405457676
16 changed files with 593 additions and 172 deletions
|
|
@ -8,7 +8,11 @@ from pydantic import BaseModel
|
|||
from api.db import db_client
|
||||
from api.db.models import UserModel
|
||||
from api.services.auth.depends import get_superuser
|
||||
from api.services.auth.stack_auth import stackauth
|
||||
from api.services.auth.stack_auth import (
|
||||
StackAuthSessionError,
|
||||
StackAuthUserSearchError,
|
||||
stackauth,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/superuser", tags=["superuser"])
|
||||
|
||||
|
|
@ -16,12 +20,14 @@ router = APIRouter(prefix="/superuser", tags=["superuser"])
|
|||
class ImpersonateRequest(BaseModel):
|
||||
"""Request payload for superadmin impersonation.
|
||||
|
||||
Either ``provider_user_id`` **or** ``user_id`` must be supplied. If both are
|
||||
provided, ``provider_user_id`` takes precedence.
|
||||
``provider_user_id``, ``user_id``, or ``email`` may be supplied. If more
|
||||
than one is provided, ``provider_user_id`` takes precedence, followed by
|
||||
``user_id`` and then ``email``.
|
||||
"""
|
||||
|
||||
provider_user_id: str | None = None
|
||||
user_id: int | None = None
|
||||
email: str | None = None
|
||||
|
||||
|
||||
class ImpersonateResponse(BaseModel):
|
||||
|
|
@ -65,32 +71,79 @@ async def impersonate(
|
|||
to create an impersonation session.
|
||||
"""
|
||||
|
||||
provider_user_id: str | None = request.provider_user_id
|
||||
provider_user_id = (
|
||||
request.provider_user_id.strip() if request.provider_user_id else None
|
||||
) or None
|
||||
email = request.email.strip().lower() if request.email else None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Fallback: resolve provider_user_id from internal ``user_id``
|
||||
# Fallback: resolve provider_user_id from internal ``user_id`` or email.
|
||||
# ------------------------------------------------------------------
|
||||
if provider_user_id is None:
|
||||
if request.user_id is None:
|
||||
if request.user_id is not None:
|
||||
db_user = await db_client.get_user_by_id(request.user_id)
|
||||
|
||||
if db_user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User with ID {request.user_id} not found.",
|
||||
)
|
||||
|
||||
provider_user_id = db_user.provider_id
|
||||
elif email:
|
||||
db_user = await db_client.get_user_by_email(email)
|
||||
|
||||
if db_user is not None:
|
||||
provider_user_id = db_user.provider_id
|
||||
else:
|
||||
try:
|
||||
stack_users = await stackauth.find_users_by_email(email)
|
||||
except StackAuthUserSearchError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="Failed to search Stack Auth users.",
|
||||
) from exc
|
||||
|
||||
if len(stack_users) == 1 and isinstance(stack_users[0].get("id"), str):
|
||||
provider_user_id = stack_users[0]["id"]
|
||||
elif len(stack_users) > 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Multiple Stack Auth users matched that email.",
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User with email {email} not found.",
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Either 'provider_user_id' or 'user_id' must be provided.",
|
||||
detail=(
|
||||
"One of 'provider_user_id', 'user_id', or 'email' must be provided."
|
||||
),
|
||||
)
|
||||
|
||||
db_user = await db_client.get_user_by_id(request.user_id)
|
||||
|
||||
if db_user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User with ID {request.user_id} not found.",
|
||||
)
|
||||
|
||||
provider_user_id = db_user.provider_id
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Call Stack Auth to create the impersonation session
|
||||
# ------------------------------------------------------------------
|
||||
session = await stackauth.impersonate(provider_user_id)
|
||||
try:
|
||||
session = await stackauth.impersonate(provider_user_id)
|
||||
except StackAuthSessionError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="Failed to create Stack Auth impersonation session.",
|
||||
) from exc
|
||||
|
||||
if (
|
||||
not isinstance(session, dict)
|
||||
or "refresh_token" not in session
|
||||
or "access_token" not in session
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="Failed to create Stack Auth impersonation session.",
|
||||
)
|
||||
|
||||
return ImpersonateResponse(
|
||||
refresh_token=session["refresh_token"],
|
||||
|
|
|
|||
|
|
@ -235,7 +235,7 @@ class TransferCallConfig(BaseModel):
|
|||
description=(
|
||||
"Phone number, SIP endpoint, or template to transfer the call to, e.g. "
|
||||
"+1234567890, PJSIP/1234, or {{initial_context.transfer_destination}}."
|
||||
)
|
||||
),
|
||||
)
|
||||
messageType: Literal["none", "custom", "audio"] = Field(
|
||||
default="none", description="Type of message to play before transfer."
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
import os
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
|
||||
class StackAuthUserSearchError(Exception):
|
||||
"""Raised when Stack Auth user search fails unexpectedly."""
|
||||
|
||||
|
||||
class StackAuthSessionError(Exception):
|
||||
"""Raised when Stack Auth cannot create an impersonation session."""
|
||||
|
||||
|
||||
class StackAuth:
|
||||
def __init__(self):
|
||||
self.project_id = os.environ.get("STACK_AUTH_PROJECT_ID")
|
||||
|
|
@ -56,10 +65,56 @@ class StackAuth:
|
|||
"is_impersonation": True,
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(url, headers=headers, json=data) as response:
|
||||
response = await response.json()
|
||||
return response
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(url, headers=headers, json=data) as response:
|
||||
if response.status >= 400:
|
||||
raise StackAuthSessionError(
|
||||
"Stack Auth session creation failed"
|
||||
)
|
||||
|
||||
return await response.json()
|
||||
except (aiohttp.ClientError, ValueError) as exc:
|
||||
raise StackAuthSessionError("Stack Auth session creation failed") from exc
|
||||
|
||||
async def find_users_by_email(self, email: str) -> list[dict[str, Any]]:
|
||||
"""Return Stack Auth users whose primary email exactly matches."""
|
||||
normalized_email = email.strip().lower()
|
||||
url = os.environ.get("STACK_AUTH_API_URL") + "/api/v1/users"
|
||||
headers = {
|
||||
"x-stack-access-type": "server",
|
||||
"x-stack-project-id": self.project_id,
|
||||
"x-stack-secret-server-key": self.secret_server_key,
|
||||
}
|
||||
params = {
|
||||
"query": normalized_email,
|
||||
"limit": "10",
|
||||
}
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, headers=headers, params=params) as response:
|
||||
if response.status >= 400:
|
||||
raise StackAuthUserSearchError("Stack Auth user search failed")
|
||||
|
||||
payload = await response.json()
|
||||
except (aiohttp.ClientError, ValueError) as exc:
|
||||
raise StackAuthUserSearchError("Stack Auth user search failed") from exc
|
||||
|
||||
users = payload.get("items", []) if isinstance(payload, dict) else []
|
||||
if not isinstance(users, list):
|
||||
return []
|
||||
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if isinstance(user, dict)
|
||||
and self._stack_user_has_email(user, normalized_email)
|
||||
]
|
||||
|
||||
def _stack_user_has_email(self, user: dict[str, Any], email: str) -> bool:
|
||||
primary_email = user.get("primary_email")
|
||||
return isinstance(primary_email, str) and primary_email.lower() == email
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Team & user management helpers
|
||||
|
|
|
|||
|
|
@ -329,7 +329,7 @@ class CustomToolManager:
|
|||
return handler, timeout_secs
|
||||
|
||||
def _transfer_handler_timeout_secs(self, tool: Any) -> float:
|
||||
config = ((tool.definition or {}).get("config", {}) or {})
|
||||
config = (tool.definition or {}).get("config", {}) or {}
|
||||
try:
|
||||
transfer_timeout = int(config.get("timeout", 30))
|
||||
except (TypeError, ValueError):
|
||||
|
|
@ -338,9 +338,8 @@ class CustomToolManager:
|
|||
|
||||
resolver_timeout = 0.0
|
||||
resolver = config.get("resolver")
|
||||
if (
|
||||
config.get("destination_source", "static") == "dynamic"
|
||||
and isinstance(resolver, dict)
|
||||
if config.get("destination_source", "static") == "dynamic" and isinstance(
|
||||
resolver, dict
|
||||
):
|
||||
try:
|
||||
resolver_timeout = float(resolver.get("timeout_ms", 3000)) / 1000.0
|
||||
|
|
@ -602,10 +601,9 @@ class CustomToolManager:
|
|||
return
|
||||
|
||||
resolver = config.get("resolver") if isinstance(config, dict) else None
|
||||
is_dynamic_transfer = (
|
||||
config.get("destination_source", "static") == "dynamic"
|
||||
and isinstance(resolver, dict)
|
||||
)
|
||||
is_dynamic_transfer = config.get(
|
||||
"destination_source", "static"
|
||||
) == "dynamic" and isinstance(resolver, dict)
|
||||
resolver_phase_muted = False
|
||||
|
||||
def clear_transfer_setup_mute_state() -> None:
|
||||
|
|
|
|||
|
|
@ -935,7 +935,9 @@ class TestTransferResolver:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_resolver_resolves_transfer_context_destination(self):
|
||||
from api.services.workflow.tools.transfer_resolver import resolve_transfer_config
|
||||
from api.services.workflow.tools.transfer_resolver import (
|
||||
resolve_transfer_config,
|
||||
)
|
||||
|
||||
tool = MockToolModel(
|
||||
tool_uuid="transfer-tool-uuid",
|
||||
|
|
@ -1661,7 +1663,9 @@ class TestCustomToolManagerUnit:
|
|||
assert transfer_kwargs["destination"] == "+14155550123"
|
||||
assert transfer_kwargs["timeout"] == 30
|
||||
assert result_received["status"] == "transfer_failed"
|
||||
assert [call.args[0] for call in mock_engine.set_mute_pipeline.call_args_list] == [
|
||||
assert [
|
||||
call.args[0] for call in mock_engine.set_mute_pipeline.call_args_list
|
||||
] == [
|
||||
True,
|
||||
True,
|
||||
False,
|
||||
|
|
@ -1751,7 +1755,9 @@ class TestCustomToolManagerUnit:
|
|||
assert result_received["status"] == "transfer_failed"
|
||||
assert result_received["reason"] == "no_destination"
|
||||
assert mock_engine._queued_speech_mute_state == "idle"
|
||||
assert [call.args[0] for call in mock_engine.set_mute_pipeline.call_args_list] == [
|
||||
assert [
|
||||
call.args[0] for call in mock_engine.set_mute_pipeline.call_args_list
|
||||
] == [
|
||||
True,
|
||||
False,
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue