feat(phase-12): implement Red/Blue API endpoints (T-109, T-110, T-111, T-112)
T-109: Rewrite tests router with full Red/Blue workflow endpoints - list with filters, create from template, Red/Blue team updates with state guards, start-execution, submit-red, submit-blue, validate-red, validate-blue, reopen, and timeline. All using workflow service from Phase 11. T-110: Rewrite evidence router with Red/Blue separation - upload with team field, list with team filter, delete with state-based permissions. Red Team edits in draft/red_executing, Blue Team in blue_evaluating, admin bypasses all. T-111: Create test_templates router with full CRUD - paginated list with source/platform/severity/search filters, by-technique lookup, admin-only create/update, and soft delete. Registered in main.py. T-112: Add POST /system/import-atomic-tests endpoint to system router - admin-only trigger for Atomic Red Team import with error handling and statistics response. Includes validation tests for all four tasks (35 checks total).
This commit is contained in:
@@ -11,6 +11,7 @@ from app.routers import auth as auth_router
|
||||
from app.routers import techniques as techniques_router
|
||||
from app.routers import tests as tests_router
|
||||
from app.routers import evidence as evidence_router
|
||||
from app.routers import test_templates as test_templates_router
|
||||
from app.routers import system as system_router
|
||||
from app.routers import metrics as metrics_router
|
||||
from app.routers import users as users_router
|
||||
@@ -50,6 +51,7 @@ app.include_router(auth_router.router, prefix="/api/v1")
|
||||
app.include_router(techniques_router.router, prefix="/api/v1")
|
||||
app.include_router(tests_router.router, prefix="/api/v1")
|
||||
app.include_router(evidence_router.router, prefix="/api/v1")
|
||||
app.include_router(test_templates_router.router, prefix="/api/v1")
|
||||
app.include_router(system_router.router, prefix="/api/v1")
|
||||
app.include_router(metrics_router.router, prefix="/api/v1")
|
||||
app.include_router(users_router.router, prefix="/api/v1")
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
"""Evidence upload and download router."""
|
||||
"""Evidence upload, download, listing and deletion router — v2 with Red/Blue separation.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
POST /tests/{test_id}/evidence — upload evidence (with team=red/blue)
|
||||
GET /tests/{test_id}/evidence — list evidences (filterable by team)
|
||||
GET /evidence/{id} — presigned download URL
|
||||
DELETE /evidence/{id} — delete evidence (only in editable states)
|
||||
|
||||
Access Control
|
||||
--------------
|
||||
- Red Team (``red_tech``) can only upload ``team=red`` when test is in
|
||||
``draft`` or ``red_executing``.
|
||||
- Blue Team (``blue_tech``) can only upload ``team=blue`` when test is in
|
||||
``blue_evaluating``.
|
||||
- Admin can upload any team in any state.
|
||||
- DELETE is restricted: red evidence in ``draft``/``red_executing``,
|
||||
blue evidence in ``blue_evaluating``. No deletions in ``in_review``,
|
||||
``validated``, or ``rejected``.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import uuid as _uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies.auth import get_current_user
|
||||
from app.models.enums import TeamSide, TestState
|
||||
from app.models.evidence import Evidence
|
||||
from app.models.test import Test
|
||||
from app.models.user import User
|
||||
@@ -17,9 +38,114 @@ from app.storage import get_presigned_url, upload_file
|
||||
|
||||
router = APIRouter(tags=["evidence"])
|
||||
|
||||
# States where red evidence can be uploaded / deleted
|
||||
_RED_EDITABLE_STATES = (TestState.draft, TestState.red_executing)
|
||||
# States where blue evidence can be uploaded / deleted
|
||||
_BLUE_EDITABLE_STATES = (TestState.blue_evaluating,)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /tests/{test_id}/evidence — upload
|
||||
# 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,
|
||||
team=evidence.team,
|
||||
notes=evidence.notes,
|
||||
download_url=get_presigned_url(evidence.file_path),
|
||||
)
|
||||
|
||||
|
||||
def _validate_upload_permission(
|
||||
test: Test,
|
||||
team: TeamSide,
|
||||
user: User,
|
||||
) -> None:
|
||||
"""Raise 403 if the user/team combination is not allowed in the current state."""
|
||||
# Admins bypass all checks
|
||||
if user.role == "admin":
|
||||
return
|
||||
|
||||
if team == TeamSide.red:
|
||||
# Only red_tech can upload red evidence
|
||||
if user.role != "red_tech":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only red_tech or admin can upload red evidence",
|
||||
)
|
||||
if test.state not in _RED_EDITABLE_STATES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Cannot upload red evidence in '{test.state.value}' state "
|
||||
f"(allowed in: draft, red_executing)",
|
||||
)
|
||||
elif team == TeamSide.blue:
|
||||
# Only blue_tech can upload blue evidence
|
||||
if user.role != "blue_tech":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only blue_tech or admin can upload blue evidence",
|
||||
)
|
||||
if test.state not in _BLUE_EDITABLE_STATES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Cannot upload blue evidence in '{test.state.value}' state "
|
||||
f"(allowed in: blue_evaluating)",
|
||||
)
|
||||
|
||||
|
||||
def _validate_delete_permission(
|
||||
test: Test,
|
||||
evidence: Evidence,
|
||||
user: User,
|
||||
) -> None:
|
||||
"""Raise 403 if the user cannot delete this evidence in the current state."""
|
||||
# No deletions in review / validated / rejected
|
||||
if test.state in (TestState.in_review, TestState.validated, TestState.rejected):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Cannot delete evidence when test is in '{test.state.value}' state",
|
||||
)
|
||||
|
||||
# Admin can delete in editable states
|
||||
if user.role == "admin":
|
||||
return
|
||||
|
||||
ev_team = evidence.team
|
||||
|
||||
if ev_team == TeamSide.red:
|
||||
if test.state not in _RED_EDITABLE_STATES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot delete red evidence outside draft/red_executing",
|
||||
)
|
||||
if user.role != "red_tech" and evidence.uploaded_by != user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions to delete this evidence",
|
||||
)
|
||||
elif ev_team == TeamSide.blue:
|
||||
if test.state not in _BLUE_EDITABLE_STATES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot delete blue evidence outside blue_evaluating",
|
||||
)
|
||||
if user.role != "blue_tech" and evidence.uploaded_by != user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions to delete this evidence",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /tests/{test_id}/evidence — upload with team
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -31,19 +157,16 @@ router = APIRouter(tags=["evidence"])
|
||||
async def upload_evidence(
|
||||
test_id: _uuid.UUID,
|
||||
file: UploadFile = File(...),
|
||||
team: TeamSide = Form(TeamSide.red),
|
||||
notes: Optional[str] = Form(None),
|
||||
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.
|
||||
The ``team`` field (sent as form data) determines whether this is
|
||||
Red Team (attack) or Blue Team (detection) evidence.
|
||||
"""
|
||||
# Verify the parent test exists
|
||||
test = db.query(Test).filter(Test.id == test_id).first()
|
||||
if test is None:
|
||||
raise HTTPException(
|
||||
@@ -51,6 +174,9 @@ async def upload_evidence(
|
||||
detail="Test not found",
|
||||
)
|
||||
|
||||
# Validate permissions
|
||||
_validate_upload_permission(test, team, current_user)
|
||||
|
||||
# 1. Read content + hash
|
||||
content = await file.read()
|
||||
sha256 = hashlib.sha256(content).hexdigest()
|
||||
@@ -69,6 +195,8 @@ async def upload_evidence(
|
||||
file_path=key,
|
||||
sha256_hash=sha256,
|
||||
uploaded_by=current_user.id,
|
||||
team=team,
|
||||
notes=notes,
|
||||
)
|
||||
db.add(evidence)
|
||||
db.commit()
|
||||
@@ -85,13 +213,42 @@ async def upload_evidence(
|
||||
"file_name": file_name,
|
||||
"sha256": sha256,
|
||||
"test_id": str(test_id),
|
||||
"team": team.value,
|
||||
},
|
||||
)
|
||||
|
||||
# Build response with download URL
|
||||
return _evidence_to_out(evidence)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /tests/{test_id}/evidence — list (with optional team filter)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/tests/{test_id}/evidence", response_model=list[EvidenceOut])
|
||||
def list_evidence(
|
||||
test_id: _uuid.UUID,
|
||||
team: Optional[str] = Query(None, description="Filter by team: red or blue"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all evidences for a test, optionally filtered by team."""
|
||||
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",
|
||||
)
|
||||
|
||||
query = db.query(Evidence).filter(Evidence.test_id == test_id)
|
||||
|
||||
if team:
|
||||
query = query.filter(Evidence.team == team)
|
||||
|
||||
evidences = query.order_by(Evidence.uploaded_at.desc()).all()
|
||||
return [_evidence_to_out(e) for e in evidences]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /evidence/{id} — presigned download URL
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -115,20 +272,55 @@ def get_evidence(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# DELETE /evidence/{id} — delete evidence (editable states only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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,
|
||||
team=evidence.team,
|
||||
notes=evidence.notes,
|
||||
download_url=get_presigned_url(evidence.file_path),
|
||||
@router.delete("/evidence/{evidence_id}", status_code=status.HTTP_200_OK)
|
||||
def delete_evidence(
|
||||
evidence_id: _uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Delete an evidence record.
|
||||
|
||||
Only allowed in editable states:
|
||||
- Red evidence: ``draft``, ``red_executing``
|
||||
- Blue evidence: ``blue_evaluating``
|
||||
- No deletions in ``in_review``, ``validated``, ``rejected``
|
||||
"""
|
||||
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",
|
||||
)
|
||||
|
||||
test = db.query(Test).filter(Test.id == evidence.test_id).first()
|
||||
if test is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Parent test not found",
|
||||
)
|
||||
|
||||
# Permission checks
|
||||
_validate_delete_permission(test, evidence, current_user)
|
||||
|
||||
# Audit before deletion
|
||||
log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
action="delete_evidence",
|
||||
entity_type="evidence",
|
||||
entity_id=evidence.id,
|
||||
details={
|
||||
"file_name": evidence.file_name,
|
||||
"test_id": str(evidence.test_id),
|
||||
"team": evidence.team.value if evidence.team else None,
|
||||
},
|
||||
)
|
||||
|
||||
db.delete(evidence)
|
||||
db.commit()
|
||||
|
||||
return {"detail": "Evidence deleted"}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""System-level endpoints (admin only).
|
||||
|
||||
Provides manual triggers for background operations such as the MITRE
|
||||
ATT&CK synchronisation, intel scanning, and scheduler health introspection.
|
||||
ATT&CK synchronisation, intel scanning, Atomic Red Team import, and
|
||||
scheduler health introspection.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -12,8 +15,11 @@ from app.dependencies.auth import require_role
|
||||
from app.models.user import User
|
||||
from app.services.mitre_sync_service import sync_mitre
|
||||
from app.services.intel_service import scan_intel
|
||||
from app.services.atomic_import_service import import_atomic_red_team
|
||||
from app.jobs.mitre_sync_job import scheduler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/system", tags=["system"])
|
||||
|
||||
|
||||
@@ -56,6 +62,38 @@ def trigger_intel_scan(
|
||||
}
|
||||
|
||||
|
||||
@router.post("/import-atomic-tests")
|
||||
def trigger_atomic_import(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""Trigger an import of Atomic Red Team tests as TestTemplates.
|
||||
|
||||
**Requires** the ``admin`` role.
|
||||
|
||||
Downloads the Atomic Red Team repository ZIP from GitHub, parses the
|
||||
YAML files, and creates/updates TestTemplate records. Running this
|
||||
endpoint multiple times is idempotent — duplicates are skipped.
|
||||
|
||||
Returns a JSON object with import statistics.
|
||||
"""
|
||||
try:
|
||||
summary = import_atomic_red_team(db)
|
||||
except Exception as exc:
|
||||
logger.error("Atomic Red Team import failed: %s", exc)
|
||||
return {
|
||||
"message": "Import failed",
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
return {
|
||||
"message": "Import completed",
|
||||
"imported": summary["created"],
|
||||
"skipped": summary["skipped_existing"],
|
||||
"total_parsed": summary["total_tests_parsed"],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/scheduler-status")
|
||||
def scheduler_status(
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
|
||||
242
backend/app/routers/test_templates.py
Normal file
242
backend/app/routers/test_templates.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""CRUD router for TestTemplates — predefined test catalog.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
GET /test-templates — list with filters + pagination
|
||||
GET /test-templates/{id} — detail
|
||||
POST /test-templates — create custom (admin)
|
||||
PATCH /test-templates/{id} — update (admin)
|
||||
DELETE /test-templates/{id} — soft delete (admin)
|
||||
GET /test-templates/by-technique/{mitre_id} — templates for a MITRE technique
|
||||
|
||||
Filters (GET /test-templates)
|
||||
-----------------------------
|
||||
- source: atomic_red_team | mitre | custom
|
||||
- platform: windows | linux | macos
|
||||
- severity: low | medium | high | critical
|
||||
- mitre_technique_id: filter by specific technique
|
||||
- search: full-text search across name and description
|
||||
- offset / limit: pagination (default limit=50)
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies.auth import get_current_user, require_role
|
||||
from app.models.test_template import TestTemplate
|
||||
from app.models.user import User
|
||||
from app.schemas.test_template import (
|
||||
TestTemplateCreate,
|
||||
TestTemplateOut,
|
||||
TestTemplateSummary,
|
||||
)
|
||||
from app.services.audit_service import log_action
|
||||
|
||||
router = APIRouter(prefix="/test-templates", tags=["test-templates"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /test-templates — list with filters + pagination
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("", response_model=list[TestTemplateSummary])
|
||||
def list_templates(
|
||||
source: Optional[str] = Query(None, description="Filter by source (atomic_red_team, mitre, custom)"),
|
||||
platform: Optional[str] = Query(None, description="Filter by platform (windows, linux, macos)"),
|
||||
severity: Optional[str] = Query(None, description="Filter by severity (low, medium, high, critical)"),
|
||||
mitre_technique_id: Optional[str] = Query(None, description="Filter by MITRE technique ID"),
|
||||
search: Optional[str] = Query(None, description="Search in name and description"),
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return a paginated, filterable list of active test templates."""
|
||||
query = db.query(TestTemplate).filter(TestTemplate.is_active == True) # noqa: E712
|
||||
|
||||
if source:
|
||||
query = query.filter(TestTemplate.source == source)
|
||||
if platform:
|
||||
query = query.filter(TestTemplate.platform.ilike(f"%{platform}%"))
|
||||
if severity:
|
||||
query = query.filter(TestTemplate.severity == severity)
|
||||
if mitre_technique_id:
|
||||
query = query.filter(TestTemplate.mitre_technique_id == mitre_technique_id)
|
||||
if search:
|
||||
pattern = f"%{search}%"
|
||||
query = query.filter(
|
||||
or_(
|
||||
TestTemplate.name.ilike(pattern),
|
||||
TestTemplate.description.ilike(pattern),
|
||||
)
|
||||
)
|
||||
|
||||
templates = (
|
||||
query
|
||||
.order_by(TestTemplate.mitre_technique_id, TestTemplate.name)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return templates
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /test-templates/by-technique/{mitre_id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/by-technique/{mitre_id}", response_model=list[TestTemplateSummary])
|
||||
def templates_by_technique(
|
||||
mitre_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return all active templates mapped to a specific MITRE technique."""
|
||||
templates = (
|
||||
db.query(TestTemplate)
|
||||
.filter(
|
||||
TestTemplate.mitre_technique_id == mitre_id,
|
||||
TestTemplate.is_active == True, # noqa: E712
|
||||
)
|
||||
.order_by(TestTemplate.name)
|
||||
.all()
|
||||
)
|
||||
return templates
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /test-templates/{id} — detail
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/{template_id}", response_model=TestTemplateOut)
|
||||
def get_template(
|
||||
template_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return full details for a single test template."""
|
||||
template = db.query(TestTemplate).filter(TestTemplate.id == template_id).first()
|
||||
if template is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Test template not found",
|
||||
)
|
||||
return template
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /test-templates — create (admin only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=TestTemplateOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_template(
|
||||
payload: TestTemplateCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""Create a custom test template. Admin only."""
|
||||
template = TestTemplate(**payload.model_dump())
|
||||
db.add(template)
|
||||
db.commit()
|
||||
db.refresh(template)
|
||||
|
||||
log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
action="create_test_template",
|
||||
entity_type="test_template",
|
||||
entity_id=template.id,
|
||||
details={
|
||||
"name": template.name,
|
||||
"source": template.source,
|
||||
"mitre_technique_id": template.mitre_technique_id,
|
||||
},
|
||||
)
|
||||
|
||||
return template
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /test-templates/{id} — update (admin only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.patch("/{template_id}", response_model=TestTemplateOut)
|
||||
def update_template(
|
||||
template_id: uuid.UUID,
|
||||
payload: TestTemplateCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""Update fields of an existing test template. Admin only."""
|
||||
template = db.query(TestTemplate).filter(TestTemplate.id == template_id).first()
|
||||
if template is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Test template not found",
|
||||
)
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(template, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(template)
|
||||
|
||||
log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
action="update_test_template",
|
||||
entity_type="test_template",
|
||||
entity_id=template.id,
|
||||
details={"updated_fields": list(update_data.keys())},
|
||||
)
|
||||
|
||||
return template
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DELETE /test-templates/{id} — soft delete (admin only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.delete("/{template_id}", status_code=status.HTTP_200_OK)
|
||||
def delete_template(
|
||||
template_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""Soft-delete a test template by setting ``is_active=False``. Admin only."""
|
||||
template = db.query(TestTemplate).filter(TestTemplate.id == template_id).first()
|
||||
if template is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Test template not found",
|
||||
)
|
||||
|
||||
template.is_active = False
|
||||
db.commit()
|
||||
|
||||
log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
action="delete_test_template",
|
||||
entity_type="test_template",
|
||||
entity_id=template.id,
|
||||
details={"name": template.name},
|
||||
)
|
||||
|
||||
return {"detail": "Test template deactivated"}
|
||||
@@ -1,24 +1,110 @@
|
||||
"""CRUD router for security Tests."""
|
||||
"""CRUD router for security Tests — v2 with Red/Blue workflow.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
GET /tests — list with filters (state, technique_id)
|
||||
POST /tests — create (red_tech, admin)
|
||||
POST /tests/from-template — create from TestTemplate (red_tech, admin)
|
||||
GET /tests/{id} — detail with split red/blue evidences
|
||||
PATCH /tests/{id} — general update (draft/rejected only)
|
||||
PATCH /tests/{id}/red — Red Team updates (draft, red_executing)
|
||||
PATCH /tests/{id}/blue — Blue Team updates (blue_evaluating)
|
||||
POST /tests/{id}/start-execution — draft → red_executing
|
||||
POST /tests/{id}/submit-red — red_executing → blue_evaluating
|
||||
POST /tests/{id}/submit-blue — blue_evaluating → in_review
|
||||
POST /tests/{id}/validate-red — Red Lead validates
|
||||
POST /tests/{id}/validate-blue — Blue Lead validates
|
||||
POST /tests/{id}/reopen — rejected → draft
|
||||
GET /tests/{id}/timeline — audit-log history for this test
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
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 TestState
|
||||
from app.dependencies.auth import get_current_user, require_any_role
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.enums import TestState, TeamSide
|
||||
from app.models.technique import Technique
|
||||
from app.models.test import Test
|
||||
from app.models.test_template import TestTemplate
|
||||
from app.models.user import User
|
||||
from app.schemas.test import TestCreate, TestOut, TestUpdate, TestValidate
|
||||
from app.schemas.test import (
|
||||
TestCreate,
|
||||
TestOut,
|
||||
TestUpdate,
|
||||
TestRedUpdate,
|
||||
TestBlueUpdate,
|
||||
TestRedValidate,
|
||||
TestBlueValidate,
|
||||
)
|
||||
from app.schemas.test_template import TestTemplateInstantiate
|
||||
from app.services.audit_service import log_action
|
||||
from app.services.status_service import recalculate_technique_status
|
||||
from app.services.test_workflow_service import (
|
||||
start_execution as wf_start_execution,
|
||||
submit_red_evidence as wf_submit_red,
|
||||
submit_blue_evidence as wf_submit_blue,
|
||||
validate_as_red_lead as wf_validate_red,
|
||||
validate_as_blue_lead as wf_validate_blue,
|
||||
reopen_test as wf_reopen,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/tests", tags=["tests"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_test_or_404(db: Session, test_id: uuid.UUID) -> Test:
|
||||
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")
|
||||
return test
|
||||
|
||||
|
||||
def _get_test_with_technique(db: Session, test_id: uuid.UUID) -> Test:
|
||||
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")
|
||||
return test
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /tests — list with filters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("", response_model=list[TestOut])
|
||||
def list_tests(
|
||||
state: Optional[str] = Query(None, description="Filter by test state"),
|
||||
technique_id: Optional[uuid.UUID] = Query(None, description="Filter by technique"),
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return a paginated list of tests, optionally filtered by state or technique."""
|
||||
query = db.query(Test)
|
||||
|
||||
if state:
|
||||
query = query.filter(Test.state == state)
|
||||
if technique_id:
|
||||
query = query.filter(Test.technique_id == technique_id)
|
||||
|
||||
tests = query.order_by(Test.created_at.desc()).offset(offset).limit(limit).all()
|
||||
return tests
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /tests — create (red_tech or admin)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -36,10 +122,8 @@ def create_test(
|
||||
):
|
||||
"""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*.
|
||||
``created_by`` is set automatically 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(
|
||||
@@ -69,7 +153,70 @@ def create_test(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /tests/{id} — detail (with evidences)
|
||||
# POST /tests/from-template — create from TestTemplate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/from-template",
|
||||
response_model=TestOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_test_from_template(
|
||||
payload: TestTemplateInstantiate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_any_role("red_tech")),
|
||||
):
|
||||
"""Instantiate a real Test from an existing TestTemplate.
|
||||
|
||||
The template's fields are copied into the new test as starting data.
|
||||
"""
|
||||
template = db.query(TestTemplate).filter(TestTemplate.id == payload.template_id).first()
|
||||
if template is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"TestTemplate with id '{payload.template_id}' not found",
|
||||
)
|
||||
|
||||
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(
|
||||
technique_id=payload.technique_id,
|
||||
name=template.name,
|
||||
description=template.description,
|
||||
platform=template.platform,
|
||||
procedure_text=template.attack_procedure,
|
||||
tool_used=template.tool_suggested,
|
||||
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_from_template",
|
||||
entity_type="test",
|
||||
entity_id=test.id,
|
||||
details={
|
||||
"name": test.name,
|
||||
"template_id": str(template.id),
|
||||
"technique_id": str(test.technique_id),
|
||||
},
|
||||
)
|
||||
|
||||
return test
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /tests/{id} — detail with evidences split by team
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -97,7 +244,7 @@ def get_test(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /tests/{id} — update (creator or admin, only in draft/rejected)
|
||||
# PATCH /tests/{id} — general update (draft / rejected)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -113,22 +260,14 @@ def update_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()
|
||||
test = _get_test_or_404(db, test_id)
|
||||
|
||||
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,
|
||||
@@ -155,114 +294,29 @@ def update_test(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /tests/{id}/validate — validate (leads + admin)
|
||||
# PATCH /tests/{id}/red — Red Team update (draft, red_executing)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/{test_id}/validate", response_model=TestOut)
|
||||
def validate_test(
|
||||
@router.patch("/{test_id}/red", response_model=TestOut)
|
||||
def update_test_red(
|
||||
test_id: uuid.UUID,
|
||||
payload: TestValidate,
|
||||
payload: TestRedUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
||||
current_user: User = Depends(require_any_role("red_tech")),
|
||||
):
|
||||
"""Validate the red or blue side of a test (dual validation).
|
||||
"""Red Team updates their fields (allowed in ``draft`` and ``red_executing``)."""
|
||||
test = _get_test_or_404(db, test_id)
|
||||
|
||||
Red Lead approves/rejects the red side; Blue Lead approves/rejects the
|
||||
blue side. When *both* sides are approved the test state moves to
|
||||
``validated``. If either side is rejected the state moves to ``rejected``.
|
||||
"""
|
||||
test = (
|
||||
db.query(Test)
|
||||
.options(joinedload(Test.technique))
|
||||
.filter(Test.id == test_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if test is None:
|
||||
if test.state not in (TestState.draft, TestState.red_executing):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Test not found",
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Cannot update red fields in '{test.state.value}' state (must be draft or red_executing)",
|
||||
)
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
if current_user.role in ("red_lead", "admin"):
|
||||
test.red_validation_status = payload.result.value
|
||||
test.red_validated_by = current_user.id
|
||||
test.red_validated_at = now
|
||||
side = "red"
|
||||
elif current_user.role == "blue_lead":
|
||||
test.blue_validation_status = payload.result.value
|
||||
test.blue_validated_by = current_user.id
|
||||
test.blue_validated_at = now
|
||||
side = "blue"
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions to validate",
|
||||
)
|
||||
|
||||
# Store the overall result from the payload
|
||||
test.result = payload.result
|
||||
|
||||
# Determine aggregate state
|
||||
red_ok = test.red_validation_status == "approved"
|
||||
blue_ok = test.blue_validation_status == "approved"
|
||||
red_rej = test.red_validation_status == "rejected"
|
||||
blue_rej = test.blue_validation_status == "rejected"
|
||||
|
||||
if red_ok and blue_ok:
|
||||
test.state = TestState.validated
|
||||
elif red_rej or blue_rej:
|
||||
test.state = TestState.rejected
|
||||
else:
|
||||
test.state = TestState.in_review
|
||||
|
||||
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={
|
||||
"side": side,
|
||||
"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
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(test, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(test)
|
||||
@@ -270,10 +324,215 @@ def reject_test(
|
||||
log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
action="reject_test",
|
||||
action="update_test_red",
|
||||
entity_type="test",
|
||||
entity_id=test.id,
|
||||
details={"technique_id": str(test.technique_id)},
|
||||
details={"updated_fields": list(update_data.keys())},
|
||||
)
|
||||
|
||||
return test
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /tests/{id}/blue — Blue Team update (blue_evaluating only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.patch("/{test_id}/blue", response_model=TestOut)
|
||||
def update_test_blue(
|
||||
test_id: uuid.UUID,
|
||||
payload: TestBlueUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_any_role("blue_tech")),
|
||||
):
|
||||
"""Blue Team updates their fields (allowed only in ``blue_evaluating``)."""
|
||||
test = _get_test_or_404(db, test_id)
|
||||
|
||||
if test.state != TestState.blue_evaluating:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Cannot update blue fields in '{test.state.value}' state (must be blue_evaluating)",
|
||||
)
|
||||
|
||||
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_blue",
|
||||
entity_type="test",
|
||||
entity_id=test.id,
|
||||
details={"updated_fields": list(update_data.keys())},
|
||||
)
|
||||
|
||||
return test
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /tests/{id}/start-execution — draft → red_executing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/{test_id}/start-execution", response_model=TestOut)
|
||||
def start_execution(
|
||||
test_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_any_role("red_tech")),
|
||||
):
|
||||
"""Move a test from ``draft`` to ``red_executing``."""
|
||||
test = _get_test_or_404(db, test_id)
|
||||
test = wf_start_execution(db, test, current_user)
|
||||
db.refresh(test)
|
||||
return test
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /tests/{id}/submit-red — red_executing → blue_evaluating
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/{test_id}/submit-red", response_model=TestOut)
|
||||
def submit_red(
|
||||
test_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_any_role("red_tech")),
|
||||
):
|
||||
"""Red Team finalises — move from ``red_executing`` to ``blue_evaluating``."""
|
||||
test = _get_test_or_404(db, test_id)
|
||||
test = wf_submit_red(db, test, current_user)
|
||||
db.refresh(test)
|
||||
return test
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /tests/{id}/submit-blue — blue_evaluating → in_review
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/{test_id}/submit-blue", response_model=TestOut)
|
||||
def submit_blue(
|
||||
test_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_any_role("blue_tech")),
|
||||
):
|
||||
"""Blue Team finalises — move from ``blue_evaluating`` to ``in_review``."""
|
||||
test = _get_test_or_404(db, test_id)
|
||||
test = wf_submit_blue(db, test, current_user)
|
||||
db.refresh(test)
|
||||
return test
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /tests/{id}/validate-red — Red Lead validates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/{test_id}/validate-red", response_model=TestOut)
|
||||
def validate_red(
|
||||
test_id: uuid.UUID,
|
||||
payload: TestRedValidate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_any_role("red_lead")),
|
||||
):
|
||||
"""Red Lead approves or rejects the red side of a test."""
|
||||
test = _get_test_with_technique(db, test_id)
|
||||
test = wf_validate_red(
|
||||
db, test, current_user,
|
||||
validation_status=payload.red_validation_status,
|
||||
notes=payload.red_validation_notes,
|
||||
)
|
||||
|
||||
# Recalculate technique status if test reached a terminal state
|
||||
if test.state in (TestState.validated, TestState.rejected):
|
||||
recalculate_technique_status(db, test.technique)
|
||||
|
||||
db.refresh(test)
|
||||
return test
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /tests/{id}/validate-blue — Blue Lead validates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/{test_id}/validate-blue", response_model=TestOut)
|
||||
def validate_blue(
|
||||
test_id: uuid.UUID,
|
||||
payload: TestBlueValidate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_any_role("blue_lead")),
|
||||
):
|
||||
"""Blue Lead approves or rejects the blue side of a test."""
|
||||
test = _get_test_with_technique(db, test_id)
|
||||
test = wf_validate_blue(
|
||||
db, test, current_user,
|
||||
validation_status=payload.blue_validation_status,
|
||||
notes=payload.blue_validation_notes,
|
||||
)
|
||||
|
||||
# Recalculate technique status if test reached a terminal state
|
||||
if test.state in (TestState.validated, TestState.rejected):
|
||||
recalculate_technique_status(db, test.technique)
|
||||
|
||||
db.refresh(test)
|
||||
return test
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /tests/{id}/reopen — rejected → draft
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/{test_id}/reopen", response_model=TestOut)
|
||||
def reopen(
|
||||
test_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
||||
):
|
||||
"""Reopen a rejected test, moving it back to ``draft``."""
|
||||
test = _get_test_or_404(db, test_id)
|
||||
test = wf_reopen(db, test, current_user)
|
||||
db.refresh(test)
|
||||
return test
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /tests/{id}/timeline — audit history for this test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/{test_id}/timeline")
|
||||
def get_test_timeline(
|
||||
test_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return the chronological audit-log history for a test."""
|
||||
# Verify the test exists
|
||||
_get_test_or_404(db, test_id)
|
||||
|
||||
logs = (
|
||||
db.query(AuditLog)
|
||||
.filter(
|
||||
AuditLog.entity_type == "test",
|
||||
AuditLog.entity_id == str(test_id),
|
||||
)
|
||||
.order_by(AuditLog.timestamp.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(log.id),
|
||||
"action": log.action,
|
||||
"user_id": str(log.user_id) if log.user_id else None,
|
||||
"timestamp": log.timestamp.isoformat() if log.timestamp else None,
|
||||
"details": log.details,
|
||||
}
|
||||
for log in logs
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user