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:
9
packages/shared/shared/api/__init__.py
Normal file
9
packages/shared/shared/api/__init__.py
Normal 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"]
|
||||
521
packages/shared/shared/api/portal.py
Normal file
521
packages/shared/shared/api/portal.py
Normal 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)
|
||||
Reference in New Issue
Block a user