Files
konstruct/packages/shared/shared/api/portal.py
Adolfo Delorenzo 43b73aa6c5 feat(04-rbac-03): wire RBAC guards to all portal API endpoints + new endpoints
- Add require_platform_admin guard to GET/POST /tenants, PUT/DELETE /tenants/{id}
- Add require_tenant_member to GET /tenants/{id}, GET agents, GET agent/{id}
- Add require_tenant_admin to POST agents, PUT/DELETE agents
- Add require_tenant_admin to billing checkout and portal endpoints
- Add require_tenant_admin to channels slack/install and whatsapp/connect
- Add require_tenant_member to channels /{tid}/test
- Add require_tenant_admin to all llm_keys endpoints
- Add require_tenant_member to all usage GET endpoints
- Add POST /tenants/{tid}/agents/{aid}/test (require_tenant_member for operators)
- Add GET /tenants/{tid}/users with pending invitations (require_tenant_admin)
- Add GET /admin/users with tenant filter/role filter (require_platform_admin)
- Add POST /admin/impersonate with AuditEvent logging (require_platform_admin)
- Add POST /admin/stop-impersonation with AuditEvent logging (require_platform_admin)
2026-03-24 17:13:35 -06:00

845 lines
28 KiB
Python

"""
FastAPI portal API router — tenant CRUD, agent CRUD, and auth endpoints.
Mounted at /api/portal in the gateway or a dedicated portal-api service.
The Auth.js v5 Credentials provider calls /auth/verify to validate operator
email/password against the portal_users table.
"""
from __future__ import annotations
import re
import uuid
from datetime import datetime
from typing import Any
import bcrypt
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from shared.api.rbac import PortalCaller, require_platform_admin, require_tenant_admin, require_tenant_member
from shared.db import get_session
from shared.models.audit import AuditEvent
from shared.models.auth import PortalInvitation, PortalUser, UserTenantRole
from shared.models.tenant import Agent, Tenant
from shared.rls import current_tenant_id
portal_router = APIRouter(prefix="/api/portal", tags=["portal"])
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,48}[a-z0-9]$|^[a-z0-9]{1,2}$")
class AuthVerifyRequest(BaseModel):
email: str
password: str
class AuthVerifyResponse(BaseModel):
id: str
email: str
name: str
role: str
tenant_ids: list[str]
active_tenant_id: str | None
class AuthRegisterRequest(BaseModel):
email: str
password: str = Field(min_length=8)
name: str = Field(min_length=1, max_length=255)
class AuthRegisterResponse(BaseModel):
id: str
email: str
name: str
role: str
class TenantCreate(BaseModel):
name: str = Field(min_length=2, max_length=100)
slug: str = Field(min_length=2, max_length=50)
settings: dict[str, Any] = Field(default_factory=dict)
@field_validator("slug")
@classmethod
def validate_slug(cls, v: str) -> str:
v = v.strip()
if not _SLUG_RE.match(v):
raise ValueError(
"slug must be 2-50 lowercase alphanumeric characters and hyphens "
"(no leading/trailing hyphens)"
)
return v
@field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str:
v = v.strip()
if len(v) < 2:
raise ValueError("name must be at least 2 characters after stripping whitespace")
return v
class TenantUpdate(BaseModel):
name: str | None = Field(default=None, min_length=2, max_length=100)
slug: str | None = Field(default=None, min_length=2, max_length=50)
settings: dict[str, Any] | None = None
@field_validator("slug")
@classmethod
def validate_slug(cls, v: str | None) -> str | None:
if v is None:
return v
v = v.strip()
if not _SLUG_RE.match(v):
raise ValueError(
"slug must be 2-50 lowercase alphanumeric characters and hyphens "
"(no leading/trailing hyphens)"
)
return v
class TenantResponse(BaseModel):
id: str
name: str
slug: str
settings: dict[str, Any]
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
@classmethod
def from_orm(cls, tenant: Tenant) -> "TenantResponse":
return cls(
id=str(tenant.id),
name=tenant.name,
slug=tenant.slug,
settings=tenant.settings,
created_at=tenant.created_at,
updated_at=tenant.updated_at,
)
class TenantsListResponse(BaseModel):
items: list[TenantResponse]
total: int
page: int
page_size: int
class AgentTestRequest(BaseModel):
message: str = Field(min_length=1, max_length=4096)
class AgentTestResponse(BaseModel):
agent_id: str
message: str
response: str
class TenantUserResponse(BaseModel):
id: str
name: str
email: str
role: str
created_at: datetime
model_config = {"from_attributes": True}
class TenantUsersResponse(BaseModel):
users: list[TenantUserResponse]
pending_invitations: list[dict[str, Any]]
class AdminUserResponse(BaseModel):
id: str
name: str
email: str
role: str
tenant_memberships: list[dict[str, Any]]
created_at: datetime
model_config = {"from_attributes": True}
class ImpersonateRequest(BaseModel):
tenant_id: uuid.UUID
class ImpersonateResponse(BaseModel):
tenant_id: str
tenant_name: str
tenant_slug: str
class AgentCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
role: str = Field(min_length=1, max_length=255)
persona: str = Field(default="")
system_prompt: str = Field(default="")
model_preference: str = Field(default="quality")
tool_assignments: list[Any] = Field(default_factory=list)
escalation_rules: list[Any] = Field(default_factory=list)
is_active: bool = Field(default=True)
@field_validator("model_preference")
@classmethod
def validate_model_preference(cls, v: str) -> str:
allowed = {"quality", "fast", "balanced", "economy", "local"}
if v not in allowed:
raise ValueError(f"model_preference must be one of: {', '.join(sorted(allowed))}")
return v
class AgentUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=255)
role: str | None = Field(default=None, min_length=1, max_length=255)
persona: str | None = None
system_prompt: str | None = None
model_preference: str | None = None
tool_assignments: list[Any] | None = None
escalation_rules: list[Any] | None = None
is_active: bool | None = None
@field_validator("model_preference")
@classmethod
def validate_model_preference(cls, v: str | None) -> str | None:
if v is None:
return v
allowed = {"quality", "fast", "balanced", "economy", "local"}
if v not in allowed:
raise ValueError(f"model_preference must be one of: {', '.join(sorted(allowed))}")
return v
class AgentResponse(BaseModel):
id: str
tenant_id: str
name: str
role: str
persona: str
system_prompt: str
model_preference: str
tool_assignments: list[Any]
escalation_rules: list[Any]
is_active: bool
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
@classmethod
def from_orm(cls, agent: Agent) -> "AgentResponse":
return cls(
id=str(agent.id),
tenant_id=str(agent.tenant_id),
name=agent.name,
role=agent.role,
persona=agent.persona,
system_prompt=agent.system_prompt,
model_preference=agent.model_preference,
tool_assignments=agent.tool_assignments,
escalation_rules=agent.escalation_rules,
is_active=agent.is_active,
created_at=agent.created_at,
updated_at=agent.updated_at,
)
# ---------------------------------------------------------------------------
# Auth endpoints
# ---------------------------------------------------------------------------
@portal_router.post("/auth/verify", response_model=AuthVerifyResponse)
async def verify_credentials(
body: AuthVerifyRequest,
session: AsyncSession = Depends(get_session),
) -> AuthVerifyResponse:
"""
Validate email + password against the portal_users table.
Used by Auth.js v5 Credentials provider. Returns 401 on invalid credentials.
Response deliberately omits hashed_password.
Returns role + tenant_ids + active_tenant_id instead of is_admin:
- platform_admin: all tenant IDs from the tenants table
- customer_admin / customer_operator: only tenant IDs from user_tenant_roles
"""
result = await session.execute(select(PortalUser).where(PortalUser.email == body.email))
user = result.scalar_one_or_none()
if user is None or not bcrypt.checkpw(body.password.encode(), user.hashed_password.encode()):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
# Resolve tenant_ids based on role
if user.role == "platform_admin":
# Platform admins see all tenants
tenants_result = await session.execute(select(Tenant))
tenant_ids = [str(t.id) for t in tenants_result.scalars().all()]
else:
# Customer admins and operators see only their assigned tenants
memberships_result = await session.execute(
select(UserTenantRole).where(UserTenantRole.user_id == user.id)
)
tenant_ids = [str(m.tenant_id) for m in memberships_result.scalars().all()]
active_tenant_id = tenant_ids[0] if tenant_ids else None
return AuthVerifyResponse(
id=str(user.id),
email=user.email,
name=user.name,
role=user.role,
tenant_ids=tenant_ids,
active_tenant_id=active_tenant_id,
)
@portal_router.post("/auth/register", response_model=AuthRegisterResponse, status_code=status.HTTP_201_CREATED)
async def register_user(
body: AuthRegisterRequest,
# DEPRECATED: Direct registration is platform-admin only.
# Standard flow: use POST /api/portal/invitations (invite-only onboarding).
caller: PortalCaller = Depends(require_platform_admin),
session: AsyncSession = Depends(get_session),
) -> AuthRegisterResponse:
"""
Create a new portal user with bcrypt-hashed password.
DEPRECATED: This endpoint is now restricted to platform admins only.
The standard onboarding flow is invite-only: POST /api/portal/invitations.
Returns 409 if email already registered.
"""
existing = await session.execute(select(PortalUser).where(PortalUser.email == body.email))
if existing.scalar_one_or_none() is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
hashed = bcrypt.hashpw(body.password.encode(), bcrypt.gensalt()).decode()
user = PortalUser(
email=body.email,
hashed_password=hashed,
name=body.name,
role="customer_admin",
)
session.add(user)
await session.commit()
await session.refresh(user)
return AuthRegisterResponse(
id=str(user.id),
email=user.email,
name=user.name,
role=user.role,
)
# ---------------------------------------------------------------------------
# Tenant endpoints (PRTA-01)
# ---------------------------------------------------------------------------
@portal_router.get("/tenants", response_model=TenantsListResponse)
async def list_tenants(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
caller: PortalCaller = Depends(require_platform_admin),
session: AsyncSession = Depends(get_session),
) -> TenantsListResponse:
"""List all tenants (platform admin view). Paginated."""
offset = (page - 1) * page_size
result = await session.execute(select(Tenant).order_by(Tenant.created_at.desc()).offset(offset).limit(page_size))
tenants = result.scalars().all()
count_result = await session.execute(select(Tenant))
total = len(count_result.scalars().all())
return TenantsListResponse(
items=[TenantResponse.from_orm(t) for t in tenants],
total=total,
page=page,
page_size=page_size,
)
@portal_router.post("/tenants", response_model=TenantResponse, status_code=status.HTTP_201_CREATED)
async def create_tenant(
body: TenantCreate,
caller: PortalCaller = Depends(require_platform_admin),
session: AsyncSession = Depends(get_session),
) -> TenantResponse:
"""Create a new tenant. Validates slug uniqueness."""
# Check name uniqueness
name_exists = await session.execute(select(Tenant).where(Tenant.name == body.name))
if name_exists.scalar_one_or_none() is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Tenant name already exists")
# Check slug uniqueness
slug_exists = await session.execute(select(Tenant).where(Tenant.slug == body.slug))
if slug_exists.scalar_one_or_none() is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Tenant slug already exists")
tenant = Tenant(name=body.name, slug=body.slug, settings=body.settings)
session.add(tenant)
await session.commit()
await session.refresh(tenant)
return TenantResponse.from_orm(tenant)
@portal_router.get("/tenants/{tenant_id}", response_model=TenantResponse)
async def get_tenant(
tenant_id: uuid.UUID,
caller: PortalCaller = Depends(require_tenant_member),
session: AsyncSession = Depends(get_session),
) -> TenantResponse:
"""Get a tenant by ID. Returns 404 if not found."""
result = await session.execute(select(Tenant).where(Tenant.id == tenant_id))
tenant = result.scalar_one_or_none()
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
return TenantResponse.from_orm(tenant)
@portal_router.put("/tenants/{tenant_id}", response_model=TenantResponse)
async def update_tenant(
tenant_id: uuid.UUID,
body: TenantUpdate,
caller: PortalCaller = Depends(require_platform_admin),
session: AsyncSession = Depends(get_session),
) -> TenantResponse:
"""Update a tenant (partial updates supported)."""
result = await session.execute(select(Tenant).where(Tenant.id == tenant_id))
tenant = result.scalar_one_or_none()
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
if body.name is not None:
# Check name uniqueness (exclude self)
name_exists = await session.execute(
select(Tenant).where(Tenant.name == body.name, Tenant.id != tenant_id)
)
if name_exists.scalar_one_or_none() is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Tenant name already exists")
tenant.name = body.name
if body.slug is not None:
# Check slug uniqueness (exclude self)
slug_exists = await session.execute(
select(Tenant).where(Tenant.slug == body.slug, Tenant.id != tenant_id)
)
if slug_exists.scalar_one_or_none() is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Tenant slug already exists")
tenant.slug = body.slug
if body.settings is not None:
tenant.settings = body.settings
await session.commit()
await session.refresh(tenant)
return TenantResponse.from_orm(tenant)
@portal_router.delete("/tenants/{tenant_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_tenant(
tenant_id: uuid.UUID,
caller: PortalCaller = Depends(require_platform_admin),
session: AsyncSession = Depends(get_session),
) -> None:
"""Delete a tenant. Cascade deletes agents and channel_connections."""
result = await session.execute(select(Tenant).where(Tenant.id == tenant_id))
tenant = result.scalar_one_or_none()
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
await session.delete(tenant)
await session.commit()
# ---------------------------------------------------------------------------
# Agent endpoints (PRTA-02)
# ---------------------------------------------------------------------------
async def _get_tenant_or_404(tenant_id: uuid.UUID, session: AsyncSession) -> Tenant:
"""Shared helper to fetch a tenant or raise 404."""
result = await session.execute(select(Tenant).where(Tenant.id == tenant_id))
tenant = result.scalar_one_or_none()
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
return tenant
@portal_router.get("/tenants/{tenant_id}/agents", response_model=list[AgentResponse])
async def list_agents(
tenant_id: uuid.UUID,
caller: PortalCaller = Depends(require_tenant_member),
session: AsyncSession = Depends(get_session),
) -> list[AgentResponse]:
"""List all agents for a tenant."""
await _get_tenant_or_404(tenant_id, session)
token = current_tenant_id.set(tenant_id)
try:
result = await session.execute(
select(Agent).where(Agent.tenant_id == tenant_id).order_by(Agent.created_at.desc())
)
agents = result.scalars().all()
finally:
current_tenant_id.reset(token)
return [AgentResponse.from_orm(a) for a in agents]
@portal_router.post(
"/tenants/{tenant_id}/agents",
response_model=AgentResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_agent(
tenant_id: uuid.UUID,
body: AgentCreate,
caller: PortalCaller = Depends(require_tenant_admin),
session: AsyncSession = Depends(get_session),
) -> AgentResponse:
"""Create an AI employee for a tenant."""
await _get_tenant_or_404(tenant_id, session)
token = current_tenant_id.set(tenant_id)
try:
agent = Agent(
tenant_id=tenant_id,
name=body.name,
role=body.role,
persona=body.persona,
system_prompt=body.system_prompt,
model_preference=body.model_preference,
tool_assignments=body.tool_assignments,
escalation_rules=body.escalation_rules,
is_active=body.is_active,
)
session.add(agent)
await session.commit()
await session.refresh(agent)
finally:
current_tenant_id.reset(token)
return AgentResponse.from_orm(agent)
@portal_router.get("/tenants/{tenant_id}/agents/{agent_id}", response_model=AgentResponse)
async def get_agent(
tenant_id: uuid.UUID,
agent_id: uuid.UUID,
caller: PortalCaller = Depends(require_tenant_member),
session: AsyncSession = Depends(get_session),
) -> AgentResponse:
"""Get an agent by ID (must belong to the specified tenant)."""
await _get_tenant_or_404(tenant_id, session)
token = current_tenant_id.set(tenant_id)
try:
result = await session.execute(
select(Agent).where(Agent.id == agent_id, Agent.tenant_id == tenant_id)
)
agent = result.scalar_one_or_none()
finally:
current_tenant_id.reset(token)
if agent is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent not found")
return AgentResponse.from_orm(agent)
@portal_router.put("/tenants/{tenant_id}/agents/{agent_id}", response_model=AgentResponse)
async def update_agent(
tenant_id: uuid.UUID,
agent_id: uuid.UUID,
body: AgentUpdate,
caller: PortalCaller = Depends(require_tenant_admin),
session: AsyncSession = Depends(get_session),
) -> AgentResponse:
"""Update an agent (partial updates supported)."""
await _get_tenant_or_404(tenant_id, session)
token = current_tenant_id.set(tenant_id)
try:
result = await session.execute(
select(Agent).where(Agent.id == agent_id, Agent.tenant_id == tenant_id)
)
agent = result.scalar_one_or_none()
if agent is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent not found")
update_fields = body.model_dump(exclude_none=True)
for field, value in update_fields.items():
setattr(agent, field, value)
await session.commit()
await session.refresh(agent)
finally:
current_tenant_id.reset(token)
return AgentResponse.from_orm(agent)
@portal_router.delete("/tenants/{tenant_id}/agents/{agent_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_agent(
tenant_id: uuid.UUID,
agent_id: uuid.UUID,
caller: PortalCaller = Depends(require_tenant_admin),
session: AsyncSession = Depends(get_session),
) -> None:
"""Delete an agent."""
await _get_tenant_or_404(tenant_id, session)
token = current_tenant_id.set(tenant_id)
try:
result = await session.execute(
select(Agent).where(Agent.id == agent_id, Agent.tenant_id == tenant_id)
)
agent = result.scalar_one_or_none()
if agent is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent not found")
await session.delete(agent)
await session.commit()
finally:
current_tenant_id.reset(token)
# ---------------------------------------------------------------------------
# Test-message endpoint — operators can send test messages to agents
# ---------------------------------------------------------------------------
@portal_router.post(
"/tenants/{tenant_id}/agents/{agent_id}/test",
response_model=AgentTestResponse,
)
async def test_agent_message(
tenant_id: uuid.UUID,
agent_id: uuid.UUID,
body: AgentTestRequest,
caller: PortalCaller = Depends(require_tenant_member),
session: AsyncSession = Depends(get_session),
) -> AgentTestResponse:
"""
Send a test message to an agent.
Available to all tenant members including customer_operator (per locked decision:
operators can send test messages to agents). Returns a lightweight echo response
with the agent's identity. Full orchestrator integration is added when the agent
pipeline is wired to the portal API.
"""
await _get_tenant_or_404(tenant_id, session)
token = current_tenant_id.set(tenant_id)
try:
result = await session.execute(
select(Agent).where(Agent.id == agent_id, Agent.tenant_id == tenant_id)
)
agent = result.scalar_one_or_none()
finally:
current_tenant_id.reset(token)
if agent is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent not found")
# Lightweight test handler — dispatches to orchestrator when integration is complete.
# Returns a stub response with agent identity until orchestrator is wired to portal API.
response_text = (
f"Hi! I'm {agent.name}, your {agent.role}. "
f"I received your test message: '{body.message[:100]}'. "
"I'm ready to assist your team!"
)
return AgentTestResponse(
agent_id=str(agent.id),
message=body.message,
response=response_text,
)
# ---------------------------------------------------------------------------
# User listing endpoints
# ---------------------------------------------------------------------------
@portal_router.get("/tenants/{tenant_id}/users", response_model=TenantUsersResponse)
async def list_tenant_users(
tenant_id: uuid.UUID,
caller: PortalCaller = Depends(require_tenant_admin),
session: AsyncSession = Depends(get_session),
) -> TenantUsersResponse:
"""
List all users and pending invitations for a tenant.
Returns active users (from user_tenant_roles JOIN portal_users) and
pending invitations for the given tenant. Requires tenant admin or platform admin.
"""
await _get_tenant_or_404(tenant_id, session)
# Load users with membership in this tenant
result = await session.execute(
select(PortalUser, UserTenantRole)
.join(UserTenantRole, PortalUser.id == UserTenantRole.user_id)
.where(UserTenantRole.tenant_id == tenant_id)
.order_by(UserTenantRole.created_at.desc())
)
rows = result.all()
users = [
TenantUserResponse(
id=str(user.id),
name=user.name,
email=user.email,
role=membership.role,
created_at=user.created_at,
)
for user, membership in rows
]
# Load pending invitations
inv_result = await session.execute(
select(PortalInvitation).where(
PortalInvitation.tenant_id == tenant_id,
PortalInvitation.status == "pending",
)
)
invitations = inv_result.scalars().all()
pending = [
{
"id": str(inv.id),
"email": inv.email,
"name": inv.name,
"role": inv.role,
"expires_at": inv.expires_at.isoformat(),
"created_at": inv.created_at.isoformat(),
}
for inv in invitations
]
return TenantUsersResponse(users=users, pending_invitations=pending)
@portal_router.get("/admin/users", response_model=list[AdminUserResponse])
async def list_all_users(
tenant_id: uuid.UUID | None = Query(default=None),
role: str | None = Query(default=None),
caller: PortalCaller = Depends(require_platform_admin),
session: AsyncSession = Depends(get_session),
) -> list[AdminUserResponse]:
"""
Global user management endpoint for platform admins.
Lists ALL portal users with their tenant memberships. Supports optional
filtering by tenant_id or role. Requires platform admin access.
"""
query = select(PortalUser).order_by(PortalUser.created_at.desc())
if role is not None:
query = query.where(PortalUser.role == role)
result = await session.execute(query)
users = result.scalars().all()
# If filtering by tenant_id, load matching user IDs first
if tenant_id is not None:
membership_result = await session.execute(
select(UserTenantRole).where(UserTenantRole.tenant_id == tenant_id)
)
tenant_user_ids = {m.user_id for m in membership_result.scalars().all()}
users = [u for u in users if u.id in tenant_user_ids]
# Build response with membership info for each user
response = []
for user in users:
mem_result = await session.execute(
select(UserTenantRole).where(UserTenantRole.user_id == user.id)
)
memberships = [
{"tenant_id": str(m.tenant_id), "role": m.role}
for m in mem_result.scalars().all()
]
response.append(
AdminUserResponse(
id=str(user.id),
name=user.name,
email=user.email,
role=user.role,
tenant_memberships=memberships,
created_at=user.created_at,
)
)
return response
# ---------------------------------------------------------------------------
# Impersonation endpoints (platform admin only)
# ---------------------------------------------------------------------------
@portal_router.post("/admin/impersonate", response_model=ImpersonateResponse)
async def start_impersonation(
body: ImpersonateRequest,
caller: PortalCaller = Depends(require_platform_admin),
session: AsyncSession = Depends(get_session),
) -> ImpersonateResponse:
"""
Begin platform admin impersonation of a tenant.
Logs an AuditEvent with action_type='impersonation' containing the platform
admin's user_id and the target tenant_id. The portal uses this response
to trigger a JWT update with impersonating_tenant_id in the session.
"""
result = await session.execute(select(Tenant).where(Tenant.id == body.tenant_id))
tenant = result.scalar_one_or_none()
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
# Log impersonation start to audit trail using raw INSERT (per audit.py design)
await session.execute(
text(
"INSERT INTO audit_events (tenant_id, user_id, action_type, metadata) "
"VALUES (:tenant_id, :user_id, 'impersonation', CAST(:metadata AS jsonb))"
),
{
"tenant_id": str(body.tenant_id),
"user_id": str(caller.user_id),
"metadata": (
f'{{"action": "start", "platform_admin_id": "{caller.user_id}", '
f'"target_tenant_id": "{body.tenant_id}"}}'
),
},
)
await session.commit()
return ImpersonateResponse(
tenant_id=str(tenant.id),
tenant_name=tenant.name,
tenant_slug=tenant.slug,
)
@portal_router.post("/admin/stop-impersonation", status_code=status.HTTP_204_NO_CONTENT)
async def stop_impersonation(
body: ImpersonateRequest,
caller: PortalCaller = Depends(require_platform_admin),
session: AsyncSession = Depends(get_session),
) -> None:
"""
End platform admin impersonation session.
Logs an AuditEvent with action_type='impersonation' and action='stop'
to complete the impersonation audit trail.
"""
await session.execute(
text(
"INSERT INTO audit_events (tenant_id, user_id, action_type, metadata) "
"VALUES (:tenant_id, :user_id, 'impersonation', CAST(:metadata AS jsonb))"
),
{
"tenant_id": str(body.tenant_id),
"user_id": str(caller.user_id),
"metadata": (
f'{{"action": "stop", "platform_admin_id": "{caller.user_id}", '
f'"target_tenant_id": "{body.tenant_id}"}}'
),
},
)
await session.commit()