feat(05-01): template list/detail/deploy API + RBAC + integration tests

- Create shared/api/templates.py with templates_router
- GET /api/portal/templates: list active templates (any authenticated user)
- GET /api/portal/templates/{id}: get template detail (any authenticated user)
- POST /api/portal/templates/{id}/deploy: create Agent snapshot (tenant_admin only)
- customer_operator returns 403 on deploy (RBAC enforced)
- Export templates_router from shared/api/__init__.py
- Mount templates_router in gateway/main.py (Phase 5 section)
- 11 integration tests pass (list, detail, deploy, RBAC, 404, snapshot independence)
This commit is contained in:
2026-03-24 20:32:30 -06:00
parent d1acb292a1
commit f9ce3d650f
4 changed files with 643 additions and 0 deletions

View File

@@ -9,6 +9,7 @@ from shared.api.channels import channels_router
from shared.api.invitations import invitations_router
from shared.api.llm_keys import llm_keys_router
from shared.api.portal import portal_router
from shared.api.templates import templates_router
from shared.api.usage import usage_router
__all__ = [
@@ -19,4 +20,5 @@ __all__ = [
"llm_keys_router",
"usage_router",
"invitations_router",
"templates_router",
]

View File

@@ -0,0 +1,186 @@
"""
FastAPI template API router — agent template gallery and deploy endpoints.
Templates are global (not tenant-scoped): any authenticated portal user can
browse them. Only tenant admins can deploy a template (creates an Agent snapshot).
Mounted at /api/portal in gateway/main.py alongside other portal routers.
Endpoints:
GET /api/portal/templates — list active templates (all authenticated users)
GET /api/portal/templates/{id} — get template detail (all authenticated users)
POST /api/portal/templates/{id}/deploy — deploy template as agent (tenant admin only)
"""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from shared.api.portal import AgentResponse
from shared.api.rbac import PortalCaller, get_portal_caller, require_tenant_admin
from shared.db import get_session
from shared.models.tenant import Agent, AgentTemplate
from shared.rls import current_tenant_id
templates_router = APIRouter(prefix="/api/portal", tags=["templates"])
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
class TemplateResponse(BaseModel):
id: str
name: str
role: str
description: str
category: str
persona: str
system_prompt: str
model_preference: str
tool_assignments: list[Any]
escalation_rules: list[Any]
is_active: bool
sort_order: int
created_at: datetime
model_config = {"from_attributes": True}
@classmethod
def from_orm(cls, tmpl: AgentTemplate) -> "TemplateResponse":
return cls(
id=str(tmpl.id),
name=tmpl.name,
role=tmpl.role,
description=tmpl.description,
category=tmpl.category,
persona=tmpl.persona,
system_prompt=tmpl.system_prompt,
model_preference=tmpl.model_preference,
tool_assignments=tmpl.tool_assignments,
escalation_rules=tmpl.escalation_rules,
is_active=tmpl.is_active,
sort_order=tmpl.sort_order,
created_at=tmpl.created_at,
)
class TemplateDeployRequest(BaseModel):
tenant_id: uuid.UUID
class TemplateDeployResponse(BaseModel):
agent: AgentResponse
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@templates_router.get("/templates", response_model=list[TemplateResponse])
async def list_templates(
caller: PortalCaller = Depends(get_portal_caller),
session: AsyncSession = Depends(get_session),
) -> list[TemplateResponse]:
"""
List all active agent templates.
Available to all authenticated portal users (any role).
Templates are global — not tenant-scoped, no RLS needed.
Returns templates ordered by sort_order ascending, then name.
"""
result = await session.execute(
select(AgentTemplate)
.where(AgentTemplate.is_active == True) # noqa: E712
.order_by(AgentTemplate.sort_order.asc(), AgentTemplate.name.asc())
)
templates = result.scalars().all()
return [TemplateResponse.from_orm(t) for t in templates]
@templates_router.get("/templates/{template_id}", response_model=TemplateResponse)
async def get_template(
template_id: uuid.UUID,
caller: PortalCaller = Depends(get_portal_caller),
session: AsyncSession = Depends(get_session),
) -> TemplateResponse:
"""
Get a single active agent template by ID.
Returns 404 if the template does not exist or is inactive.
Available to all authenticated portal users (any role).
"""
result = await session.execute(
select(AgentTemplate).where(
AgentTemplate.id == template_id,
AgentTemplate.is_active == True, # noqa: E712
)
)
tmpl = result.scalar_one_or_none()
if tmpl is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return TemplateResponse.from_orm(tmpl)
@templates_router.post(
"/templates/{template_id}/deploy",
response_model=TemplateDeployResponse,
status_code=status.HTTP_201_CREATED,
)
async def deploy_template(
template_id: uuid.UUID,
body: TemplateDeployRequest,
caller: PortalCaller = Depends(get_portal_caller),
session: AsyncSession = Depends(get_session),
) -> TemplateDeployResponse:
"""
Deploy a template as an independent Agent snapshot for a tenant.
Guard: tenant admin only (customer_operator gets 403).
The deployed Agent is a point-in-time snapshot — subsequent changes to the
template do not affect already-deployed agents.
Returns 404 if template not found. Returns 403 if caller lacks admin access
to the specified tenant.
"""
# RBAC: tenant admin check (reuses require_tenant_admin from rbac.py)
await require_tenant_admin(body.tenant_id, caller, session)
# Fetch template (404 if not found or inactive)
result = await session.execute(
select(AgentTemplate).where(AgentTemplate.id == template_id)
)
tmpl = result.scalar_one_or_none()
if tmpl is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
# Create Agent snapshot scoped to the target tenant
token = current_tenant_id.set(body.tenant_id)
try:
agent = Agent(
tenant_id=body.tenant_id,
name=tmpl.name,
role=tmpl.role,
persona=tmpl.persona,
system_prompt=tmpl.system_prompt,
model_preference=tmpl.model_preference,
tool_assignments=tmpl.tool_assignments,
escalation_rules=tmpl.escalation_rules,
is_active=True,
)
session.add(agent)
await session.commit()
await session.refresh(agent)
finally:
current_tenant_id.reset(token)
return TemplateDeployResponse(agent=AgentResponse.from_orm(agent))