174 lines
6.0 KiB
Python
174 lines
6.0 KiB
Python
"""Bot API key management and project report endpoints."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
|
|
from sqlmodel import col, desc, select
|
|
|
|
from app.api.deps import require_org_admin, require_org_member
|
|
from app.core.agent_tokens import generate_agent_token, hash_agent_token, verify_agent_token
|
|
from app.core.time import utcnow
|
|
from app.db.session import get_session
|
|
from app.models.bot import BotApiKey, BotReport
|
|
from app.schemas.bot import (
|
|
BotApiKeyCreate,
|
|
BotApiKeyCreated,
|
|
BotApiKeyRead,
|
|
BotReportCreate,
|
|
BotReportRead,
|
|
)
|
|
from app.services.organizations import OrganizationContext
|
|
|
|
if TYPE_CHECKING:
|
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
|
|
router = APIRouter(prefix="/bot", tags=["bot"])
|
|
|
|
SESSION_DEP = Depends(get_session)
|
|
ORG_MEMBER_DEP = Depends(require_org_member)
|
|
ORG_ADMIN_DEP = Depends(require_org_admin)
|
|
|
|
|
|
async def _get_bot_key_auth(
|
|
x_bot_key: str | None = Header(default=None, alias="X-Bot-Key"),
|
|
session: AsyncSession = SESSION_DEP,
|
|
) -> BotApiKey:
|
|
"""Validate X-Bot-Key header against stored hashes. Returns the matching key row."""
|
|
if not x_bot_key:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="X-Bot-Key header required.")
|
|
keys = list(await session.exec(select(BotApiKey)))
|
|
for key in keys:
|
|
if verify_agent_token(x_bot_key, key.key_hash):
|
|
key.last_used_at = utcnow()
|
|
session.add(key)
|
|
await session.commit()
|
|
return key
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid bot key.")
|
|
|
|
|
|
BOT_KEY_DEP = Depends(_get_bot_key_auth)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Key management (user auth)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.post("/keys", response_model=BotApiKeyCreated, status_code=status.HTTP_201_CREATED)
|
|
async def create_bot_key(
|
|
data: BotApiKeyCreate,
|
|
session: AsyncSession = SESSION_DEP,
|
|
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
) -> BotApiKeyCreated:
|
|
"""Generate a new bot API key. The plaintext key is returned once — store it immediately."""
|
|
plaintext = generate_agent_token()
|
|
key = BotApiKey(
|
|
organization_id=ctx.organization.id,
|
|
name=data.name,
|
|
key_hash=hash_agent_token(plaintext),
|
|
key_last_four=plaintext[-4:],
|
|
)
|
|
session.add(key)
|
|
await session.commit()
|
|
await session.refresh(key)
|
|
return BotApiKeyCreated(
|
|
id=key.id,
|
|
name=key.name,
|
|
key_last_four=key.key_last_four,
|
|
created_at=key.created_at,
|
|
last_used_at=key.last_used_at,
|
|
key=plaintext,
|
|
)
|
|
|
|
|
|
@router.get("/keys", response_model=list[BotApiKeyRead])
|
|
async def list_bot_keys(
|
|
session: AsyncSession = SESSION_DEP,
|
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
|
) -> list[BotApiKeyRead]:
|
|
"""List all bot API keys for this organization."""
|
|
keys = list(await session.exec(
|
|
select(BotApiKey)
|
|
.where(col(BotApiKey.organization_id) == ctx.organization.id)
|
|
.order_by(desc(col(BotApiKey.created_at)))
|
|
))
|
|
return [BotApiKeyRead.model_validate(k, from_attributes=True) for k in keys]
|
|
|
|
|
|
@router.delete("/keys/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_bot_key(
|
|
key_id: UUID,
|
|
session: AsyncSession = SESSION_DEP,
|
|
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
) -> None:
|
|
"""Revoke a bot API key."""
|
|
key = await session.get(BotApiKey, key_id)
|
|
if key is None or key.organization_id != ctx.organization.id:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
await session.delete(key)
|
|
await session.commit()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Reporting (bot key auth)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.post("/report", response_model=BotReportRead, status_code=status.HTTP_201_CREATED)
|
|
async def create_bot_report(
|
|
data: BotReportCreate,
|
|
session: AsyncSession = SESSION_DEP,
|
|
key: BotApiKey = BOT_KEY_DEP,
|
|
) -> BotReportRead:
|
|
"""Record what the bot is currently working on."""
|
|
report = BotReport(
|
|
organization_id=key.organization_id,
|
|
key_id=key.id,
|
|
project=data.project,
|
|
task=data.task,
|
|
status=data.status,
|
|
detail=data.detail,
|
|
)
|
|
session.add(report)
|
|
await session.commit()
|
|
await session.refresh(report)
|
|
return BotReportRead.model_validate(report, from_attributes=True)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Report reading (user auth)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get("/reports/latest", response_model=BotReportRead)
|
|
async def get_latest_bot_report(
|
|
session: AsyncSession = SESSION_DEP,
|
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
|
) -> BotReportRead:
|
|
"""Return the most recent bot report for this organization."""
|
|
report = (await session.exec(
|
|
select(BotReport)
|
|
.where(col(BotReport.organization_id) == ctx.organization.id)
|
|
.order_by(desc(col(BotReport.reported_at)))
|
|
.limit(1)
|
|
)).first()
|
|
if report is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
return BotReportRead.model_validate(report, from_attributes=True)
|
|
|
|
|
|
@router.get("/reports", response_model=list[BotReportRead])
|
|
async def list_bot_reports(
|
|
limit: int = Query(default=50, ge=1, le=200),
|
|
session: AsyncSession = SESSION_DEP,
|
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
|
) -> list[BotReportRead]:
|
|
"""List bot reports newest-first."""
|
|
reports = list(await session.exec(
|
|
select(BotReport)
|
|
.where(col(BotReport.organization_id) == ctx.organization.id)
|
|
.order_by(desc(col(BotReport.reported_at)))
|
|
.limit(limit)
|
|
))
|
|
return [BotReportRead.model_validate(r, from_attributes=True) for r in reports]
|