feat(01-04): FastAPI portal API endpoints with tenant/agent CRUD and auth

- Add packages/shared/shared/api/portal.py with APIRouter at /api/portal
- POST /auth/verify validates bcrypt credentials against portal_users table
- POST /auth/register creates new portal users with hashed passwords
- Tenant CRUD: GET/POST /tenants, GET/PUT/DELETE /tenants/{id}
- Agent CRUD: full CRUD under /tenants/{tenant_id}/agents/{id}
- Agent endpoints set RLS current_tenant_id context for policy compliance
- Pydantic v2 schemas with slug validation (lowercase, hyphens, 2-50 chars)
- Add bcrypt>=4.0.0 dependency to konstruct-shared
- Integration tests: 38 tests covering all CRUD, validation, and isolation
This commit is contained in:
2026-03-23 10:05:07 -06:00
parent ee2f88e13b
commit 7b348b97e9
6 changed files with 1149 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
"""
Konstruct shared API routers.
Import and mount these routers in service main.py files.
"""
from shared.api.portal import portal_router
__all__ = ["portal_router"]

View File

@@ -0,0 +1,521 @@
"""
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
from sqlalchemy.ext.asyncio import AsyncSession
from shared.db import get_session
from shared.models.auth import PortalUser
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
is_admin: bool
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
is_admin: bool
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 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.
"""
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")
return AuthVerifyResponse(
id=str(user.id),
email=user.email,
name=user.name,
is_admin=user.is_admin,
)
@portal_router.post("/auth/register", response_model=AuthRegisterResponse, status_code=status.HTTP_201_CREATED)
async def register_user(
body: AuthRegisterRequest,
session: AsyncSession = Depends(get_session),
) -> AuthRegisterResponse:
"""
Create a new portal user with bcrypt-hashed password.
In production, restrict this to admin-only or use a setup wizard.
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,
is_admin=False,
)
session.add(user)
await session.commit()
await session.refresh(user)
return AuthRegisterResponse(
id=str(user.id),
email=user.email,
name=user.name,
is_admin=user.is_admin,
)
# ---------------------------------------------------------------------------
# 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),
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,
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,
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,
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,
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,
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,
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,
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,
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,
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)