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:
0
backend/app/api/routes/__init__.py
Normal file
0
backend/app/api/routes/__init__.py
Normal file
49
backend/app/api/routes/ai.py
Normal file
49
backend/app/api/routes/ai.py
Normal 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}
|
||||
42
backend/app/api/routes/attack_paths.py
Normal file
42
backend/app/api/routes/attack_paths.py
Normal 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}
|
||||
37
backend/app/api/routes/auth.py
Normal file
37
backend/app/api/routes/auth.py
Normal 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)
|
||||
143
backend/app/api/routes/dashboard.py
Normal file
143
backend/app/api/routes/dashboard.py
Normal 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,
|
||||
)
|
||||
127
backend/app/api/routes/findings.py
Normal file
127
backend/app/api/routes/findings.py
Normal 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
|
||||
88
backend/app/api/routes/footprint.py
Normal file
88
backend/app/api/routes/footprint.py
Normal 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
|
||||
103
backend/app/api/routes/reports.py
Normal file
103
backend/app/api/routes/reports.py
Normal 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
|
||||
Reference in New Issue
Block a user