- mcp_server.py: use shlex.quote() on all shell-interpolated values to prevent command injection via ticket title/description/human_id - corvid/router.py: add alphanumeric-only regex validation for environment prefix; add full ancestor cycle detection before setting parent_id; import re - Add SECURITY_REVIEW.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
454 lines
17 KiB
Python
454 lines
17 KiB
Python
import hmac
|
||
import re
|
||
import uuid
|
||
|
||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
|
||
from pydantic import BaseModel
|
||
from sqlalchemy import func, or_, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from .models import Environment, Ticket, TicketAttachment
|
||
|
||
VALID_STATUSES = {"open", "ongoing", "completed", "abandoned"}
|
||
VALID_TYPES = {"feature", "bug", "chore", "project"}
|
||
VALID_USERS = {"kevin", "claude"}
|
||
|
||
|
||
# ── Pydantic schemas ──────────────────────────────────────────────────────────
|
||
|
||
class EnvironmentCreate(BaseModel):
|
||
prefix: str
|
||
name: str
|
||
description: str | None = None
|
||
|
||
|
||
class EnvironmentUpdate(BaseModel):
|
||
prefix: str | None = None
|
||
name: str | None = None
|
||
description: str | None = None
|
||
|
||
|
||
class TicketCreate(BaseModel):
|
||
user: str = "kevin"
|
||
title: str
|
||
description: str | None = None
|
||
status: str = "open"
|
||
type: str = "feature"
|
||
parent_id: int | None = None
|
||
effort: int | None = None
|
||
startup_script: str | None = None
|
||
environment_id: int | None = None
|
||
|
||
|
||
class TicketUpdate(BaseModel):
|
||
user: str | None = None
|
||
title: str | None = None
|
||
description: str | None = None
|
||
status: str | None = None
|
||
type: str | None = None
|
||
parent_id: int | None = None
|
||
effort: int | None = None
|
||
startup_script: str | None = None
|
||
environment_id: int | None = None
|
||
|
||
|
||
class AttachmentCreate(BaseModel):
|
||
title: str
|
||
content: str
|
||
created_by: str = "kevin"
|
||
|
||
|
||
class AttachmentUpdate(BaseModel):
|
||
title: str | None = None
|
||
content: str | None = None
|
||
|
||
|
||
# ── Serialisers ───────────────────────────────────────────────────────────────
|
||
|
||
def _env_dict(e: Environment) -> dict:
|
||
return {
|
||
"id": e.id,
|
||
"prefix": e.prefix,
|
||
"name": e.name,
|
||
"description": e.description,
|
||
"created_at": e.created_at.isoformat() if e.created_at else None,
|
||
}
|
||
|
||
|
||
async def _ticket_dict(t: Ticket, db: AsyncSession) -> dict:
|
||
child_ids = (await db.execute(
|
||
select(Ticket.id).where(Ticket.parent_id == t.id).order_by(Ticket.id)
|
||
)).scalars().all()
|
||
return {
|
||
"id": t.id,
|
||
"human_id": t.human_id,
|
||
"guid": t.guid,
|
||
"user": t.user,
|
||
"title": t.title,
|
||
"description": t.description,
|
||
"submitted_at": t.submitted_at.isoformat() if t.submitted_at else None,
|
||
"status": t.status,
|
||
"type": t.type,
|
||
"parent_id": t.parent_id,
|
||
"child_ids": list(child_ids),
|
||
"attachment_count": len(t.attachments),
|
||
"effort": t.effort,
|
||
"startup_script": t.startup_script,
|
||
"environment_id": t.environment_id,
|
||
}
|
||
|
||
|
||
def _att_dict(a: TicketAttachment) -> dict:
|
||
return {
|
||
"id": a.id,
|
||
"ticket_id": a.ticket_id,
|
||
"title": a.title,
|
||
"content": a.content,
|
||
"created_by": a.created_by,
|
||
"created_at": a.created_at.isoformat() if a.created_at else None,
|
||
}
|
||
|
||
|
||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
async def _get_ticket_or_404(db: AsyncSession, ticket_id: int) -> Ticket:
|
||
t = (await db.execute(select(Ticket).where(Ticket.id == ticket_id))).scalar_one_or_none()
|
||
if not t:
|
||
raise HTTPException(404, "ticket not found")
|
||
return t
|
||
|
||
|
||
async def _get_env_or_404(db: AsyncSession, env_id: int) -> Environment:
|
||
e = await db.get(Environment, env_id)
|
||
if not e:
|
||
raise HTTPException(404, "environment not found")
|
||
return e
|
||
|
||
|
||
async def _default_env_id(db: AsyncSession) -> int | None:
|
||
e = (await db.execute(select(Environment).order_by(Environment.id))).scalars().first()
|
||
return e.id if e else None
|
||
|
||
|
||
# ── Router factory ────────────────────────────────────────────────────────────
|
||
|
||
def make_router(api_key: str, get_db, require_user=None) -> APIRouter:
|
||
"""
|
||
Return a FastAPI APIRouter with all /api/environments/* and /api/tickets/* endpoints.
|
||
|
||
Args:
|
||
api_key: Value of TICKETS_API_KEY — requests presenting this in
|
||
X-Api-Key are authenticated as the service account.
|
||
get_db: FastAPI dependency yielding an AsyncSession.
|
||
require_user: Optional FastAPI dependency / callable(request) that
|
||
returns a user dict or raises. When provided, web
|
||
sessions are accepted alongside the API key. When
|
||
omitted, only API-key auth is accepted.
|
||
"""
|
||
router = APIRouter()
|
||
|
||
def _api_key_ok(x_api_key: str | None) -> bool:
|
||
if not api_key or not x_api_key:
|
||
return False
|
||
return hmac.compare_digest(x_api_key, api_key)
|
||
|
||
def require_ticket_auth(
|
||
request: Request,
|
||
x_api_key: str | None = Header(default=None),
|
||
):
|
||
if _api_key_ok(x_api_key):
|
||
return {"username": "claude", "email": "claude@internal"}
|
||
if require_user is not None:
|
||
return require_user(request)
|
||
if not api_key:
|
||
return {"username": "local", "email": "local@localhost"}
|
||
raise HTTPException(401, "unauthorized")
|
||
|
||
# ── Environment endpoints ─────────────────────────────────────────────────
|
||
|
||
@router.get("/api/environments")
|
||
async def list_environments(
|
||
db: AsyncSession = Depends(get_db),
|
||
_auth=Depends(require_ticket_auth),
|
||
):
|
||
rows = (await db.execute(select(Environment).order_by(Environment.id))).scalars().all()
|
||
return [_env_dict(e) for e in rows]
|
||
|
||
@router.post("/api/environments", status_code=201)
|
||
async def create_environment(
|
||
body: EnvironmentCreate,
|
||
db: AsyncSession = Depends(get_db),
|
||
_auth=Depends(require_ticket_auth),
|
||
):
|
||
prefix = body.prefix.strip().upper()
|
||
if not prefix or len(prefix) > 16:
|
||
raise HTTPException(400, "prefix must be 1–16 characters")
|
||
if not re.match(r'^[A-Z0-9_-]+$', prefix):
|
||
raise HTTPException(400, "prefix must contain only letters, digits, underscores, and hyphens")
|
||
existing = (await db.execute(
|
||
select(Environment).where(Environment.prefix == prefix)
|
||
)).scalar_one_or_none()
|
||
if existing:
|
||
raise HTTPException(409, f"prefix '{prefix}' already exists")
|
||
e = Environment(prefix=prefix, name=body.name.strip(), description=body.description)
|
||
db.add(e)
|
||
await db.commit()
|
||
await db.refresh(e)
|
||
return _env_dict(e)
|
||
|
||
@router.get("/api/environments/{env_id}")
|
||
async def get_environment(
|
||
env_id: int,
|
||
db: AsyncSession = Depends(get_db),
|
||
_auth=Depends(require_ticket_auth),
|
||
):
|
||
return _env_dict(await _get_env_or_404(db, env_id))
|
||
|
||
@router.patch("/api/environments/{env_id}")
|
||
async def update_environment(
|
||
env_id: int,
|
||
body: EnvironmentUpdate,
|
||
db: AsyncSession = Depends(get_db),
|
||
_auth=Depends(require_ticket_auth),
|
||
):
|
||
e = await _get_env_or_404(db, env_id)
|
||
if body.prefix is not None:
|
||
e.prefix = body.prefix.strip().upper()
|
||
if body.name is not None:
|
||
e.name = body.name.strip()
|
||
if body.description is not None:
|
||
e.description = body.description
|
||
await db.commit()
|
||
await db.refresh(e)
|
||
return _env_dict(e)
|
||
|
||
@router.delete("/api/environments/{env_id}")
|
||
async def delete_environment(
|
||
env_id: int,
|
||
db: AsyncSession = Depends(get_db),
|
||
_auth=Depends(require_ticket_auth),
|
||
):
|
||
e = await _get_env_or_404(db, env_id)
|
||
count = (await db.execute(
|
||
select(func.count()).select_from(Ticket).where(Ticket.environment_id == env_id)
|
||
)).scalar()
|
||
if count:
|
||
raise HTTPException(409, f"environment has {count} ticket(s); reassign before deleting")
|
||
await db.delete(e)
|
||
await db.commit()
|
||
return {"ok": True}
|
||
|
||
# ── Ticket endpoints ──────────────────────────────────────────────────────
|
||
|
||
@router.get("/api/tickets")
|
||
async def list_tickets(
|
||
status: list[str] = Query(default=[]),
|
||
type: list[str] = Query(default=[]),
|
||
q: str = Query(default=""),
|
||
environment_id: int | None = Query(default=None),
|
||
db: AsyncSession = Depends(get_db),
|
||
_auth=Depends(require_ticket_auth),
|
||
):
|
||
stmt = select(Ticket).order_by(Ticket.submitted_at.desc())
|
||
if environment_id is not None:
|
||
stmt = stmt.where(Ticket.environment_id == environment_id)
|
||
if status:
|
||
stmt = stmt.where(Ticket.status.in_(status))
|
||
if type:
|
||
stmt = stmt.where(Ticket.type.in_(type))
|
||
if q:
|
||
term = f"%{q}%"
|
||
stmt = stmt.where(or_(Ticket.title.ilike(term), Ticket.description.ilike(term)))
|
||
rows = (await db.execute(stmt)).scalars().all()
|
||
return [await _ticket_dict(t, db) for t in rows]
|
||
|
||
@router.post("/api/tickets", status_code=201)
|
||
async def create_ticket(
|
||
body: TicketCreate,
|
||
db: AsyncSession = Depends(get_db),
|
||
_auth=Depends(require_ticket_auth),
|
||
):
|
||
if body.status not in VALID_STATUSES:
|
||
raise HTTPException(400, f"status must be one of {sorted(VALID_STATUSES)}")
|
||
if body.type not in VALID_TYPES:
|
||
raise HTTPException(400, f"type must be one of {sorted(VALID_TYPES)}")
|
||
if body.user not in VALID_USERS:
|
||
raise HTTPException(400, f"user must be one of {sorted(VALID_USERS)}")
|
||
if body.effort is not None and not (1 <= body.effort <= 10):
|
||
raise HTTPException(400, "effort must be between 1 and 10")
|
||
if body.parent_id is not None:
|
||
await _get_ticket_or_404(db, body.parent_id)
|
||
env_id = body.environment_id
|
||
if env_id is not None:
|
||
await _get_env_or_404(db, env_id)
|
||
else:
|
||
env_id = await _default_env_id(db)
|
||
t = Ticket(
|
||
guid=str(uuid.uuid4()),
|
||
user=body.user,
|
||
title=body.title.strip(),
|
||
description=body.description,
|
||
status=body.status,
|
||
type=body.type,
|
||
parent_id=body.parent_id,
|
||
effort=body.effort,
|
||
startup_script=body.startup_script,
|
||
environment_id=env_id,
|
||
)
|
||
db.add(t)
|
||
await db.commit()
|
||
return await _ticket_dict(await _get_ticket_or_404(db, t.id), db)
|
||
|
||
@router.get("/api/tickets/{ticket_id}")
|
||
async def get_ticket(
|
||
ticket_id: int,
|
||
db: AsyncSession = Depends(get_db),
|
||
_auth=Depends(require_ticket_auth),
|
||
):
|
||
return await _ticket_dict(await _get_ticket_or_404(db, ticket_id), db)
|
||
|
||
@router.patch("/api/tickets/{ticket_id}")
|
||
async def update_ticket(
|
||
ticket_id: int,
|
||
body: TicketUpdate,
|
||
db: AsyncSession = Depends(get_db),
|
||
_auth=Depends(require_ticket_auth),
|
||
):
|
||
t = await _get_ticket_or_404(db, ticket_id)
|
||
if body.status is not None:
|
||
if body.status not in VALID_STATUSES:
|
||
raise HTTPException(400, f"status must be one of {sorted(VALID_STATUSES)}")
|
||
t.status = body.status
|
||
if body.type is not None:
|
||
if body.type not in VALID_TYPES:
|
||
raise HTTPException(400, f"type must be one of {sorted(VALID_TYPES)}")
|
||
t.type = body.type
|
||
if body.user is not None:
|
||
if body.user not in VALID_USERS:
|
||
raise HTTPException(400, f"user must be one of {sorted(VALID_USERS)}")
|
||
t.user = body.user
|
||
if body.title is not None:
|
||
t.title = body.title.strip()
|
||
if body.description is not None:
|
||
t.description = body.description
|
||
if "parent_id" in body.model_fields_set:
|
||
if body.parent_id is not None:
|
||
if body.parent_id == ticket_id:
|
||
raise HTTPException(400, "ticket cannot be its own parent")
|
||
await _get_ticket_or_404(db, body.parent_id)
|
||
# Detect cycles of any depth before setting parent
|
||
ancestor_id = body.parent_id
|
||
visited: set[int] = set()
|
||
while ancestor_id is not None:
|
||
if ancestor_id == ticket_id:
|
||
raise HTTPException(400, "setting this parent would create a cycle")
|
||
if ancestor_id in visited:
|
||
break
|
||
visited.add(ancestor_id)
|
||
ancestor = (await db.execute(
|
||
select(Ticket).where(Ticket.id == ancestor_id)
|
||
)).scalar_one_or_none()
|
||
ancestor_id = ancestor.parent_id if ancestor else None
|
||
t.parent_id = body.parent_id
|
||
if "effort" in body.model_fields_set:
|
||
if body.effort is not None and not (1 <= body.effort <= 10):
|
||
raise HTTPException(400, "effort must be between 1 and 10")
|
||
t.effort = body.effort
|
||
if "startup_script" in body.model_fields_set:
|
||
t.startup_script = body.startup_script
|
||
if "environment_id" in body.model_fields_set and body.environment_id is not None:
|
||
await _get_env_or_404(db, body.environment_id)
|
||
t.environment_id = body.environment_id
|
||
await db.commit()
|
||
return await _ticket_dict(await _get_ticket_or_404(db, ticket_id), db)
|
||
|
||
@router.delete("/api/tickets/{ticket_id}")
|
||
async def delete_ticket(
|
||
ticket_id: int,
|
||
db: AsyncSession = Depends(get_db),
|
||
_auth=Depends(require_ticket_auth),
|
||
):
|
||
t = await _get_ticket_or_404(db, ticket_id)
|
||
await db.delete(t)
|
||
await db.commit()
|
||
return {"ok": True}
|
||
|
||
# ── Attachment endpoints ──────────────────────────────────────────────────
|
||
|
||
@router.get("/api/tickets/{ticket_id}/attachments")
|
||
async def list_attachments(
|
||
ticket_id: int,
|
||
db: AsyncSession = Depends(get_db),
|
||
_auth=Depends(require_ticket_auth),
|
||
):
|
||
await _get_ticket_or_404(db, ticket_id)
|
||
rows = (await db.execute(
|
||
select(TicketAttachment)
|
||
.where(TicketAttachment.ticket_id == ticket_id)
|
||
.order_by(TicketAttachment.created_at)
|
||
)).scalars().all()
|
||
return [_att_dict(a) for a in rows]
|
||
|
||
@router.post("/api/tickets/{ticket_id}/attachments", status_code=201)
|
||
async def create_attachment(
|
||
ticket_id: int,
|
||
body: AttachmentCreate,
|
||
db: AsyncSession = Depends(get_db),
|
||
_auth=Depends(require_ticket_auth),
|
||
):
|
||
await _get_ticket_or_404(db, ticket_id)
|
||
if not body.title.strip():
|
||
raise HTTPException(400, "title is required")
|
||
if not body.content.strip():
|
||
raise HTTPException(400, "content is required")
|
||
if body.created_by not in VALID_USERS:
|
||
raise HTTPException(400, f"created_by must be one of {sorted(VALID_USERS)}")
|
||
a = TicketAttachment(
|
||
ticket_id=ticket_id,
|
||
title=body.title.strip(),
|
||
content=body.content,
|
||
created_by=body.created_by,
|
||
)
|
||
db.add(a)
|
||
await db.commit()
|
||
await db.refresh(a)
|
||
return _att_dict(a)
|
||
|
||
@router.patch("/api/tickets/{ticket_id}/attachments/{att_id}")
|
||
async def update_attachment(
|
||
ticket_id: int,
|
||
att_id: int,
|
||
body: AttachmentUpdate,
|
||
db: AsyncSession = Depends(get_db),
|
||
_auth=Depends(require_ticket_auth),
|
||
):
|
||
a = await db.get(TicketAttachment, att_id)
|
||
if not a or a.ticket_id != ticket_id:
|
||
raise HTTPException(404, "attachment not found")
|
||
if body.title is not None:
|
||
if not body.title.strip():
|
||
raise HTTPException(400, "title cannot be empty")
|
||
a.title = body.title.strip()
|
||
if body.content is not None:
|
||
if not body.content.strip():
|
||
raise HTTPException(400, "content cannot be empty")
|
||
a.content = body.content
|
||
await db.commit()
|
||
await db.refresh(a)
|
||
return _att_dict(a)
|
||
|
||
@router.delete("/api/tickets/{ticket_id}/attachments/{att_id}")
|
||
async def delete_attachment(
|
||
ticket_id: int,
|
||
att_id: int,
|
||
db: AsyncSession = Depends(get_db),
|
||
_auth=Depends(require_ticket_auth),
|
||
):
|
||
a = await db.get(TicketAttachment, att_id)
|
||
if not a or a.ticket_id != ticket_id:
|
||
raise HTTPException(404, "attachment not found")
|
||
await db.delete(a)
|
||
await db.commit()
|
||
return {"ok": True}
|
||
|
||
return router
|