feat: Phase 3 - CRUD core for Techniques, Tests and Evidence (T-014 to T-017)

- Add Pydantic schemas for Technique, Test and Evidence
- Add CRUD endpoints for Techniques (list with filters, detail, create, update, review)
- Add CRUD endpoints for Tests (create, detail, update, validate, reject)
- Add evidence upload with SHA-256 integrity and presigned download URLs
- Add MinIO/S3 storage client with bucket auto-creation on startup
- Add status_service to recalculate technique coverage from test results
- Add require_any_role RBAC dependency for multi-role authorization
- Update README with API endpoints reference and project structure
This commit is contained in:
2026-02-06 13:52:27 +01:00
parent 508f0723af
commit 4f6dd838fd
12 changed files with 958 additions and 5 deletions

View File

@@ -0,0 +1,132 @@
"""Evidence upload and download router."""
import hashlib
import uuid as _uuid
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies.auth import get_current_user
from app.models.evidence import Evidence
from app.models.test import Test
from app.models.user import User
from app.schemas.evidence import EvidenceOut
from app.services.audit_service import log_action
from app.storage import get_presigned_url, upload_file
router = APIRouter(tags=["evidence"])
# ---------------------------------------------------------------------------
# POST /tests/{test_id}/evidence — upload
# ---------------------------------------------------------------------------
@router.post(
"/tests/{test_id}/evidence",
response_model=EvidenceOut,
status_code=status.HTTP_201_CREATED,
)
async def upload_evidence(
test_id: _uuid.UUID,
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Upload a file as evidence for the given test.
Steps:
1. Read file content and compute SHA-256.
2. Build an object key ``{test_id}/{uuid}_{filename}``.
3. Upload to MinIO.
4. Persist an :class:`Evidence` row in the database.
5. Write an audit-log entry.
"""
# Verify the parent test exists
test = db.query(Test).filter(Test.id == test_id).first()
if test is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Test not found",
)
# 1. Read content + hash
content = await file.read()
sha256 = hashlib.sha256(content).hexdigest()
# 2. Object key
file_name = file.filename or "unnamed"
key = f"{test_id}/{_uuid.uuid4()}_{file_name}"
# 3. Upload to MinIO
upload_file(content, key)
# 4. Persist metadata
evidence = Evidence(
test_id=test_id,
file_name=file_name,
file_path=key,
sha256_hash=sha256,
uploaded_by=current_user.id,
)
db.add(evidence)
db.commit()
db.refresh(evidence)
# 5. Audit
log_action(
db,
user_id=current_user.id,
action="upload_evidence",
entity_type="evidence",
entity_id=evidence.id,
details={
"file_name": file_name,
"sha256": sha256,
"test_id": str(test_id),
},
)
# Build response with download URL
return _evidence_to_out(evidence)
# ---------------------------------------------------------------------------
# GET /evidence/{id} — presigned download URL
# ---------------------------------------------------------------------------
@router.get("/evidence/{evidence_id}", response_model=EvidenceOut)
def get_evidence(
evidence_id: _uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return evidence metadata together with a presigned download URL."""
evidence = db.query(Evidence).filter(Evidence.id == evidence_id).first()
if evidence is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Evidence not found",
)
return _evidence_to_out(evidence)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _evidence_to_out(evidence: Evidence) -> EvidenceOut:
"""Convert an ORM ``Evidence`` to the API schema, injecting a presigned URL."""
return EvidenceOut(
id=evidence.id,
test_id=evidence.test_id,
file_name=evidence.file_name,
sha256_hash=evidence.sha256_hash,
uploaded_by=evidence.uploaded_by,
uploaded_at=evidence.uploaded_at,
download_url=get_presigned_url(evidence.file_path),
)

View File

@@ -0,0 +1,206 @@
"""CRUD router for MITRE ATT&CK Techniques."""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session, joinedload
from app.database import get_db
from app.dependencies.auth import get_current_user, require_role, require_any_role
from app.models.enums import TechniqueStatus
from app.models.technique import Technique
from app.models.user import User
from app.schemas.technique import (
TechniqueCreate,
TechniqueOut,
TechniqueSummary,
TechniqueUpdate,
)
from app.services.audit_service import log_action
router = APIRouter(prefix="/techniques", tags=["techniques"])
# ---------------------------------------------------------------------------
# GET /techniques — list (with optional filters)
# ---------------------------------------------------------------------------
@router.get("", response_model=list[TechniqueSummary])
def list_techniques(
tactic: str | None = Query(None, description="Filter by tactic name"),
status_global: TechniqueStatus | None = Query(
None, alias="status", description="Filter by global status"
),
review_required: bool | None = Query(None, description="Filter by review flag"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return a lightweight list of techniques, optionally filtered."""
query = db.query(Technique)
if tactic is not None:
query = query.filter(Technique.tactic == tactic)
if status_global is not None:
query = query.filter(Technique.status_global == status_global)
if review_required is not None:
query = query.filter(Technique.review_required == review_required)
return query.order_by(Technique.mitre_id).all()
# ---------------------------------------------------------------------------
# GET /techniques/{mitre_id} — detail (with tests)
# ---------------------------------------------------------------------------
@router.get("/{mitre_id}", response_model=TechniqueOut)
def get_technique(
mitre_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return full details for a single technique, including its tests."""
technique = (
db.query(Technique)
.options(joinedload(Technique.tests))
.filter(Technique.mitre_id == mitre_id)
.first()
)
if technique is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Technique {mitre_id} not found",
)
return technique
# ---------------------------------------------------------------------------
# POST /techniques — create (admin only)
# ---------------------------------------------------------------------------
@router.post(
"",
response_model=TechniqueOut,
status_code=status.HTTP_201_CREATED,
)
def create_technique(
payload: TechniqueCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Create a new technique manually."""
# Ensure mitre_id is unique
existing = (
db.query(Technique).filter(Technique.mitre_id == payload.mitre_id).first()
)
if existing is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Technique with mitre_id '{payload.mitre_id}' already exists",
)
technique = Technique(**payload.model_dump())
db.add(technique)
db.commit()
db.refresh(technique)
log_action(
db,
user_id=current_user.id,
action="create_technique",
entity_type="technique",
entity_id=technique.id,
details={"mitre_id": technique.mitre_id, "name": technique.name},
)
return technique
# ---------------------------------------------------------------------------
# PATCH /techniques/{mitre_id} — update (admin only)
# ---------------------------------------------------------------------------
@router.patch("/{mitre_id}", response_model=TechniqueOut)
def update_technique(
mitre_id: str,
payload: TechniqueUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Update one or more fields of an existing technique."""
technique = (
db.query(Technique).filter(Technique.mitre_id == mitre_id).first()
)
if technique is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Technique {mitre_id} not found",
)
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(technique, field, value)
db.commit()
db.refresh(technique)
log_action(
db,
user_id=current_user.id,
action="update_technique",
entity_type="technique",
entity_id=technique.id,
details={"mitre_id": mitre_id, "updated_fields": list(update_data.keys())},
)
return technique
# ---------------------------------------------------------------------------
# PATCH /techniques/{mitre_id}/review — mark as reviewed (leads + admin)
# ---------------------------------------------------------------------------
@router.patch("/{mitre_id}/review", response_model=TechniqueOut)
def review_technique(
mitre_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
):
"""Mark a technique as reviewed.
Sets ``review_required`` to *False* and records the current timestamp
in ``last_review_date``.
"""
technique = (
db.query(Technique).filter(Technique.mitre_id == mitre_id).first()
)
if technique is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Technique {mitre_id} not found",
)
technique.review_required = False
technique.last_review_date = datetime.utcnow()
db.commit()
db.refresh(technique)
log_action(
db,
user_id=current_user.id,
action="review_technique",
entity_type="technique",
entity_id=technique.id,
details={"mitre_id": mitre_id},
)
return technique

View File

@@ -0,0 +1,248 @@
"""CRUD router for security Tests."""
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session, joinedload
from app.database import get_db
from app.dependencies.auth import get_current_user, require_role, require_any_role
from app.models.enums import TestState
from app.models.technique import Technique
from app.models.test import Test
from app.models.user import User
from app.schemas.test import TestCreate, TestOut, TestUpdate, TestValidate
from app.services.audit_service import log_action
from app.services.status_service import recalculate_technique_status
router = APIRouter(prefix="/tests", tags=["tests"])
# ---------------------------------------------------------------------------
# POST /tests — create (red_tech or admin)
# ---------------------------------------------------------------------------
@router.post(
"",
response_model=TestOut,
status_code=status.HTTP_201_CREATED,
)
def create_test(
payload: TestCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech")),
):
"""Create a new test linked to an existing technique.
The ``created_by`` field is set automatically to the current user and
``state`` defaults to *draft*.
"""
# Verify the parent technique exists
technique = db.query(Technique).filter(Technique.id == payload.technique_id).first()
if technique is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Technique with id '{payload.technique_id}' not found",
)
test = Test(
**payload.model_dump(),
created_by=current_user.id,
state=TestState.draft,
)
db.add(test)
db.commit()
db.refresh(test)
log_action(
db,
user_id=current_user.id,
action="create_test",
entity_type="test",
entity_id=test.id,
details={"name": test.name, "technique_id": str(test.technique_id)},
)
return test
# ---------------------------------------------------------------------------
# GET /tests/{id} — detail (with evidences)
# ---------------------------------------------------------------------------
@router.get("/{test_id}", response_model=TestOut)
def get_test(
test_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return full details for a single test, including its evidences."""
test = (
db.query(Test)
.options(joinedload(Test.evidences))
.filter(Test.id == test_id)
.first()
)
if test is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Test not found",
)
return test
# ---------------------------------------------------------------------------
# PATCH /tests/{id} — update (creator or admin, only in draft/rejected)
# ---------------------------------------------------------------------------
@router.patch("/{test_id}", response_model=TestOut)
def update_test(
test_id: uuid.UUID,
payload: TestUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update one or more fields of an existing test.
Only the original creator or an admin can update.
The test must be in ``draft`` or ``rejected`` state.
"""
test = db.query(Test).filter(Test.id == test_id).first()
if test is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Test not found",
)
# Ownership / admin check
if current_user.role != "admin" and test.created_by != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
# State guard
if test.state not in (TestState.draft, TestState.rejected):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot update a test in '{test.state.value}' state (must be draft or rejected)",
)
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(test, field, value)
db.commit()
db.refresh(test)
log_action(
db,
user_id=current_user.id,
action="update_test",
entity_type="test",
entity_id=test.id,
details={"updated_fields": list(update_data.keys())},
)
return test
# ---------------------------------------------------------------------------
# POST /tests/{id}/validate — validate (leads + admin)
# ---------------------------------------------------------------------------
@router.post("/{test_id}/validate", response_model=TestOut)
def validate_test(
test_id: uuid.UUID,
payload: TestValidate,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
):
"""Mark a test as validated.
Sets ``state`` to *validated*, records ``validated_by`` / ``validated_at``,
stores the ``result``, and recalculates the parent technique's global status.
"""
test = (
db.query(Test)
.options(joinedload(Test.technique))
.filter(Test.id == test_id)
.first()
)
if test is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Test not found",
)
test.state = TestState.validated
test.result = payload.result
test.validated_by = current_user.id
test.validated_at = datetime.utcnow()
db.commit()
db.refresh(test)
# Recalculate the parent technique's global status
technique = test.technique
recalculate_technique_status(db, technique)
log_action(
db,
user_id=current_user.id,
action="validate_test",
entity_type="test",
entity_id=test.id,
details={
"result": payload.result.value,
"technique_id": str(test.technique_id),
},
)
return test
# ---------------------------------------------------------------------------
# POST /tests/{id}/reject — reject (leads + admin)
# ---------------------------------------------------------------------------
@router.post("/{test_id}/reject", response_model=TestOut)
def reject_test(
test_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
):
"""Reject a test, setting its state to *rejected*."""
test = db.query(Test).filter(Test.id == test_id).first()
if test is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Test not found",
)
test.state = TestState.rejected
db.commit()
db.refresh(test)
log_action(
db,
user_id=current_user.id,
action="reject_test",
entity_type="test",
entity_id=test.id,
details={"technique_id": str(test.technique_id)},
)
return test