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:
2026-03-24 17:13:35 -06:00
parent e899b14fa7
commit 43b73aa6c5
5 changed files with 314 additions and 3 deletions

View File

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

View File

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

View File

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

View File

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

View File

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