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
This commit is contained in:
drjones
2026-07-05 09:46:08 +00:00
commit 463dff883b
64 changed files with 11679 additions and 0 deletions

View File

@@ -0,0 +1,127 @@
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