feat(knowledge): Phase 11 — Knowledge Management (Playbooks + Lessons Learned) [FASE-11]
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
- Playbooks: versioned Markdown runbooks per technique × type (attack/detect/investigate/respond/hunt) - PlaybookVersion: immutable snapshots on every update; restore to any previous version - LessonLearned: post-mortem records linked to tests/campaigns/attack-paths or manual - Alembic migration b037know (raw SQL, idempotent, no PostgreSQL enums) - Router /api/v1/knowledge: 14 endpoints for playbooks + lessons + stats - Pydantic validators for playbook_type, severity, entity_type (422 on invalid) - Knowledge stats endpoint: totals + breakdown by severity and playbook type - Soft-delete on both resources; include_inactive filter for admin recovery - QA script: 70+ tests across CRUD, versioning, filtering, auth, soft-delete, regression Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
209
backend/app/routers/knowledge.py
Normal file
209
backend/app/routers/knowledge.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""Phase 11: Knowledge Management router — Playbooks + Lessons Learned."""
|
||||
|
||||
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.knowledge_schema import (
|
||||
PlaybookCreate, PlaybookUpdate, PlaybookOut, PlaybookVersionOut,
|
||||
LessonLearnedCreate, LessonLearnedUpdate, LessonLearnedOut,
|
||||
)
|
||||
from app.services import playbook_service as pb_svc
|
||||
from app.services import lesson_learned_service as ll_svc
|
||||
|
||||
router = APIRouter(prefix="/knowledge", tags=["knowledge"])
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# Playbooks
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@router.get("/playbooks", response_model=List[PlaybookOut])
|
||||
def list_playbooks(
|
||||
technique_id: Optional[UUID] = None,
|
||||
playbook_type: Optional[str] = None,
|
||||
include_inactive: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
return pb_svc.list_playbooks(
|
||||
db,
|
||||
technique_id=technique_id,
|
||||
playbook_type=playbook_type,
|
||||
include_inactive=include_inactive,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/playbooks", response_model=PlaybookOut, status_code=201)
|
||||
def create_playbook(
|
||||
body: PlaybookCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
return pb_svc.create_playbook(db, body.model_dump(), user.id)
|
||||
|
||||
|
||||
@router.get("/playbooks/{playbook_id}", response_model=PlaybookOut)
|
||||
def get_playbook(
|
||||
playbook_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
return pb_svc.get_playbook(db, playbook_id)
|
||||
|
||||
|
||||
@router.patch("/playbooks/{playbook_id}", response_model=PlaybookOut)
|
||||
def update_playbook(
|
||||
playbook_id: UUID,
|
||||
body: PlaybookUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
return pb_svc.update_playbook(db, playbook_id, body.model_dump(exclude_unset=True), user.id)
|
||||
|
||||
|
||||
@router.delete("/playbooks/{playbook_id}", status_code=204)
|
||||
def delete_playbook(
|
||||
playbook_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(require_any_role("admin", "red_lead", "blue_lead")),
|
||||
):
|
||||
pb_svc.delete_playbook(db, playbook_id, user.id)
|
||||
|
||||
|
||||
# ── Versions ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/playbooks/{playbook_id}/versions", response_model=List[PlaybookVersionOut])
|
||||
def list_versions(
|
||||
playbook_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
return pb_svc.get_playbook_versions(db, playbook_id)
|
||||
|
||||
|
||||
@router.post("/playbooks/{playbook_id}/restore/{version}", response_model=PlaybookOut)
|
||||
def restore_version(
|
||||
playbook_id: UUID,
|
||||
version: int,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""Roll the playbook back to a specific historical version."""
|
||||
return pb_svc.restore_version(db, playbook_id, version, user.id)
|
||||
|
||||
|
||||
# ── By technique (convenience) ────────────────────────────────────────────────
|
||||
|
||||
@router.get(
|
||||
"/techniques/{technique_id}/playbooks",
|
||||
response_model=List[PlaybookOut],
|
||||
)
|
||||
def playbooks_for_technique(
|
||||
technique_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""List all active playbooks for a specific technique."""
|
||||
return pb_svc.list_playbooks(db, technique_id=technique_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/techniques/{technique_id}/playbooks/{playbook_type}",
|
||||
response_model=PlaybookOut,
|
||||
)
|
||||
def get_playbook_by_technique_type(
|
||||
technique_id: UUID,
|
||||
playbook_type: str,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
pb = pb_svc.get_playbook_by_technique_type(db, technique_id, playbook_type)
|
||||
if not pb:
|
||||
from app.domain.errors import DomainError
|
||||
raise DomainError(
|
||||
f"No '{playbook_type}' playbook for technique {technique_id}",
|
||||
status_code=404,
|
||||
)
|
||||
return pb
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# Lessons Learned
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@router.get("/lessons", response_model=List[LessonLearnedOut])
|
||||
def list_lessons(
|
||||
entity_type: Optional[str] = None,
|
||||
entity_id: Optional[UUID] = None,
|
||||
severity: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
technique_id: Optional[str] = None,
|
||||
include_inactive: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
return ll_svc.list_lessons_learned(
|
||||
db,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
severity=severity,
|
||||
tag=tag,
|
||||
technique_id=technique_id,
|
||||
include_inactive=include_inactive,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/lessons", response_model=LessonLearnedOut, status_code=201)
|
||||
def create_lesson(
|
||||
body: LessonLearnedCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
return ll_svc.create_lesson_learned(db, body.model_dump(), user.id)
|
||||
|
||||
|
||||
@router.get("/lessons/{lesson_id}", response_model=LessonLearnedOut)
|
||||
def get_lesson(
|
||||
lesson_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
return ll_svc.get_lesson_learned(db, lesson_id)
|
||||
|
||||
|
||||
@router.patch("/lessons/{lesson_id}", response_model=LessonLearnedOut)
|
||||
def update_lesson(
|
||||
lesson_id: UUID,
|
||||
body: LessonLearnedUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
return ll_svc.update_lesson_learned(
|
||||
db, lesson_id, body.model_dump(exclude_unset=True), user.id
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/lessons/{lesson_id}", status_code=204)
|
||||
def delete_lesson(
|
||||
lesson_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(require_any_role("admin", "red_lead", "blue_lead")),
|
||||
):
|
||||
"""Soft-delete a lesson (admin / lead only)."""
|
||||
ll_svc.delete_lesson_learned(db, lesson_id, user.id)
|
||||
|
||||
|
||||
# ── Stats ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/stats")
|
||||
def knowledge_stats(
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""Summary counts: total playbooks, lessons by severity, playbooks by type."""
|
||||
return ll_svc.get_knowledge_stats(db)
|
||||
Reference in New Issue
Block a user