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>
107 lines
4.5 KiB
Python
107 lines
4.5 KiB
Python
"""Phase 11: Knowledge Management — Playbooks + Lessons Learned
|
|
|
|
Revision ID: b037know
|
|
Revises: b036atk
|
|
Create Date: 2026-05-20
|
|
|
|
Uses raw SQL to bypass SQLAlchemy DDL hooks (no enum types — string columns
|
|
with Pydantic-layer validation instead, so no PostgreSQL enums needed).
|
|
"""
|
|
|
|
from typing import Union
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
|
|
revision: str = "b037know"
|
|
down_revision: Union[str, None] = "b036atk"
|
|
branch_labels = None
|
|
depends_on = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
conn = op.get_bind()
|
|
|
|
# ── playbooks ──────────────────────────────────────────────────────────────
|
|
conn.execute(sa.text("""
|
|
CREATE TABLE IF NOT EXISTS playbooks (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
technique_id UUID NOT NULL REFERENCES techniques(id) ON DELETE CASCADE,
|
|
playbook_type VARCHAR(32) NOT NULL,
|
|
title VARCHAR(255) NOT NULL,
|
|
content TEXT NOT NULL DEFAULT '',
|
|
version INTEGER NOT NULL DEFAULT 1,
|
|
tools JSONB,
|
|
prerequisites JSONB,
|
|
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
updated_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
created_at TIMESTAMP DEFAULT now(),
|
|
updated_at TIMESTAMP DEFAULT now(),
|
|
is_active BOOLEAN DEFAULT TRUE,
|
|
CONSTRAINT uq_playbook_technique_type UNIQUE (technique_id, playbook_type)
|
|
)
|
|
"""))
|
|
conn.execute(sa.text(
|
|
"CREATE INDEX IF NOT EXISTS ix_playbooks_technique_id ON playbooks (technique_id)"
|
|
))
|
|
conn.execute(sa.text(
|
|
"CREATE INDEX IF NOT EXISTS ix_playbooks_type ON playbooks (playbook_type)"
|
|
))
|
|
|
|
# ── playbook_versions ──────────────────────────────────────────────────────
|
|
conn.execute(sa.text("""
|
|
CREATE TABLE IF NOT EXISTS playbook_versions (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
playbook_id UUID NOT NULL REFERENCES playbooks(id) ON DELETE CASCADE,
|
|
version INTEGER NOT NULL,
|
|
title VARCHAR(255) NOT NULL,
|
|
content TEXT NOT NULL DEFAULT '',
|
|
tools JSONB,
|
|
prerequisites JSONB,
|
|
changed_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
change_note VARCHAR(500),
|
|
created_at TIMESTAMP DEFAULT now()
|
|
)
|
|
"""))
|
|
conn.execute(sa.text(
|
|
"CREATE INDEX IF NOT EXISTS ix_pb_versions_playbook_id ON playbook_versions (playbook_id)"
|
|
))
|
|
conn.execute(sa.text(
|
|
"CREATE INDEX IF NOT EXISTS ix_pb_versions_version ON playbook_versions (playbook_id, version)"
|
|
))
|
|
|
|
# ── lessons_learned ────────────────────────────────────────────────────────
|
|
conn.execute(sa.text("""
|
|
CREATE TABLE IF NOT EXISTS lessons_learned (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
title VARCHAR(255) NOT NULL,
|
|
what_happened TEXT NOT NULL DEFAULT '',
|
|
root_cause TEXT NOT NULL DEFAULT '',
|
|
fix_applied TEXT,
|
|
severity VARCHAR(16) NOT NULL DEFAULT 'medium',
|
|
entity_type VARCHAR(32) NOT NULL DEFAULT 'manual',
|
|
entity_id UUID,
|
|
technique_ids JSONB,
|
|
tags JSONB,
|
|
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
created_at TIMESTAMP DEFAULT now(),
|
|
updated_at TIMESTAMP DEFAULT now(),
|
|
is_active BOOLEAN DEFAULT TRUE
|
|
)
|
|
"""))
|
|
conn.execute(sa.text(
|
|
"CREATE INDEX IF NOT EXISTS ix_ll_entity ON lessons_learned (entity_type, entity_id)"
|
|
))
|
|
conn.execute(sa.text(
|
|
"CREATE INDEX IF NOT EXISTS ix_ll_severity ON lessons_learned (severity)"
|
|
))
|
|
conn.execute(sa.text(
|
|
"CREATE INDEX IF NOT EXISTS ix_ll_created_by ON lessons_learned (created_by)"
|
|
))
|
|
|
|
|
|
def downgrade() -> None:
|
|
conn = op.get_bind()
|
|
conn.execute(sa.text("DROP TABLE IF EXISTS lessons_learned"))
|
|
conn.execute(sa.text("DROP TABLE IF EXISTS playbook_versions"))
|
|
conn.execute(sa.text("DROP TABLE IF EXISTS playbooks"))
|