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

View File

@@ -0,0 +1,49 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.db.session import get_db
from app.models.models import Finding
from app.core.security import require_it_or_above
router = APIRouter(prefix="/ai", tags=["ai"])
@router.post("/translate/{finding_id}")
async def translate_finding(
finding_id: str,
payload: dict = Depends(require_it_or_above),
db: AsyncSession = Depends(get_db),
):
"""Trigger or re-trigger AI translation for a specific finding."""
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")
from app.services.ai_translator import translate_finding_async
import asyncio
asyncio.create_task(translate_finding_async(finding_id))
return {"status": "queued", "finding_id": finding_id}
@router.get("/explain/{finding_id}")
async def explain_finding(
finding_id: str,
question: str = Query(default="Why does this matter to our business?"),
payload: dict = Depends(require_it_or_above),
db: AsyncSession = Depends(get_db),
):
"""AI Security Coach: answer a specific question about a finding."""
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")
from app.services.ai_translator import answer_finding_question
answer = await answer_finding_question(finding, question)
return {"question": question, "answer": answer, "finding_id": finding_id}

View File

@@ -0,0 +1,42 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from app.db.session import get_db
from app.models.models import Finding, AttackPath
from app.schemas.schemas import AttackPathOut
from app.core.security import require_executive_or_above
router = APIRouter(prefix="/attack-paths", tags=["attack-paths"])
@router.get("/{finding_id}", response_model=List[AttackPathOut])
async def get_attack_paths(
finding_id: str,
payload: dict = Depends(require_executive_or_above),
db: AsyncSession = Depends(get_db),
):
# Verify tenant access
f_result = await db.execute(select(Finding).where(Finding.id == finding_id))
finding = f_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")
result = await db.execute(select(AttackPath).where(AttackPath.finding_id == finding_id))
return result.scalars().all()
@router.post("/{finding_id}/generate")
async def generate_attack_path(
finding_id: str,
payload: dict = Depends(require_executive_or_above),
db: AsyncSession = Depends(get_db),
):
"""Trigger AI generation of attack path narrative for a finding."""
from app.services.ai_translator import generate_attack_path_narrative
import asyncio
asyncio.create_task(generate_attack_path_narrative(finding_id))
return {"status": "queued", "finding_id": finding_id}

View File

@@ -0,0 +1,37 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from datetime import datetime
from app.db.session import get_db
from app.models.models import User
from app.schemas.schemas import LoginRequest, TokenResponse, UserMe
from app.core.security import verify_password, create_access_token, get_current_user_payload
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.email == body.email, User.is_active == True))
user: User | None = result.scalar_one_or_none()
if not user or not verify_password(body.password, user.hashed_password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
user.last_login = datetime.utcnow()
await db.commit()
token = create_access_token({"sub": user.id, "role": user.role.value, "tenant_id": user.tenant_id})
return TokenResponse(
access_token=token,
role=user.role.value,
tenant_id=user.tenant_id,
full_name=user.full_name
)
@router.get("/me", response_model=UserMe)
async def get_me(payload: dict = Depends(get_current_user_payload), db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.id == payload["sub"]))
user: User | None = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return UserMe(id=user.id, email=user.email, full_name=user.full_name, role=user.role, tenant_id=user.tenant_id)

View File

@@ -0,0 +1,143 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from datetime import datetime, timedelta
from typing import List
from app.db.session import get_db
from app.models.models import Finding, RiskScore, AuditReport, FindingStatus
from app.schemas.schemas import DashboardResponse, RiskCardData
from app.core.security import require_executive_or_above
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@router.get("/{tenant_id}", response_model=DashboardResponse)
async def get_dashboard(
tenant_id: str,
payload: dict = Depends(require_executive_or_above),
db: AsyncSession = Depends(get_db),
):
# Enforce tenant isolation
if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != tenant_id:
raise HTTPException(status_code=403, detail="Access denied")
# Current score (most recent)
score_result = await db.execute(
select(RiskScore)
.where(RiskScore.tenant_id == tenant_id)
.order_by(desc(RiskScore.score_date))
.limit(1)
)
current_score_obj = score_result.scalar_one_or_none()
current_score = current_score_obj.overall_score if current_score_obj else 50.0
# Previous score (30 days ago) for delta
prev_result = await db.execute(
select(RiskScore)
.where(
RiskScore.tenant_id == tenant_id,
RiskScore.score_date <= datetime.utcnow() - timedelta(days=30)
)
.order_by(desc(RiskScore.score_date))
.limit(1)
)
prev_score_obj = prev_result.scalar_one_or_none()
prev_score = prev_score_obj.overall_score if prev_score_obj else None
delta = round(current_score - prev_score, 1) if prev_score else None
# 90-day trend
trend_result = await db.execute(
select(RiskScore)
.where(
RiskScore.tenant_id == tenant_id,
RiskScore.score_date >= datetime.utcnow() - timedelta(days=90)
)
.order_by(RiskScore.score_date)
)
trend_scores = trend_result.scalars().all()
score_trend = [
{"date": rs.score_date.strftime("%Y-%m-%d"), "score": rs.overall_score}
for rs in trend_scores
]
# Top 3 risks
top_result = await db.execute(
select(Finding)
.where(
Finding.tenant_id == tenant_id,
Finding.is_top_risk == True,
Finding.status.in_([FindingStatus.open, FindingStatus.in_progress])
)
.order_by(Finding.created_at)
.limit(3)
)
top_findings = top_result.scalars().all()
# If no manually-flagged top risks, fall back to open criticals
if not top_findings:
fallback = await db.execute(
select(Finding)
.where(
Finding.tenant_id == tenant_id,
Finding.status.in_([FindingStatus.open, FindingStatus.in_progress])
)
.order_by(Finding.created_at)
.limit(3)
)
top_findings = fallback.scalars().all()
top_risks = [
RiskCardData(
id=f.id,
title=f.title,
ai_summary=f.ai_summary,
ai_business_impact=f.ai_business_impact,
ai_impact_level=f.ai_impact_level,
ai_fix_priority=f.ai_fix_priority,
severity=f.severity.value,
category=f.category.value,
)
for f in top_findings
]
# Open finding counts
counts_result = await db.execute(
select(Finding.severity, func.count(Finding.id))
.where(
Finding.tenant_id == tenant_id,
Finding.status.in_([FindingStatus.open, FindingStatus.in_progress])
)
.group_by(Finding.severity)
)
counts = {row[0].value: row[1] for row in counts_result}
total_open = sum(counts.values())
# Baseline
baseline_result = await db.execute(
select(AuditReport)
.where(AuditReport.tenant_id == tenant_id, AuditReport.is_baseline == True)
.order_by(AuditReport.report_date)
.limit(1)
)
baseline = baseline_result.scalar_one_or_none()
# Tenant name
from app.models.models import Tenant
t_result = await db.execute(select(Tenant).where(Tenant.id == tenant_id))
tenant = t_result.scalar_one_or_none()
return DashboardResponse(
tenant_name=tenant.name if tenant else "Unknown",
current_score=current_score,
previous_score=prev_score,
score_delta=delta,
score_trend=score_trend,
top_risks=top_risks,
open_critical=counts.get("critical", 0),
open_high=counts.get("high", 0),
open_medium=counts.get("medium", 0),
total_open=total_open,
baseline_score=baseline.baseline_score if baseline else None,
baseline_date=baseline.report_date if baseline else None,
)

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

View File

@@ -0,0 +1,88 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from app.db.session import get_db
from app.models.models import Finding, Executive, AuthorizedAsset
from app.schemas.schemas import AuthorizedAssetCreate, AuthorizedAssetOut
from app.core.security import require_admin, require_it_or_above
router = APIRouter(prefix="/footprint", tags=["footprint"])
@router.get("/{tenant_id}")
async def get_footprint(
tenant_id: str,
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")
# Digital footprint findings
from app.models.models import FindingCategory
fp_result = await db.execute(
select(Finding).where(
Finding.tenant_id == tenant_id,
Finding.category == FindingCategory.digital_footprint
)
)
footprint_findings = fp_result.scalars().all()
# Executives
exec_result = await db.execute(
select(Executive).where(Executive.tenant_id == tenant_id, Executive.is_enrolled == True)
)
executives = exec_result.scalars().all()
return {
"tenant_id": tenant_id,
"executives": [
{"id": e.id, "name": e.full_name, "title": e.title, "email": e.corporate_email}
for e in executives
],
"footprint_findings": [
{
"id": f.id,
"title": f.title,
"severity": f.severity.value,
"ai_summary": f.ai_summary,
"status": f.status.value,
"source": f.source,
}
for f in footprint_findings
],
"total_exposures": len(footprint_findings),
}
@router.get("/authorized-assets/{tenant_id}", response_model=List[AuthorizedAssetOut])
async def list_authorized_assets(
tenant_id: str,
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")
result = await db.execute(
select(AuthorizedAsset).where(
AuthorizedAsset.tenant_id == tenant_id,
AuthorizedAsset.is_active == True
)
)
return result.scalars().all()
@router.post("/authorized-assets/{tenant_id}", response_model=AuthorizedAssetOut, status_code=201)
async def add_authorized_asset(
tenant_id: str,
body: AuthorizedAssetCreate,
payload: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
asset = AuthorizedAsset(tenant_id=tenant_id, **body.model_dump())
db.add(asset)
await db.commit()
await db.refresh(asset)
return asset

View File

@@ -0,0 +1,103 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from typing import List
from datetime import datetime
import json
from app.db.session import get_db
from app.models.models import AuditReport, Finding, RiskScore, Executive, AuthorizedAsset, FindingStatus
from app.schemas.schemas import AuditReportOut, AuditReportCreate
from app.core.security import require_admin
router = APIRouter(prefix="/audit-reports", tags=["audit-reports"])
@router.get("", response_model=List[AuditReportOut])
async def list_reports(
tenant_id: str = Query(...),
payload: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(AuditReport)
.where(AuditReport.tenant_id == tenant_id)
.order_by(desc(AuditReport.report_date))
)
return result.scalars().all()
@router.post("/generate", response_model=AuditReportOut, status_code=201)
async def generate_audit_report(
tenant_id: str = Query(...),
body: AuditReportCreate = ...,
payload: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""Generate a Vault Audit Report snapshot for a tenant."""
# Get current score
score_result = await db.execute(
select(RiskScore)
.where(RiskScore.tenant_id == tenant_id)
.order_by(desc(RiskScore.score_date))
.limit(1)
)
latest_score = score_result.scalar_one_or_none()
# Snapshot top findings
findings_result = await db.execute(
select(Finding)
.where(
Finding.tenant_id == tenant_id,
Finding.status.in_([FindingStatus.open, FindingStatus.in_progress])
)
.order_by(Finding.created_at)
.limit(20)
)
top_findings = findings_result.scalars().all()
findings_snapshot = [
{
"id": f.id,
"title": f.title,
"severity": f.severity.value,
"category": f.category.value,
"ai_summary": f.ai_summary,
"ai_impact_level": f.ai_impact_level,
}
for f in top_findings
]
report = AuditReport(
tenant_id=tenant_id,
title=body.title,
report_date=datetime.utcnow(),
baseline_score=latest_score.overall_score if latest_score else None,
executive_summary=body.executive_summary,
scope_description=body.scope_description,
key_findings_json=json.dumps(findings_snapshot),
is_baseline=True,
generated_by=payload.get("sub"),
)
db.add(report)
await db.commit()
await db.refresh(report)
# Kick off PDF generation in background
from app.services.report_generator import generate_pdf_for_report
import asyncio
asyncio.create_task(generate_pdf_for_report(report.id))
return report
@router.get("/{report_id}", response_model=AuditReportOut)
async def get_report(
report_id: str,
payload: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(AuditReport).where(AuditReport.id == report_id))
report = result.scalar_one_or_none()
if not report:
raise HTTPException(status_code=404, detail="Report not found")
return report