- 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)
845 lines
28 KiB
Python
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()
|