Files
trustos/backend/app/api/routes/findings.py
drjones 463dff883b feat: initial TrustOS platform scaffold
- FastAPI backend: auth, findings, dashboard, attack paths, footprint, AI translator, risk calculator, PDF report generator
- Next.js frontend: Vault dashboard, login, findings table, finding detail with AI coach, digital footprint, reports
- PostgreSQL data model: tenants, users, assets, findings, risk scores, audit reports, attack paths
- Docker Compose + Dockerfiles for all services
- Demo seed data: Acme Corp with 6 findings and 90-day risk score history
- AI Risk Translator (OpenAI/Anthropic) with plain-English business impact
- Role-based access: executive / it_admin / trustos_admin
- Scope-lock engine: authorization required before any assessment

Stage 1-8 complete: Phase 1 Vault Audit product ready
2026-07-05 09:46:08 +00:00

128 lines
4.6 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from typing import List, Optional
from datetime import datetime
from app.db.session import get_db
from app.models.models import Finding, FindingStatus, FindingSeverity
from app.schemas.schemas import FindingOut, FindingCreate, FindingStatusUpdate
from app.core.security import require_executive_or_above, require_it_or_above
router = APIRouter(prefix="/findings", tags=["findings"])
@router.get("", response_model=List[FindingOut])
async def list_findings(
tenant_id: str = Query(...),
severity: Optional[str] = None,
status: Optional[str] = None,
category: Optional[str] = None,
limit: int = 50,
offset: int = 0,
payload: dict = Depends(require_executive_or_above),
db: AsyncSession = Depends(get_db),
):
if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != tenant_id:
raise HTTPException(status_code=403, detail="Access denied")
query = select(Finding).where(Finding.tenant_id == tenant_id)
if severity:
query = query.where(Finding.severity == severity)
if status:
query = query.where(Finding.status == status)
if category:
query = query.where(Finding.category == category)
query = query.order_by(desc(Finding.created_at)).limit(limit).offset(offset)
result = await db.execute(query)
return result.scalars().all()
@router.get("/{finding_id}", response_model=FindingOut)
async def get_finding(
finding_id: str,
payload: dict = Depends(require_executive_or_above),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Finding).where(Finding.id == finding_id))
finding = result.scalar_one_or_none()
if not finding:
raise HTTPException(status_code=404, detail="Finding not found")
if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != finding.tenant_id:
raise HTTPException(status_code=403, detail="Access denied")
return finding
@router.post("", response_model=FindingOut, status_code=201)
async def create_finding(
tenant_id: str = Query(...),
body: FindingCreate = ...,
payload: dict = Depends(require_it_or_above),
db: AsyncSession = Depends(get_db),
):
if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != tenant_id:
raise HTTPException(status_code=403, detail="Access denied")
finding = Finding(tenant_id=tenant_id, **body.model_dump())
db.add(finding)
await db.commit()
await db.refresh(finding)
return finding
@router.patch("/{finding_id}/status", response_model=FindingOut)
async def update_finding_status(
finding_id: str,
body: FindingStatusUpdate,
payload: dict = Depends(require_it_or_above),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Finding).where(Finding.id == finding_id))
finding = result.scalar_one_or_none()
if not finding:
raise HTTPException(status_code=404, detail="Finding not found")
if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != finding.tenant_id:
raise HTTPException(status_code=403, detail="Access denied")
finding.status = body.status
if body.resolution_note:
finding.resolution_note = body.resolution_note
if body.assignee_email:
finding.assignee_email = body.assignee_email
if body.due_date:
finding.due_date = body.due_date
if body.status == FindingStatus.resolved:
finding.resolved_at = datetime.utcnow()
if body.status == FindingStatus.verified:
finding.verified_at = datetime.utcnow()
if not body.resolution_note:
raise HTTPException(status_code=400, detail="A resolution note is required to verify a finding")
finding.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(finding)
# Trigger async risk score recalculation (fire and forget)
from app.services.risk_calculator import recalculate_risk_score
import asyncio
asyncio.create_task(recalculate_risk_score(finding.tenant_id))
return finding
@router.patch("/{finding_id}/top-risk", response_model=FindingOut)
async def toggle_top_risk(
finding_id: str,
is_top_risk: bool = Query(...),
payload: dict = Depends(require_it_or_above),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Finding).where(Finding.id == finding_id))
finding = result.scalar_one_or_none()
if not finding:
raise HTTPException(status_code=404, detail="Not found")
finding.is_top_risk = is_top_risk
await db.commit()
await db.refresh(finding)
return finding