Files
breakpilot-compliance/backend-compliance/compliance/api/security_backlog_routes.py
Sharang Parnerkar cb90d0db0c chore(backend): deprecation sweep — Pydantic V1 -> V2, utcnow -> tz-aware
Two low-risk Pydantic V1 idioms that will be hard errors in V3:
  - Query(regex=...) -> Query(pattern=...) (audit_routes, control_generator_routes)
  - class Config: from_attributes=True -> model_config = ConfigDict(...)
    in source_policy_router.py (schemas.py is intentionally skipped — it is
    the Phase 1 schema-split target and the ConfigDict conversion is most
    efficient to do during that split).

Naive -> aware datetime sweep across 47 files:
  - datetime.utcnow() -> datetime.now(timezone.utc)
  - default=datetime.utcnow -> default=lambda: datetime.now(timezone.utc)
  - onupdate=datetime.utcnow -> onupdate=lambda: datetime.now(timezone.utc)

All SQLAlchemy DateTime columns in the project already declare
timezone=True, so the DB schema expects aware datetimes. Before this
commit, the in-Python side was generating naive values and the driver
was silently coercing them. This is a latent-bug fix, not a behavior
change at the DB boundary.

Verified:
  - 173/173 pytest compliance/tests/ pass (same as baseline)
  - tests/contracts/test_openapi_baseline.py passes (360 paths,
    484 operations unchanged)
  - DeprecationWarning count dropped from 158 -> 35

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:09:59 +02:00

246 lines
8.2 KiB
Python

"""
FastAPI routes for Security Backlog Tracking.
Endpoints:
GET /security-backlog — list with filters (status, severity, type, search; limit/offset)
GET /security-backlog/stats — open, critical, high, overdue counts
POST /security-backlog — create finding
PUT /security-backlog/{id} — update finding
DELETE /security-backlog/{id} — delete finding (204)
"""
import logging
from datetime import datetime, timezone
from typing import Optional, Any, Dict
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.orm import Session
from classroom_engine.database import get_db
from .tenant_utils import get_tenant_id as _get_tenant_id
from .db_utils import row_to_dict as _row_to_dict
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/security-backlog", tags=["security-backlog"])
# =============================================================================
# Pydantic Schemas
# =============================================================================
class SecurityItemCreate(BaseModel):
title: str
description: Optional[str] = None
type: str = "vulnerability"
severity: str = "medium"
status: str = "open"
source: Optional[str] = None
cve: Optional[str] = None
cvss: Optional[float] = None
affected_asset: Optional[str] = None
assigned_to: Optional[str] = None
due_date: Optional[datetime] = None
remediation: Optional[str] = None
class SecurityItemUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
type: Optional[str] = None
severity: Optional[str] = None
status: Optional[str] = None
source: Optional[str] = None
cve: Optional[str] = None
cvss: Optional[float] = None
affected_asset: Optional[str] = None
assigned_to: Optional[str] = None
due_date: Optional[datetime] = None
remediation: Optional[str] = None
# =============================================================================
# Routes
# =============================================================================
@router.get("")
async def list_security_items(
status: Optional[str] = Query(None),
severity: Optional[str] = Query(None),
type: Optional[str] = Query(None),
search: Optional[str] = Query(None),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
tenant_id: str = Depends(_get_tenant_id),
):
"""List security backlog items with optional filters."""
where_clauses = ["tenant_id = :tenant_id"]
params: Dict[str, Any] = {"tenant_id": tenant_id, "limit": limit, "offset": offset}
if status:
where_clauses.append("status = :status")
params["status"] = status
if severity:
where_clauses.append("severity = :severity")
params["severity"] = severity
if type:
where_clauses.append("type = :type")
params["type"] = type
if search:
where_clauses.append("(title ILIKE :search OR description ILIKE :search)")
params["search"] = f"%{search}%"
where_sql = " AND ".join(where_clauses)
total_row = db.execute(
text(f"SELECT COUNT(*) FROM compliance_security_backlog WHERE {where_sql}"),
params,
).fetchone()
total = total_row[0] if total_row else 0
rows = db.execute(
text(f"""
SELECT * FROM compliance_security_backlog
WHERE {where_sql}
ORDER BY
CASE severity
WHEN 'critical' THEN 0
WHEN 'high' THEN 1
WHEN 'medium' THEN 2
ELSE 3
END,
CASE status
WHEN 'open' THEN 0
WHEN 'in-progress' THEN 1
WHEN 'accepted-risk' THEN 2
ELSE 3
END,
created_at DESC
LIMIT :limit OFFSET :offset
"""),
params,
).fetchall()
return {
"items": [_row_to_dict(r) for r in rows],
"total": total,
}
@router.get("/stats")
async def get_security_stats(
db: Session = Depends(get_db),
tenant_id: str = Depends(_get_tenant_id),
):
"""Return security backlog counts."""
rows = db.execute(text("""
SELECT
COUNT(*) FILTER (WHERE status = 'open') AS open,
COUNT(*) FILTER (WHERE status = 'in-progress') AS in_progress,
COUNT(*) FILTER (WHERE status = 'resolved') AS resolved,
COUNT(*) FILTER (WHERE status = 'accepted-risk') AS accepted_risk,
COUNT(*) FILTER (WHERE severity = 'critical' AND status != 'resolved') AS critical,
COUNT(*) FILTER (WHERE severity = 'high' AND status != 'resolved') AS high,
COUNT(*) FILTER (
WHERE due_date IS NOT NULL
AND due_date < NOW()
AND status NOT IN ('resolved', 'accepted-risk')
) AS overdue,
COUNT(*) AS total
FROM compliance_security_backlog
WHERE tenant_id = :tenant_id
"""), {"tenant_id": tenant_id}).fetchone()
if rows:
d = dict(rows._mapping)
return {k: (v or 0) for k, v in d.items()}
return {"open": 0, "in_progress": 0, "resolved": 0, "accepted_risk": 0,
"critical": 0, "high": 0, "overdue": 0, "total": 0}
@router.post("", status_code=201)
async def create_security_item(
payload: SecurityItemCreate,
db: Session = Depends(get_db),
tenant_id: str = Depends(_get_tenant_id),
):
"""Create a new security backlog item."""
row = db.execute(text("""
INSERT INTO compliance_security_backlog
(tenant_id, title, description, type, severity, status,
source, cve, cvss, affected_asset, assigned_to, due_date, remediation)
VALUES
(:tenant_id, :title, :description, :type, :severity, :status,
:source, :cve, :cvss, :affected_asset, :assigned_to, :due_date, :remediation)
RETURNING *
"""), {
"tenant_id": tenant_id,
"title": payload.title,
"description": payload.description,
"type": payload.type,
"severity": payload.severity,
"status": payload.status,
"source": payload.source,
"cve": payload.cve,
"cvss": payload.cvss,
"affected_asset": payload.affected_asset,
"assigned_to": payload.assigned_to,
"due_date": payload.due_date,
"remediation": payload.remediation,
}).fetchone()
db.commit()
return _row_to_dict(row)
@router.put("/{item_id}")
async def update_security_item(
item_id: str,
payload: SecurityItemUpdate,
db: Session = Depends(get_db),
tenant_id: str = Depends(_get_tenant_id),
):
"""Update a security backlog item."""
updates: Dict[str, Any] = {"id": item_id, "tenant_id": tenant_id, "updated_at": datetime.now(timezone.utc)}
set_clauses = ["updated_at = :updated_at"]
for field, value in payload.model_dump(exclude_unset=True).items():
updates[field] = value
set_clauses.append(f"{field} = :{field}")
if len(set_clauses) == 1:
raise HTTPException(status_code=400, detail="No fields to update")
row = db.execute(text(f"""
UPDATE compliance_security_backlog
SET {', '.join(set_clauses)}
WHERE id = :id AND tenant_id = :tenant_id
RETURNING *
"""), updates).fetchone()
db.commit()
if not row:
raise HTTPException(status_code=404, detail="Security item not found")
return _row_to_dict(row)
@router.delete("/{item_id}", status_code=204)
async def delete_security_item(
item_id: str,
db: Session = Depends(get_db),
tenant_id: str = Depends(_get_tenant_id),
):
result = db.execute(text("""
DELETE FROM compliance_security_backlog
WHERE id = :id AND tenant_id = :tenant_id
"""), {"id": item_id, "tenant_id": tenant_id})
db.commit()
if result.rowcount == 0:
raise HTTPException(status_code=404, detail="Security item not found")