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)
This commit is contained in:
@@ -33,6 +33,7 @@ from sqlalchemy import select, text
|
|||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from shared.api.rbac import PortalCaller, require_tenant_admin
|
||||||
from shared.config import settings
|
from shared.config import settings
|
||||||
from shared.db import get_session
|
from shared.db import get_session
|
||||||
from shared.models.billing import StripeEvent
|
from shared.models.billing import StripeEvent
|
||||||
@@ -205,6 +206,7 @@ async def process_stripe_event(event_data: dict[str, Any], session: AsyncSession
|
|||||||
@billing_router.post("/checkout", response_model=CheckoutResponse)
|
@billing_router.post("/checkout", response_model=CheckoutResponse)
|
||||||
async def create_checkout_session(
|
async def create_checkout_session(
|
||||||
body: CheckoutRequest,
|
body: CheckoutRequest,
|
||||||
|
caller: PortalCaller = Depends(require_tenant_admin),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> CheckoutResponse:
|
) -> CheckoutResponse:
|
||||||
"""
|
"""
|
||||||
@@ -254,6 +256,7 @@ async def create_checkout_session(
|
|||||||
@billing_router.post("/portal", response_model=PortalResponse)
|
@billing_router.post("/portal", response_model=PortalResponse)
|
||||||
async def create_billing_portal_session(
|
async def create_billing_portal_session(
|
||||||
body: PortalRequest,
|
body: PortalRequest,
|
||||||
|
caller: PortalCaller = Depends(require_tenant_admin),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> PortalResponse:
|
) -> PortalResponse:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ from pydantic import BaseModel
|
|||||||
from sqlalchemy import select, text
|
from sqlalchemy import select, text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from shared.api.rbac import PortalCaller, require_tenant_admin, require_tenant_member
|
||||||
from shared.config import settings
|
from shared.config import settings
|
||||||
from shared.crypto import KeyEncryptionService
|
from shared.crypto import KeyEncryptionService
|
||||||
from shared.db import get_session
|
from shared.db import get_session
|
||||||
@@ -186,6 +187,7 @@ async def _get_tenant_or_404(tenant_id: uuid.UUID, session: AsyncSession) -> Ten
|
|||||||
@channels_router.get("/slack/install", response_model=SlackInstallResponse)
|
@channels_router.get("/slack/install", response_model=SlackInstallResponse)
|
||||||
async def slack_install(
|
async def slack_install(
|
||||||
tenant_id: uuid.UUID = Query(...),
|
tenant_id: uuid.UUID = Query(...),
|
||||||
|
caller: PortalCaller = Depends(require_tenant_admin),
|
||||||
) -> SlackInstallResponse:
|
) -> SlackInstallResponse:
|
||||||
"""
|
"""
|
||||||
Generate the Slack OAuth authorization URL for installing the app.
|
Generate the Slack OAuth authorization URL for installing the app.
|
||||||
@@ -322,6 +324,7 @@ async def slack_callback(
|
|||||||
@channels_router.post("/whatsapp/connect", response_model=WhatsAppConnectResponse, status_code=status.HTTP_201_CREATED)
|
@channels_router.post("/whatsapp/connect", response_model=WhatsAppConnectResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def whatsapp_connect(
|
async def whatsapp_connect(
|
||||||
body: WhatsAppConnectRequest,
|
body: WhatsAppConnectRequest,
|
||||||
|
caller: PortalCaller = Depends(require_tenant_admin),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> WhatsAppConnectResponse:
|
) -> WhatsAppConnectResponse:
|
||||||
"""
|
"""
|
||||||
@@ -396,6 +399,7 @@ async def whatsapp_connect(
|
|||||||
async def test_channel_connection(
|
async def test_channel_connection(
|
||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
body: TestChannelRequest,
|
body: TestChannelRequest,
|
||||||
|
caller: PortalCaller = Depends(require_tenant_member),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> TestChannelResponse:
|
) -> TestChannelResponse:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from pydantic import BaseModel, Field
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from shared.api.rbac import PortalCaller, require_tenant_admin
|
||||||
from shared.config import settings
|
from shared.config import settings
|
||||||
from shared.crypto import KeyEncryptionService
|
from shared.crypto import KeyEncryptionService
|
||||||
from shared.db import get_session
|
from shared.db import get_session
|
||||||
@@ -120,6 +121,7 @@ async def _get_tenant_or_404(tenant_id: uuid.UUID, session: AsyncSession) -> Ten
|
|||||||
@llm_keys_router.get("", response_model=list[LlmKeyResponse])
|
@llm_keys_router.get("", response_model=list[LlmKeyResponse])
|
||||||
async def list_llm_keys(
|
async def list_llm_keys(
|
||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
|
caller: PortalCaller = Depends(require_tenant_admin),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> list[LlmKeyResponse]:
|
) -> list[LlmKeyResponse]:
|
||||||
"""
|
"""
|
||||||
@@ -143,6 +145,7 @@ async def list_llm_keys(
|
|||||||
async def create_llm_key(
|
async def create_llm_key(
|
||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
body: LlmKeyCreate,
|
body: LlmKeyCreate,
|
||||||
|
caller: PortalCaller = Depends(require_tenant_admin),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> LlmKeyResponse:
|
) -> LlmKeyResponse:
|
||||||
"""
|
"""
|
||||||
@@ -195,6 +198,7 @@ async def create_llm_key(
|
|||||||
async def delete_llm_key(
|
async def delete_llm_key(
|
||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
key_id: uuid.UUID,
|
key_id: uuid.UUID,
|
||||||
|
caller: PortalCaller = Depends(require_tenant_admin),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -16,12 +16,13 @@ from typing import Any
|
|||||||
import bcrypt
|
import bcrypt
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from shared.api.rbac import PortalCaller, require_platform_admin
|
from shared.api.rbac import PortalCaller, require_platform_admin, require_tenant_admin, require_tenant_member
|
||||||
from shared.db import get_session
|
from shared.db import get_session
|
||||||
from shared.models.auth import PortalUser, UserTenantRole
|
from shared.models.audit import AuditEvent
|
||||||
|
from shared.models.auth import PortalInvitation, PortalUser, UserTenantRole
|
||||||
from shared.models.tenant import Agent, Tenant
|
from shared.models.tenant import Agent, Tenant
|
||||||
from shared.rls import current_tenant_id
|
from shared.rls import current_tenant_id
|
||||||
|
|
||||||
@@ -134,6 +135,52 @@ class TenantsListResponse(BaseModel):
|
|||||||
page_size: 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):
|
class AgentCreate(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=255)
|
name: str = Field(min_length=1, max_length=255)
|
||||||
role: str = Field(min_length=1, max_length=255)
|
role: str = Field(min_length=1, max_length=255)
|
||||||
@@ -306,6 +353,7 @@ async def register_user(
|
|||||||
async def list_tenants(
|
async def list_tenants(
|
||||||
page: int = Query(default=1, ge=1),
|
page: int = Query(default=1, ge=1),
|
||||||
page_size: int = Query(default=20, ge=1, le=100),
|
page_size: int = Query(default=20, ge=1, le=100),
|
||||||
|
caller: PortalCaller = Depends(require_platform_admin),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> TenantsListResponse:
|
) -> TenantsListResponse:
|
||||||
"""List all tenants (platform admin view). Paginated."""
|
"""List all tenants (platform admin view). Paginated."""
|
||||||
@@ -327,6 +375,7 @@ async def list_tenants(
|
|||||||
@portal_router.post("/tenants", response_model=TenantResponse, status_code=status.HTTP_201_CREATED)
|
@portal_router.post("/tenants", response_model=TenantResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_tenant(
|
async def create_tenant(
|
||||||
body: TenantCreate,
|
body: TenantCreate,
|
||||||
|
caller: PortalCaller = Depends(require_platform_admin),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> TenantResponse:
|
) -> TenantResponse:
|
||||||
"""Create a new tenant. Validates slug uniqueness."""
|
"""Create a new tenant. Validates slug uniqueness."""
|
||||||
@@ -350,6 +399,7 @@ async def create_tenant(
|
|||||||
@portal_router.get("/tenants/{tenant_id}", response_model=TenantResponse)
|
@portal_router.get("/tenants/{tenant_id}", response_model=TenantResponse)
|
||||||
async def get_tenant(
|
async def get_tenant(
|
||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
|
caller: PortalCaller = Depends(require_tenant_member),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> TenantResponse:
|
) -> TenantResponse:
|
||||||
"""Get a tenant by ID. Returns 404 if not found."""
|
"""Get a tenant by ID. Returns 404 if not found."""
|
||||||
@@ -364,6 +414,7 @@ async def get_tenant(
|
|||||||
async def update_tenant(
|
async def update_tenant(
|
||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
body: TenantUpdate,
|
body: TenantUpdate,
|
||||||
|
caller: PortalCaller = Depends(require_platform_admin),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> TenantResponse:
|
) -> TenantResponse:
|
||||||
"""Update a tenant (partial updates supported)."""
|
"""Update a tenant (partial updates supported)."""
|
||||||
@@ -401,6 +452,7 @@ async def update_tenant(
|
|||||||
@portal_router.delete("/tenants/{tenant_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@portal_router.delete("/tenants/{tenant_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_tenant(
|
async def delete_tenant(
|
||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
|
caller: PortalCaller = Depends(require_platform_admin),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Delete a tenant. Cascade deletes agents and channel_connections."""
|
"""Delete a tenant. Cascade deletes agents and channel_connections."""
|
||||||
@@ -429,6 +481,7 @@ async def _get_tenant_or_404(tenant_id: uuid.UUID, session: AsyncSession) -> Ten
|
|||||||
@portal_router.get("/tenants/{tenant_id}/agents", response_model=list[AgentResponse])
|
@portal_router.get("/tenants/{tenant_id}/agents", response_model=list[AgentResponse])
|
||||||
async def list_agents(
|
async def list_agents(
|
||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
|
caller: PortalCaller = Depends(require_tenant_member),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> list[AgentResponse]:
|
) -> list[AgentResponse]:
|
||||||
"""List all agents for a tenant."""
|
"""List all agents for a tenant."""
|
||||||
@@ -452,6 +505,7 @@ async def list_agents(
|
|||||||
async def create_agent(
|
async def create_agent(
|
||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
body: AgentCreate,
|
body: AgentCreate,
|
||||||
|
caller: PortalCaller = Depends(require_tenant_admin),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> AgentResponse:
|
) -> AgentResponse:
|
||||||
"""Create an AI employee for a tenant."""
|
"""Create an AI employee for a tenant."""
|
||||||
@@ -481,6 +535,7 @@ async def create_agent(
|
|||||||
async def get_agent(
|
async def get_agent(
|
||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
agent_id: uuid.UUID,
|
agent_id: uuid.UUID,
|
||||||
|
caller: PortalCaller = Depends(require_tenant_member),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> AgentResponse:
|
) -> AgentResponse:
|
||||||
"""Get an agent by ID (must belong to the specified tenant)."""
|
"""Get an agent by ID (must belong to the specified tenant)."""
|
||||||
@@ -503,6 +558,7 @@ async def update_agent(
|
|||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
agent_id: uuid.UUID,
|
agent_id: uuid.UUID,
|
||||||
body: AgentUpdate,
|
body: AgentUpdate,
|
||||||
|
caller: PortalCaller = Depends(require_tenant_admin),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> AgentResponse:
|
) -> AgentResponse:
|
||||||
"""Update an agent (partial updates supported)."""
|
"""Update an agent (partial updates supported)."""
|
||||||
@@ -531,6 +587,7 @@ async def update_agent(
|
|||||||
async def delete_agent(
|
async def delete_agent(
|
||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
agent_id: uuid.UUID,
|
agent_id: uuid.UUID,
|
||||||
|
caller: PortalCaller = Depends(require_tenant_admin),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Delete an agent."""
|
"""Delete an agent."""
|
||||||
@@ -547,3 +604,241 @@ async def delete_agent(
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
finally:
|
finally:
|
||||||
current_tenant_id.reset(token)
|
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()
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from pydantic import BaseModel
|
|||||||
from sqlalchemy import select, text
|
from sqlalchemy import select, text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from shared.api.rbac import PortalCaller, require_tenant_member
|
||||||
from shared.db import get_session
|
from shared.db import get_session
|
||||||
from shared.models.tenant import Agent, Tenant
|
from shared.models.tenant import Agent, Tenant
|
||||||
|
|
||||||
@@ -201,6 +202,7 @@ async def get_usage_summary(
|
|||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
start_date: str = Query(default=None),
|
start_date: str = Query(default=None),
|
||||||
end_date: str = Query(default=None),
|
end_date: str = Query(default=None),
|
||||||
|
caller: PortalCaller = Depends(require_tenant_member),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> UsageSummaryResponse:
|
) -> UsageSummaryResponse:
|
||||||
"""
|
"""
|
||||||
@@ -268,6 +270,7 @@ async def get_usage_by_provider(
|
|||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
start_date: str = Query(default=None),
|
start_date: str = Query(default=None),
|
||||||
end_date: str = Query(default=None),
|
end_date: str = Query(default=None),
|
||||||
|
caller: PortalCaller = Depends(require_tenant_member),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> ProviderUsageResponse:
|
) -> ProviderUsageResponse:
|
||||||
"""Cost aggregated by LLM provider from audit_events.metadata.provider."""
|
"""Cost aggregated by LLM provider from audit_events.metadata.provider."""
|
||||||
@@ -323,6 +326,7 @@ async def get_message_volume(
|
|||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
start_date: str = Query(default=None),
|
start_date: str = Query(default=None),
|
||||||
end_date: str = Query(default=None),
|
end_date: str = Query(default=None),
|
||||||
|
caller: PortalCaller = Depends(require_tenant_member),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> MessageVolumeResponse:
|
) -> MessageVolumeResponse:
|
||||||
"""Message count grouped by channel from audit_events.metadata.channel."""
|
"""Message count grouped by channel from audit_events.metadata.channel."""
|
||||||
@@ -371,6 +375,7 @@ async def get_message_volume(
|
|||||||
@usage_router.get("/{tenant_id}/budget-alerts", response_model=BudgetAlertsResponse)
|
@usage_router.get("/{tenant_id}/budget-alerts", response_model=BudgetAlertsResponse)
|
||||||
async def get_budget_alerts(
|
async def get_budget_alerts(
|
||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
|
caller: PortalCaller = Depends(require_tenant_member),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> BudgetAlertsResponse:
|
) -> BudgetAlertsResponse:
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user