From 43b73aa6c578f05e32fa4be046384dea2281283c Mon Sep 17 00:00:00 2001 From: Adolfo Delorenzo Date: Tue, 24 Mar 2026 17:13:35 -0600 Subject: [PATCH] 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) --- packages/shared/shared/api/billing.py | 3 + packages/shared/shared/api/channels.py | 4 + packages/shared/shared/api/llm_keys.py | 4 + packages/shared/shared/api/portal.py | 301 ++++++++++++++++++++++++- packages/shared/shared/api/usage.py | 5 + 5 files changed, 314 insertions(+), 3 deletions(-) diff --git a/packages/shared/shared/api/billing.py b/packages/shared/shared/api/billing.py index f4d220f..f3dc37c 100644 --- a/packages/shared/shared/api/billing.py +++ b/packages/shared/shared/api/billing.py @@ -33,6 +33,7 @@ from sqlalchemy import select, text from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from shared.api.rbac import PortalCaller, require_tenant_admin from shared.config import settings from shared.db import get_session 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) async def create_checkout_session( body: CheckoutRequest, + caller: PortalCaller = Depends(require_tenant_admin), session: AsyncSession = Depends(get_session), ) -> CheckoutResponse: """ @@ -254,6 +256,7 @@ async def create_checkout_session( @billing_router.post("/portal", response_model=PortalResponse) async def create_billing_portal_session( body: PortalRequest, + caller: PortalCaller = Depends(require_tenant_admin), session: AsyncSession = Depends(get_session), ) -> PortalResponse: """ diff --git a/packages/shared/shared/api/channels.py b/packages/shared/shared/api/channels.py index 0dfb876..7992e02 100644 --- a/packages/shared/shared/api/channels.py +++ b/packages/shared/shared/api/channels.py @@ -38,6 +38,7 @@ from pydantic import BaseModel from sqlalchemy import select, text 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.crypto import KeyEncryptionService 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) async def slack_install( tenant_id: uuid.UUID = Query(...), + caller: PortalCaller = Depends(require_tenant_admin), ) -> SlackInstallResponse: """ 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) async def whatsapp_connect( body: WhatsAppConnectRequest, + caller: PortalCaller = Depends(require_tenant_admin), session: AsyncSession = Depends(get_session), ) -> WhatsAppConnectResponse: """ @@ -396,6 +399,7 @@ async def whatsapp_connect( async def test_channel_connection( tenant_id: uuid.UUID, body: TestChannelRequest, + caller: PortalCaller = Depends(require_tenant_member), session: AsyncSession = Depends(get_session), ) -> TestChannelResponse: """ diff --git a/packages/shared/shared/api/llm_keys.py b/packages/shared/shared/api/llm_keys.py index 64d2c75..eae7a3c 100644 --- a/packages/shared/shared/api/llm_keys.py +++ b/packages/shared/shared/api/llm_keys.py @@ -27,6 +27,7 @@ from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from shared.api.rbac import PortalCaller, require_tenant_admin from shared.config import settings from shared.crypto import KeyEncryptionService 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]) async def list_llm_keys( tenant_id: uuid.UUID, + caller: PortalCaller = Depends(require_tenant_admin), session: AsyncSession = Depends(get_session), ) -> list[LlmKeyResponse]: """ @@ -143,6 +145,7 @@ async def list_llm_keys( async def create_llm_key( tenant_id: uuid.UUID, body: LlmKeyCreate, + caller: PortalCaller = Depends(require_tenant_admin), session: AsyncSession = Depends(get_session), ) -> LlmKeyResponse: """ @@ -195,6 +198,7 @@ async def create_llm_key( async def delete_llm_key( tenant_id: uuid.UUID, key_id: uuid.UUID, + caller: PortalCaller = Depends(require_tenant_admin), session: AsyncSession = Depends(get_session), ) -> None: """ diff --git a/packages/shared/shared/api/portal.py b/packages/shared/shared/api/portal.py index 6b30f15..bc3b8d4 100644 --- a/packages/shared/shared/api/portal.py +++ b/packages/shared/shared/api/portal.py @@ -16,12 +16,13 @@ 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 +from sqlalchemy import select, text 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.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.rls import current_tenant_id @@ -134,6 +135,52 @@ class TenantsListResponse(BaseModel): 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) @@ -306,6 +353,7 @@ async def register_user( 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.""" @@ -327,6 +375,7 @@ async def list_tenants( @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.""" @@ -350,6 +399,7 @@ async def create_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.""" @@ -364,6 +414,7 @@ async def get_tenant( 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).""" @@ -401,6 +452,7 @@ async def update_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.""" @@ -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]) 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.""" @@ -452,6 +505,7 @@ async def list_agents( 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.""" @@ -481,6 +535,7 @@ async def create_agent( 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).""" @@ -503,6 +558,7 @@ 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).""" @@ -531,6 +587,7 @@ async def update_agent( 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.""" @@ -547,3 +604,241 @@ async def 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() diff --git a/packages/shared/shared/api/usage.py b/packages/shared/shared/api/usage.py index 4249a86..eb4afd3 100644 --- a/packages/shared/shared/api/usage.py +++ b/packages/shared/shared/api/usage.py @@ -24,6 +24,7 @@ from pydantic import BaseModel from sqlalchemy import select, text from sqlalchemy.ext.asyncio import AsyncSession +from shared.api.rbac import PortalCaller, require_tenant_member from shared.db import get_session from shared.models.tenant import Agent, Tenant @@ -201,6 +202,7 @@ async def get_usage_summary( tenant_id: uuid.UUID, start_date: str = Query(default=None), end_date: str = Query(default=None), + caller: PortalCaller = Depends(require_tenant_member), session: AsyncSession = Depends(get_session), ) -> UsageSummaryResponse: """ @@ -268,6 +270,7 @@ async def get_usage_by_provider( tenant_id: uuid.UUID, start_date: str = Query(default=None), end_date: str = Query(default=None), + caller: PortalCaller = Depends(require_tenant_member), session: AsyncSession = Depends(get_session), ) -> ProviderUsageResponse: """Cost aggregated by LLM provider from audit_events.metadata.provider.""" @@ -323,6 +326,7 @@ async def get_message_volume( tenant_id: uuid.UUID, start_date: str = Query(default=None), end_date: str = Query(default=None), + caller: PortalCaller = Depends(require_tenant_member), session: AsyncSession = Depends(get_session), ) -> MessageVolumeResponse: """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) async def get_budget_alerts( tenant_id: uuid.UUID, + caller: PortalCaller = Depends(require_tenant_member), session: AsyncSession = Depends(get_session), ) -> BudgetAlertsResponse: """