feat(dashboard): Phase 13 — Executive Dashboard
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

PostureSnapshot model, Alembic migration (b039exec), schemas, service
aggregating all phases (coverage/risk/operations/knowledge/MTTD), and
router at /api/v1/dashboard with executive view, KPIs, coverage-by-tactic,
posture-history, posture-snapshot, and activity-feed endpoints.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kitos
2026-05-20 16:20:21 +02:00
parent 41a0c536bb
commit ab591d30c4
8 changed files with 997 additions and 0 deletions

View File

@@ -0,0 +1,77 @@
"""Phase 13: Executive Dashboard — posture_snapshots table.
Revision ID: b039exec
Revises: b038risk
Create Date: 2026-05-20
"""
from alembic import op
import sqlalchemy as sa
revision = "b039exec"
down_revision = "b038risk"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS posture_snapshots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
snapshot_date DATE NOT NULL,
-- Coverage
total_techniques INTEGER NOT NULL DEFAULT 0,
validated_count INTEGER NOT NULL DEFAULT 0,
partial_count INTEGER NOT NULL DEFAULT 0,
not_covered_count INTEGER NOT NULL DEFAULT 0,
coverage_pct FLOAT NOT NULL DEFAULT 0.0,
-- Risk
avg_risk_score FLOAT NOT NULL DEFAULT 0.0,
critical_count INTEGER NOT NULL DEFAULT 0,
high_count INTEGER NOT NULL DEFAULT 0,
medium_count INTEGER NOT NULL DEFAULT 0,
low_count INTEGER NOT NULL DEFAULT 0,
-- Operations
open_queue_items INTEGER NOT NULL DEFAULT 0,
orphan_techniques INTEGER NOT NULL DEFAULT 0,
-- Knowledge
playbook_count INTEGER NOT NULL DEFAULT 0,
lesson_count INTEGER NOT NULL DEFAULT 0,
-- MTTD
mttd_avg_seconds FLOAT,
executions_30d INTEGER NOT NULL DEFAULT 0,
detection_rate_30d FLOAT,
-- Meta
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now(),
extra JSONB
)
"""))
# Unique constraint: one snapshot per calendar day
conn.execute(sa.text("""
DO $$ BEGIN
ALTER TABLE posture_snapshots
ADD CONSTRAINT uq_posture_snapshot_date UNIQUE (snapshot_date);
EXCEPTION WHEN duplicate_table THEN NULL;
WHEN duplicate_object THEN NULL;
END $$
"""))
# Index for date-range trend queries
conn.execute(sa.text("""
CREATE INDEX IF NOT EXISTS ix_posture_snapshots_date
ON posture_snapshots (snapshot_date)
"""))
def downgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("DROP TABLE IF EXISTS posture_snapshots CASCADE"))

View File

@@ -43,6 +43,7 @@ from app.routers import ownership as ownership_router
from app.routers import attack_paths as attack_paths_router
from app.routers import knowledge as knowledge_router
from app.routers import risk_intelligence as risk_router
from app.routers import executive_dashboard as dashboard_router
from app.domain.errors import DomainError
from app.middleware.error_handler import domain_exception_handler
from app.middleware.request_context import RequestContextMiddleware
@@ -145,6 +146,7 @@ app.include_router(ownership_router.router, prefix="/api/v1")
app.include_router(attack_paths_router.router, prefix="/api/v1")
app.include_router(knowledge_router.router, prefix="/api/v1")
app.include_router(risk_router.router, prefix="/api/v1")
app.include_router(dashboard_router.router, prefix="/api/v1")
@app.get("/health", include_in_schema=False)

View File

@@ -40,6 +40,7 @@ from app.models.attack_path import (
)
from app.models.knowledge import Playbook, PlaybookVersion, LessonLearned
from app.models.risk_intelligence import TechniqueRiskProfile
from app.models.executive_dashboard import PostureSnapshot
__all__ = [
"User", "Technique", "Test", "TestTemplate", "Evidence",
@@ -63,4 +64,5 @@ __all__ = [
"ExecutionStatus", "StepResultStatus", "TimelineActorSide", "TimelineEntryType",
"Playbook", "PlaybookVersion", "LessonLearned",
"TechniqueRiskProfile",
"PostureSnapshot",
]

View File

@@ -0,0 +1,68 @@
"""Phase 13: Executive Dashboard — PostureSnapshot model."""
import uuid
from datetime import datetime
from sqlalchemy import (
Boolean, Column, Date, DateTime, Float, ForeignKey,
Index, Integer, UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from app.database import Base
class PostureSnapshot(Base):
"""
Daily point-in-time capture of the organisation's security posture.
Aggregates data from all phases (coverage, risk, ownership, knowledge,
attack-paths) into a single row that can be trended over time.
"""
__tablename__ = "posture_snapshots"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
snapshot_date = Column(Date, nullable=False) # one per calendar day
# ── Coverage ──────────────────────────────────────────────────────────────
total_techniques = Column(Integer, nullable=False, default=0)
validated_count = Column(Integer, nullable=False, default=0)
partial_count = Column(Integer, nullable=False, default=0)
not_covered_count = Column(Integer, nullable=False, default=0)
coverage_pct = Column(Float, nullable=False, default=0.0) # 0100
# ── Risk ─────────────────────────────────────────────────────────────────
avg_risk_score = Column(Float, nullable=False, default=0.0)
critical_count = Column(Integer, nullable=False, default=0)
high_count = Column(Integer, nullable=False, default=0)
medium_count = Column(Integer, nullable=False, default=0)
low_count = Column(Integer, nullable=False, default=0)
# ── Operations ────────────────────────────────────────────────────────────
open_queue_items = Column(Integer, nullable=False, default=0)
orphan_techniques = Column(Integer, nullable=False, default=0)
# ── Knowledge ─────────────────────────────────────────────────────────────
playbook_count = Column(Integer, nullable=False, default=0)
lesson_count = Column(Integer, nullable=False, default=0)
# ── MTTD (from attack-path executions completed in last 30 d) ────────────
mttd_avg_seconds = Column(Float, nullable=True) # None if no data
executions_30d = Column(Integer, nullable=False, default=0)
detection_rate_30d = Column(Float, nullable=True) # avg across executions
# ── Meta ─────────────────────────────────────────────────────────────────
created_by = Column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
created_at = Column(DateTime, default=datetime.utcnow)
extra = Column(JSONB, nullable=True) # full breakdown / by-tactic
creator = relationship("User", foreign_keys=[created_by])
__table_args__ = (
UniqueConstraint("snapshot_date", name="uq_posture_snapshot_date"),
Index("ix_posture_snapshots_date", "snapshot_date"),
)

View File

@@ -0,0 +1,124 @@
"""Phase 13: Executive Dashboard router."""
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies.auth import get_current_user, require_any_role
from app.schemas.executive_dashboard_schema import (
PostureSnapshotOut,
ExecutiveSummary,
KpiBlock,
CoverageByTactic,
PostureHistoryEntry,
ActivityEntry,
)
import app.services.executive_dashboard_service as svc
router = APIRouter(prefix="/dashboard", tags=["Executive Dashboard"])
@router.get("/executive", response_model=ExecutiveSummary)
def executive_view(
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""
Full executive view — snapshot, 30-day trends, top risks,
coverage by tactic, and recent activity feed.
"""
data = svc.get_executive_summary(db)
snap = data["snapshot"]
return ExecutiveSummary(
snapshot=PostureSnapshotOut.model_validate(snap),
coverage_trend=data["coverage_trend"],
risk_trend=data["risk_trend"],
top_risks=data["top_risks"],
coverage_by_tactic=data["coverage_by_tactic"],
recent_activity=data["recent_activity"],
)
@router.get("/kpis", response_model=KpiBlock)
def kpis(
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Compact KPI block — live aggregation without persisting a snapshot."""
live = svc.get_live_kpis(db)
# Try to find today's snapshot id; fall back to None
from datetime import date
from app.models.executive_dashboard import PostureSnapshot
today_snap = db.query(PostureSnapshot).filter(
PostureSnapshot.snapshot_date == date.today()
).first()
return KpiBlock(
coverage_pct=live["coverage_pct"],
avg_risk_score=live["avg_risk_score"],
critical_count=live["critical_count"],
open_queue_items=live["open_queue_items"],
orphan_techniques=live["orphan_techniques"],
mttd_avg_seconds=live.get("mttd_avg_seconds"),
detection_rate_30d=live.get("detection_rate_30d"),
playbook_count=live["playbook_count"],
lesson_count=live["lesson_count"],
snapshot_date=live["snapshot_date"],
snapshot_id=today_snap.id if today_snap else None,
)
@router.get("/coverage-by-tactic", response_model=List[CoverageByTactic])
def coverage_by_tactic(
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Per-tactic validated / partial / not_covered breakdown."""
return svc.get_coverage_by_tactic(db)
@router.get("/posture-history", response_model=List[PostureHistoryEntry])
def posture_history(
days: int = Query(30, ge=1, le=365),
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Historical posture snapshots for trend charts (default last 30 days)."""
snaps = svc.get_posture_history(db, days=days)
return [
PostureHistoryEntry(
snapshot_date=s.snapshot_date,
coverage_pct=s.coverage_pct,
avg_risk_score=s.avg_risk_score,
critical_count=s.critical_count,
open_queue_items=s.open_queue_items,
)
for s in snaps
]
@router.post("/posture-snapshot", response_model=PostureSnapshotOut, status_code=201)
def create_posture_snapshot(
db: Session = Depends(get_db),
user=Depends(require_any_role("admin", "red_lead", "blue_lead")),
):
"""
Take (or refresh) today's posture snapshot — admin / leads only.
Aggregates live data from all phases into a single PostureSnapshot row.
"""
snap = svc.take_posture_snapshot(db, created_by=user.id)
return PostureSnapshotOut.model_validate(snap)
@router.get("/activity", response_model=List[ActivityEntry])
def recent_activity(
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Recent activity feed — tests, attack-path executions, OSINT signals."""
return svc.get_recent_activity(db, limit=limit)

View File

@@ -0,0 +1,113 @@
"""Phase 13: Executive Dashboard — Pydantic schemas."""
from __future__ import annotations
from datetime import date, datetime
from typing import Any, Dict, List, Optional
from uuid import UUID
from pydantic import BaseModel, Field
class PostureSnapshotOut(BaseModel):
id: UUID
snapshot_date: date
# Coverage
total_techniques: int
validated_count: int
partial_count: int
not_covered_count: int
coverage_pct: float
# Risk
avg_risk_score: float
critical_count: int
high_count: int
medium_count: int
low_count: int
# Operations
open_queue_items: int
orphan_techniques: int
# Knowledge
playbook_count: int
lesson_count: int
# MTTD
mttd_avg_seconds: Optional[float] = None
executions_30d: int
detection_rate_30d: Optional[float] = None
# Meta
created_by: Optional[UUID] = None
created_at: Optional[datetime] = None
extra: Optional[Dict[str, Any]] = None
class Config:
from_attributes = True
class ExecutiveSummary(BaseModel):
"""Full executive view — current posture + trends."""
snapshot: PostureSnapshotOut
coverage_trend: List[Dict[str, Any]] = Field(
default_factory=list,
description="Last 30-day coverage_pct series [{date, value}]",
)
risk_trend: List[Dict[str, Any]] = Field(
default_factory=list,
description="Last 30-day avg_risk_score series [{date, value}]",
)
top_risks: List[Dict[str, Any]] = Field(
default_factory=list,
description="Top 5 highest-risk techniques",
)
coverage_by_tactic: List[Dict[str, Any]] = Field(
default_factory=list,
description="Per-tactic validated/partial/not_covered counts",
)
recent_activity: List[Dict[str, Any]] = Field(
default_factory=list,
description="Most-recent events (tests, paths, queue changes)",
)
class KpiBlock(BaseModel):
"""Compact KPI block for a dashboard header."""
coverage_pct: float
avg_risk_score: float
critical_count: int
open_queue_items: int
orphan_techniques: int
mttd_avg_seconds: Optional[float] = None
detection_rate_30d: Optional[float] = None
playbook_count: int
lesson_count: int
snapshot_date: date
snapshot_id: UUID
class CoverageByTactic(BaseModel):
tactic: str
total: int
validated: int
partial: int
not_covered: int
coverage_pct: float
class PostureHistoryEntry(BaseModel):
snapshot_date: date
coverage_pct: float
avg_risk_score: float
critical_count: int
open_queue_items: int
class ActivityEntry(BaseModel):
ts: datetime
category: str # "test" | "attack_path" | "queue" | "osint"
title: str
detail: Optional[str] = None

View File

@@ -0,0 +1,361 @@
"""Phase 13: Executive Dashboard service — aggregate posture data across all phases."""
from __future__ import annotations
import time
from datetime import date, datetime, timedelta
from typing import List, Optional
from uuid import UUID
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.models.executive_dashboard import PostureSnapshot
from app.models.technique import Technique
from app.models.risk_intelligence import TechniqueRiskProfile
from app.models.ownership_queue import (
TechniqueOwnership, RevalidationQueueItem, QueueStatus,
)
from app.models.knowledge import Playbook, LessonLearned
from app.models.attack_path import AttackPathExecution, ExecutionStatus
from app.models.test import Test
from app.models.osint_item import OsintItem
from app.models.enums import TechniqueStatus
# ── Internal aggregation helpers ──────────────────────────────────────────────
def _aggregate_coverage(db: Session) -> dict:
"""Aggregate technique coverage counts from live data."""
techniques = db.query(Technique).all()
total = len(techniques)
counts = {
TechniqueStatus.validated: 0,
TechniqueStatus.partial: 0,
TechniqueStatus.not_covered: 0,
}
for t in techniques:
s = t.status_global
if s in counts:
counts[s] += 1
validated = counts[TechniqueStatus.validated]
partial = counts[TechniqueStatus.partial]
not_covered = total - validated - partial
coverage_pct = round((validated + partial * 0.5) / total * 100.0, 2) if total > 0 else 0.0
return {
"total_techniques": total,
"validated_count": validated,
"partial_count": partial,
"not_covered_count": not_covered,
"coverage_pct": coverage_pct,
}
def _aggregate_risk(db: Session) -> dict:
"""Aggregate risk metrics from TechniqueRiskProfile."""
profiles = db.query(TechniqueRiskProfile).all()
if not profiles:
return {
"avg_risk_score": 0.0,
"critical_count": 0,
"high_count": 0,
"medium_count": 0,
"low_count": 0,
}
by_level = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
score_sum = 0.0
for p in profiles:
score_sum += p.risk_score
lvl = p.risk_level or "info"
by_level[lvl] = by_level.get(lvl, 0) + 1
return {
"avg_risk_score": round(score_sum / len(profiles), 2),
"critical_count": by_level["critical"],
"high_count": by_level["high"],
"medium_count": by_level["medium"],
"low_count": by_level["low"],
}
def _aggregate_operations(db: Session) -> dict:
"""Aggregate operational queue and orphan counts."""
open_queue = db.query(RevalidationQueueItem).filter(
RevalidationQueueItem.status.in_([QueueStatus.pending, QueueStatus.in_progress]),
).count()
# Orphan = technique with no ownership record OR owner_id IS NULL
owned_technique_ids = (
db.query(TechniqueOwnership.technique_id)
.filter(TechniqueOwnership.owner_id.isnot(None))
.subquery()
)
total_tech = db.query(Technique).count()
owned_count = db.query(TechniqueOwnership).filter(
TechniqueOwnership.owner_id.isnot(None)
).count()
orphans = total_tech - owned_count
return {
"open_queue_items": open_queue,
"orphan_techniques": max(orphans, 0),
}
def _aggregate_knowledge(db: Session) -> dict:
"""Count active playbooks and lessons learned."""
playbook_count = db.query(Playbook).filter(Playbook.is_active == True).count()
lesson_count = db.query(LessonLearned).filter(LessonLearned.is_active == True).count()
return {
"playbook_count": playbook_count,
"lesson_count": lesson_count,
}
def _aggregate_mttd(db: Session) -> dict:
"""Aggregate MTTD from completed attack-path executions in the last 30 days."""
cutoff = datetime.utcnow() - timedelta(days=30)
execs = db.query(AttackPathExecution).filter(
AttackPathExecution.status == ExecutionStatus.completed,
AttackPathExecution.completed_at >= cutoff,
).all()
count = len(execs)
mttd_values = [e.mttd_seconds for e in execs if e.mttd_seconds is not None]
dr_values = [e.detection_rate for e in execs if e.detection_rate is not None]
return {
"executions_30d": count,
"mttd_avg_seconds": round(sum(mttd_values) / len(mttd_values), 2) if mttd_values else None,
"detection_rate_30d": round(sum(dr_values) / len(dr_values), 4) if dr_values else None,
}
def _build_extra_breakdown(db: Session) -> dict:
"""Build the by-tactic breakdown stored in the `extra` JSONB field."""
techniques = db.query(Technique).all()
tactic_map: dict = {}
for t in techniques:
tac = t.tactic or "Unknown"
if tac not in tactic_map:
tactic_map[tac] = {"total": 0, "validated": 0, "partial": 0, "not_covered": 0}
tactic_map[tac]["total"] += 1
s = t.status_global
if s == TechniqueStatus.validated:
tactic_map[tac]["validated"] += 1
elif s == TechniqueStatus.partial:
tactic_map[tac]["partial"] += 1
else:
tactic_map[tac]["not_covered"] += 1
coverage_by_tactic = [
{
"tactic": tac,
"total": v["total"],
"validated": v["validated"],
"partial": v["partial"],
"not_covered": v["not_covered"],
"coverage_pct": round(
(v["validated"] + v["partial"] * 0.5) / v["total"] * 100.0, 2
) if v["total"] > 0 else 0.0,
}
for tac, v in sorted(tactic_map.items())
]
return {"coverage_by_tactic": coverage_by_tactic}
# ── Snapshot persistence ───────────────────────────────────────────────────────
def take_posture_snapshot(
db: Session,
created_by: Optional[UUID] = None,
) -> PostureSnapshot:
"""
Aggregate all phases and write (or update) today's PostureSnapshot.
Upserts on snapshot_date — only one row per calendar day.
"""
today = date.today()
coverage = _aggregate_coverage(db)
risk = _aggregate_risk(db)
operations = _aggregate_operations(db)
knowledge = _aggregate_knowledge(db)
mttd = _aggregate_mttd(db)
extra = _build_extra_breakdown(db)
existing = db.query(PostureSnapshot).filter(
PostureSnapshot.snapshot_date == today,
).first()
values = {
**coverage,
**risk,
**operations,
**knowledge,
**mttd,
"extra": extra,
}
if existing:
for k, v in values.items():
setattr(existing, k, v)
existing.created_by = created_by
db.commit()
db.refresh(existing)
return existing
snap = PostureSnapshot(snapshot_date=today, created_by=created_by, **values)
db.add(snap)
db.commit()
db.refresh(snap)
return snap
# ── Live / read-only aggregations (no DB write) ───────────────────────────────
def get_live_kpis(db: Session) -> dict:
"""Return current KPIs without persisting a snapshot."""
coverage = _aggregate_coverage(db)
risk = _aggregate_risk(db)
operations = _aggregate_operations(db)
knowledge = _aggregate_knowledge(db)
mttd = _aggregate_mttd(db)
return {**coverage, **risk, **operations, **knowledge, **mttd, "snapshot_date": date.today()}
def get_coverage_by_tactic(db: Session) -> list:
"""Per-tactic validated/partial/not_covered breakdown."""
extra = _build_extra_breakdown(db)
return extra["coverage_by_tactic"]
def get_posture_history(
db: Session,
days: int = 30,
) -> List[PostureSnapshot]:
"""Return the last `days` PostureSnapshot rows ordered ascending."""
cutoff = date.today() - timedelta(days=days)
return (
db.query(PostureSnapshot)
.filter(PostureSnapshot.snapshot_date >= cutoff)
.order_by(PostureSnapshot.snapshot_date.asc())
.all()
)
def get_top_risks(db: Session, limit: int = 5) -> list:
"""Return top-N risk profiles with technique details."""
from app.models.risk_intelligence import TechniqueRiskProfile
rows = (
db.query(TechniqueRiskProfile, Technique)
.join(Technique, TechniqueRiskProfile.technique_id == Technique.id)
.order_by(TechniqueRiskProfile.risk_score.desc())
.limit(limit)
.all()
)
return [
{
"technique_id": str(p.technique_id),
"technique_name": t.name,
"technique_tid": t.mitre_id,
"tactic": t.tactic,
"risk_score": p.risk_score,
"risk_level": p.risk_level,
"likelihood": p.likelihood,
"impact": p.impact,
"detection_gap": p.detection_gap,
}
for p, t in rows
]
def get_recent_activity(db: Session, limit: int = 20) -> list:
"""Combine recent events from tests, attack-path executions, queue, and OSINT."""
events: list = []
# Recent test executions (use execution_date, fall back to created_at)
recent_tests = (
db.query(Test)
.filter(Test.result.isnot(None))
.order_by(Test.created_at.desc())
.limit(limit)
.all()
)
for t in recent_tests:
ts = t.execution_date or t.created_at
events.append({
"ts": ts,
"category": "test",
"title": f"Test executed — result: {t.result.value if t.result else 'pending'}",
"detail": str(t.id),
})
# Recent completed attack-path executions
recent_execs = (
db.query(AttackPathExecution)
.filter(
AttackPathExecution.status == ExecutionStatus.completed,
AttackPathExecution.completed_at.isnot(None),
)
.order_by(AttackPathExecution.completed_at.desc())
.limit(limit // 2)
.all()
)
for e in recent_execs:
dr = f"{e.detection_rate * 100:.0f}%" if e.detection_rate is not None else "n/a"
events.append({
"ts": e.completed_at,
"category": "attack_path",
"title": f"Attack path completed — detection: {dr}",
"detail": str(e.id),
})
# Recent OSINT items
recent_osint = (
db.query(OsintItem)
.order_by(OsintItem.discovered_at.desc())
.limit(limit // 4)
.all()
)
for o in recent_osint:
events.append({
"ts": o.discovered_at,
"category": "osint",
"title": f"OSINT signal: {o.title or 'unknown'}",
"detail": str(o.id),
})
# Sort all events descending by timestamp, return top `limit`
events.sort(key=lambda x: x["ts"] or datetime.min, reverse=True)
return events[:limit]
def get_executive_summary(db: Session) -> dict:
"""Full executive view — live KPIs + snapshot + trends + top risks + activity."""
# Take (or update) today's snapshot
snap = take_posture_snapshot(db)
# 30-day trend
history = get_posture_history(db, days=30)
coverage_trend = [
{"date": str(s.snapshot_date), "value": s.coverage_pct}
for s in history
]
risk_trend = [
{"date": str(s.snapshot_date), "value": s.avg_risk_score}
for s in history
]
return {
"snapshot": snap,
"coverage_trend": coverage_trend,
"risk_trend": risk_trend,
"top_risks": get_top_risks(db),
"coverage_by_tactic": get_coverage_by_tactic(db),
"recent_activity": get_recent_activity(db),
}