- 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
128 lines
4.6 KiB
Python
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
|