fix: fix superadmin impersonation

This commit is contained in:
Abhishek Kumar 2026-07-11 15:51:36 +05:30
parent d1339970a5
commit e405457676
16 changed files with 593 additions and 172 deletions

View file

@ -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"],

View file

@ -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."

View file

@ -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

View file

@ -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:

View file

@ -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,
]