feat(m7): per-test execution — red/blue zones, evidence pipeline, activity poll

DoD M7 (spec §F5 + §F6 + §F8 + tasks/todo.md M7) covered end-to-end:

Backend
- New migration `91a4e7c6d2f3` adds `mission_tests.last_actor_id` (FK users
  ON DELETE SET NULL) and `ix_mission_tests_updated_at` for the polling query.
- `detection_levels`: 4 default rows seeded at boot, `GET /detection-levels`
  read-only (CRUD lands in M8).
- `mission_tests` service + `missions` API extension:
  - `GET /missions/{id}/tests/{test_id}` — full detail incl. evidence list
  - `PUT  /missions/{id}/tests/{test_id}` — patch red/blue fields with per-field
    perm classification (`mission.write_red_fields` vs `mission.write_blue_fields`)
  - `POST /missions/{id}/tests/{test_id}/transition` — pending↔skipped/blocked
    and pending→executed→reviewed_by_blue (+ undo paths), side-aware perm gate
    that fires *before* idempotency, `executed_at` auto-stamped on the way in
  - `GET  /missions/{id}/activity?since=<ISO>` — drives the 15 s polling badge
- `evidence` service + top-level `/evidence/<id>` API:
  - Streaming upload, SHA256 chunk-by-chunk, 25 MB cap, ext+MIME whitelist
  - Content-addressed storage at ${EVIDENCE_DIR}/<mission>/<test>/<sha256><ext>
  - Atomic `os.replace`, hex-validated SHA path component, root-dir guard
  - Membership-aware (404 on miss/forbidden, no existence leak)
- `/diag/reset` now wipes ${EVIDENCE_DIR}/* in test mode (symlink-safe) and
  re-seeds detection levels as a safety net.

Frontend
- `lib/missions.ts` — M7 types + queryKey factory + state-machine matrix.
- `pages/MissionTestPage.tsx` — two-zone layout: red border (command, output,
  comment, mark-executed + override toggle) and cyan border (detection-level
  select, comment, drag-and-drop evidence dropzone). Last-touched badge polls
  /activity every 15 s, gated on document.visibilityState. Per-field disable
  based on the user's red/blue perms (server stays the arbiter).
- `pages/MissionDetailPage.tsx` — test rows link to the new per-test page.
- `App.tsx` — registers /missions/:id/tests/:testId behind RequireAuth.
- `HomePage.tsx` — hero + roadmap card bumped to M7; next is M8.

Tests
- `backend/tests/test_mission_tests.py` — 27 pytest tests (red/blue field
  gating, state-machine matrix incl. idempotent-side enforcement, executed_at
  override, 24/26 MB upload + SHA256, MIME/ext whitelist, soft-delete hide,
  activity polling with URL-encoded `since`, membership 404 vs admin bypass,
  cross-mission evidence access).
- `e2e/tests/m7-execution.spec.ts` — 5 Playwright tests against the live stack
  (red-only/blue-only API gating, mark-executed + reviewed_by_blue side
  enforcement, 24 MB/26 MB upload + SHA256 round-trip, SPA per-test page save
  + transition, non-member 404 message). afterAll restores stable admin and
  re-syncs MITRE.

Docs
- CHANGELOG.md: M7 section + post-M7 review-pass subsection.
- README.md: status, feature blurb, roadmap, testing-m7 link.
- tasks/testing-m7.md: manual + automated procedure with transition matrix
  and perm-gating table.
- tasks/lessons.md: M7 retrospectives (LogRecord `created` trap, URL-encoded
  query timestamps, perm-before-flush, atomic move, polling visibility gate).

Test count: 133 pytest / 49 Playwright, all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-05-14 08:16:48 +02:00
parent 3c1675966d
commit ed70458d8f
23 changed files with 4273 additions and 19 deletions

View File

@@ -0,0 +1,37 @@
"""Detection-level taxonomy API.
Read-only in M7 — M8 will add CRUD. The four defaults are seeded at boot
via `app.services.detection_levels.seed_detection_levels()`.
"""
from __future__ import annotations
from typing import Any
from flask import Blueprint, jsonify
from app.core.auth_decorators import require_auth, require_perm
from app.services import detection_levels as svc
bp = Blueprint("detection_levels", __name__, url_prefix="/detection-levels")
def _serialize(view: svc.DetectionLevelView) -> dict[str, Any]:
return {
"id": str(view.id),
"key": view.key,
"label_fr": view.label_fr,
"label_en": view.label_en,
"color_token": view.color_token,
"position": view.position,
"is_default": view.is_default,
"is_system": view.is_system,
}
@bp.get("")
@require_auth
@require_perm("detection_level.read")
def list_detection_levels():
items = svc.list_detection_levels()
return jsonify({"items": [_serialize(it) for it in items]})

View File

@@ -8,6 +8,8 @@ is the bedrock of the e2e suite (clean DB + freshly minted install token).
from __future__ import annotations
import logging
import shutil
from pathlib import Path
from flask import Blueprint, abort, jsonify
from sqlalchemy import text
@@ -16,6 +18,7 @@ from sqlalchemy.exc import SQLAlchemyError
from app.core.config import settings
from app.core.install_token import regenerate_install_token
from app.db.session import get_engine
from app.services.detection_levels import seed_detection_levels
bp = Blueprint("diag", __name__, url_prefix="/diag")
log = logging.getLogger("metamorph.diag")
@@ -108,10 +111,39 @@ def reset_test_state():
"mitre_techniques, mitre_tactics RESTART IDENTITY CASCADE"
)
)
# Detection levels (M7) are reference data seeded at boot — they
# are explicitly preserved here, but the seed is re-run below to
# cover the edge case where an operator hand-tweaked the rows
# before invoking the reset. The seed is idempotent.
except SQLAlchemyError as e:
log.error("metamorph.diag.reset_failed", extra={"error": str(e)})
return jsonify({"reset": False, "error": "database_error"}), 500
# M7: wipe the evidence directory so an e2e suite that uploads bytes does
# not accumulate files across runs. Only in `test`; in `dev` we keep the
# files (operator likely wants to inspect what they uploaded by hand).
if settings.APP_ENV == "test":
evidence_root = Path(settings.EVIDENCE_DIR)
if evidence_root.exists():
for child in evidence_root.iterdir():
# Symlinks are unlinked, never followed — a hostile or
# accidental symlink inside the evidence dir must NOT cause
# rmtree to recurse into an unrelated tree.
try:
if child.is_symlink() or not child.is_dir():
child.unlink(missing_ok=True)
else:
shutil.rmtree(child)
except OSError as e:
log.warning(
"metamorph.diag.evidence_cleanup_failed",
extra={"path": str(child), "error": str(e)},
)
# Detection levels were preserved during the wipe; re-run the seed to
# cover the off-chance an operator has deleted some rows manually.
seed_detection_levels()
token = regenerate_install_token()
# Clear the in-memory rate-limit counters so the e2e suite that follows can

123
backend/app/api/evidence.py Normal file
View File

@@ -0,0 +1,123 @@
"""Top-level evidence routes (download + soft-delete by id).
Upload is collocated under `/missions/{id}/tests/{test_id}/evidence` because
that path encodes the parent context. Once an evidence row exists, callers
can address it by id directly — these routes own that side.
Membership/visibility is enforced through the service (`EvidenceNotFound` is
returned for both "missing" and "not visible" outcomes — no existence leak).
"""
from __future__ import annotations
import logging
import uuid
from typing import Any
from flask import Blueprint, abort, g, jsonify, request, send_file
from app.core.auth_decorators import AuthenticatedUser, require_auth, require_perm
from app.services import evidence as svc
bp = Blueprint("evidence", __name__, url_prefix="/evidence")
log = logging.getLogger("metamorph.api.evidence")
def _serialize(ev: svc.EvidenceView) -> dict[str, Any]:
return {
"id": str(ev.id),
"mission_test_id": str(ev.mission_test_id),
"sha256": ev.sha256,
"mime": ev.mime,
"size_bytes": ev.size_bytes,
"original_filename": ev.original_filename,
"uploaded_by_user_id": (
str(ev.uploaded_by_user_id) if ev.uploaded_by_user_id else None
),
"uploaded_by_email": ev.uploaded_by_email,
"uploaded_by_display_name": ev.uploaded_by_display_name,
"uploaded_at": ev.uploaded_at.isoformat(),
"created_at": ev.created_at.isoformat(),
}
def _current_user() -> AuthenticatedUser:
user: AuthenticatedUser | None = getattr(g, "current_user", None)
if user is None:
abort(401, description="not authenticated")
assert user is not None # for Pyright; abort raises HTTPException
return user
def _parse_uuid_or_400(raw: str) -> uuid.UUID | None:
try:
return uuid.UUID(raw)
except ValueError:
return None
@bp.get("/<evidence_id>")
@require_auth
@require_perm("mission.read")
def get_evidence(evidence_id: str):
"""Metadata read. Use `?download=true` to receive the bytes inline.
The download mode streams the on-disk file via `send_file` with the
original filename in `Content-Disposition`. Browsers handle the
Content-Type guess from the stored mime.
"""
eid = _parse_uuid_or_400(evidence_id)
if eid is None:
return jsonify({"error": "invalid_id"}), 400
user = _current_user()
want_download = request.args.get("download", "false").lower() == "true"
if want_download:
try:
view, path = svc.get_evidence_for_download(
eid, viewer_id=user.id, viewer_is_admin=user.is_admin
)
except svc.EvidenceNotFound:
return jsonify({"error": "not_found"}), 404
log.info(
"metamorph.evidence.download",
extra={
"evidence_id": str(eid),
"user_id": str(user.id),
"size_bytes": view.size_bytes,
},
)
return send_file(
str(path),
mimetype=view.mime,
as_attachment=True,
download_name=view.original_filename,
etag=view.sha256,
conditional=True,
max_age=0,
)
try:
view = svc.get_evidence(
eid, viewer_id=user.id, viewer_is_admin=user.is_admin
)
except svc.EvidenceNotFound:
return jsonify({"error": "not_found"}), 404
return jsonify(_serialize(view))
@bp.delete("/<evidence_id>")
@require_auth
@require_perm("mission.write_blue_fields")
def soft_delete_evidence(evidence_id: str):
eid = _parse_uuid_or_400(evidence_id)
if eid is None:
return jsonify({"error": "invalid_id"}), 400
user = _current_user()
try:
svc.soft_delete_evidence(
eid, viewer_id=user.id, viewer_is_admin=user.is_admin
)
except svc.EvidenceNotFound:
return jsonify({"error": "not_found"}), 404
return jsonify({"ok": True})

View File

@@ -9,19 +9,25 @@ Status transitions are routed through a single POST endpoint that accepts a
target status. We accept either `mission.update` or `mission.archive` at the
gate — archiving requires the dedicated perm if the target is `archived`, and
the service enforces the lifecycle graph (`_VALID_TRANSITIONS`).
M7 extends this blueprint with per-test routes under `/missions/<id>/tests/...`
plus an activity polling endpoint. The split is purely organisational — the
membership and visibility rules stay identical to M6.
"""
from __future__ import annotations
import logging
import uuid
from datetime import date
from datetime import date, datetime, timezone
from typing import Any
from flask import Blueprint, abort, g, jsonify, request
from pydantic import BaseModel, Field, ValidationError
from app.core.auth_decorators import AuthenticatedUser, require_auth, require_perm
from app.services import evidence as evidence_svc
from app.services import mission_tests as test_svc
from app.services import missions as svc
bp = Blueprint("missions", __name__, url_prefix="/missions")
@@ -496,3 +502,331 @@ def soft_delete_mission(mission_id: str):
return jsonify({"error": "not_found"}), 404
log.info("metamorph.mission.soft_deleted", extra={"mission_id": str(mid)})
return jsonify({"ok": True})
# =========================================================================== #
# M7 — per-test routes
# =========================================================================== #
class UpdateMissionTestPayload(BaseModel):
red_command: str | None = Field(default=None, max_length=20_000)
red_output: str | None = Field(default=None, max_length=200_000)
red_comment_md: str | None = Field(default=None, max_length=20_000)
blue_comment_md: str | None = Field(default=None, max_length=20_000)
detection_level_id: uuid.UUID | None = None
executed_at: datetime | None = None
executed_at_overridden: bool | None = None
model_config = {"extra": "forbid"}
class TestTransitionPayload(BaseModel):
target_state: str = Field(min_length=1, max_length=24)
model_config = {"extra": "forbid"}
def _serialize_evidence(ev: test_svc.EvidenceView) -> dict[str, Any]:
return {
"id": str(ev.id),
"mission_test_id": str(ev.mission_test_id),
"sha256": ev.sha256,
"mime": ev.mime,
"size_bytes": ev.size_bytes,
"original_filename": ev.original_filename,
"uploaded_by_user_id": (
str(ev.uploaded_by_user_id) if ev.uploaded_by_user_id else None
),
"uploaded_by_email": ev.uploaded_by_email,
"uploaded_by_display_name": ev.uploaded_by_display_name,
"uploaded_at": ev.uploaded_at.isoformat(),
"created_at": ev.created_at.isoformat(),
}
def _serialize_test_detail(t: test_svc.MissionTestDetailView) -> dict[str, Any]:
return {
"id": str(t.id),
"mission_id": str(t.mission_id),
"scenario_id": str(t.scenario_id),
"position": t.position,
"snapshot_name": t.snapshot_name,
"snapshot_description": t.snapshot_description,
"snapshot_objective": t.snapshot_objective,
"snapshot_procedure_md": t.snapshot_procedure_md,
"snapshot_prerequisites_md": t.snapshot_prerequisites_md,
"snapshot_expected_red_md": t.snapshot_expected_red_md,
"snapshot_expected_blue_md": t.snapshot_expected_blue_md,
"snapshot_opsec_level": t.snapshot_opsec_level,
"snapshot_tags": t.snapshot_tags,
"snapshot_expected_iocs": t.snapshot_expected_iocs,
"state": t.state,
"executed_at": t.executed_at.isoformat() if t.executed_at else None,
"executed_at_overridden": t.executed_at_overridden,
"red_command": t.red_command,
"red_output": t.red_output,
"red_comment_md": t.red_comment_md,
"blue_comment_md": t.blue_comment_md,
"detection_level_id": (
str(t.detection_level_id) if t.detection_level_id else None
),
"detection_level_key": t.detection_level_key,
"last_actor_id": str(t.last_actor_id) if t.last_actor_id else None,
"last_actor_email": t.last_actor_email,
"last_actor_display_name": t.last_actor_display_name,
"updated_at": t.updated_at.isoformat(),
"mitre_tags": [
{
"kind": tag.kind,
"external_id": tag.external_id,
"name": tag.name,
"url": tag.url,
}
for tag in t.mitre_tags
],
"evidence": [_serialize_evidence(e) for e in t.evidence],
}
def _serialize_activity(a: test_svc.ActivityEntryView) -> dict[str, Any]:
return {
"test_id": str(a.test_id),
"scenario_id": str(a.scenario_id),
"state": a.state,
"updated_at": a.updated_at.isoformat(),
"last_actor_id": str(a.last_actor_id) if a.last_actor_id else None,
"last_actor_email": a.last_actor_email,
"last_actor_display_name": a.last_actor_display_name,
}
def _has_perm(user: AuthenticatedUser, code: str) -> bool:
return user.is_admin or code in user.permissions
@bp.get("/<mission_id>/tests/<test_id>")
@require_auth
@require_perm("mission.read")
def get_mission_test(mission_id: str, test_id: str):
mid = _parse_uuid_or_400(mission_id)
tid = _parse_uuid_or_400(test_id)
if mid is None or tid is None:
return jsonify({"error": "invalid_id"}), 400
user = _current_user()
try:
view = test_svc.get_mission_test(
mid, tid, viewer_id=user.id, viewer_is_admin=user.is_admin
)
except svc.MissionNotFound:
return jsonify({"error": "not_found"}), 404
except test_svc.MissionTestNotFound:
return jsonify({"error": "not_found"}), 404
return jsonify(_serialize_test_detail(view))
@bp.put("/<mission_id>/tests/<test_id>")
@require_auth
@require_perm("mission.write_red_fields", "mission.write_blue_fields")
def update_mission_test(mission_id: str, test_id: str):
"""Patch any subset of red/blue fields on a test.
The outer decorator gates on *either* side perm so a user with only
`write_blue_fields` reaches the handler — but the service then refuses
individual fields they cannot write (red fields → 403). The membership
filter remains row-level inside the service.
"""
mid = _parse_uuid_or_400(mission_id)
tid = _parse_uuid_or_400(test_id)
if mid is None or tid is None:
return jsonify({"error": "invalid_id"}), 400
raw = request.get_json(silent=True) or {}
try:
payload = UpdateMissionTestPayload.model_validate(raw)
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
kwargs: dict[str, Any] = {}
for field in (
"red_command",
"red_output",
"red_comment_md",
"blue_comment_md",
"detection_level_id",
"executed_at",
"executed_at_overridden",
):
if field in raw:
kwargs[field] = getattr(payload, field)
user = _current_user()
try:
view = test_svc.update_mission_test_fields(
mid,
tid,
viewer_id=user.id,
viewer_is_admin=user.is_admin,
has_red_perm=_has_perm(user, "mission.write_red_fields"),
has_blue_perm=_has_perm(user, "mission.write_blue_fields"),
**kwargs,
)
except svc.MissionNotFound:
return jsonify({"error": "not_found"}), 404
except test_svc.MissionTestNotFound:
return jsonify({"error": "not_found"}), 404
except test_svc.MissingFieldPermission as e:
log.info(
"metamorph.mission_test.field_perm_denied",
extra={
"mission_id": str(mid),
"test_id": str(tid),
"user_id": str(user.id),
"reason": str(e),
},
)
return jsonify({"error": "forbidden", "message": str(e)}), 403
except test_svc.InvalidTestPayload as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
log.info(
"metamorph.mission_test.updated",
extra={
"mission_id": str(mid),
"test_id": str(tid),
"fields": sorted(kwargs.keys()),
},
)
return jsonify(_serialize_test_detail(view))
@bp.post("/<mission_id>/tests/<test_id>/transition")
@require_auth
@require_perm("mission.write_red_fields", "mission.write_blue_fields")
def transition_mission_test(mission_id: str, test_id: str):
mid = _parse_uuid_or_400(mission_id)
tid = _parse_uuid_or_400(test_id)
if mid is None or tid is None:
return jsonify({"error": "invalid_id"}), 400
try:
payload = TestTransitionPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
user = _current_user()
try:
view = test_svc.transition_mission_test(
mid,
tid,
payload.target_state,
viewer_id=user.id,
viewer_is_admin=user.is_admin,
has_red_perm=_has_perm(user, "mission.write_red_fields"),
has_blue_perm=_has_perm(user, "mission.write_blue_fields"),
)
except svc.MissionNotFound:
return jsonify({"error": "not_found"}), 404
except test_svc.MissionTestNotFound:
return jsonify({"error": "not_found"}), 404
except test_svc.MissingFieldPermission as e:
return jsonify({"error": "forbidden", "message": str(e)}), 403
except test_svc.InvalidTestTransition as e:
return jsonify({"error": "invalid_transition", "message": str(e)}), 409
except test_svc.InvalidTestPayload as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
log.info(
"metamorph.mission_test.transitioned",
extra={
"mission_id": str(mid),
"test_id": str(tid),
"state": view.state,
},
)
return jsonify(_serialize_test_detail(view))
@bp.post("/<mission_id>/tests/<test_id>/evidence")
@require_auth
@require_perm("mission.write_blue_fields")
def upload_evidence(mission_id: str, test_id: str):
"""Multipart upload — single `file` part. Returns the new evidence row.
Streaming + size cap + SHA256 calc happen in the service; we just sniff
the request and surface the right error codes.
"""
mid = _parse_uuid_or_400(mission_id)
tid = _parse_uuid_or_400(test_id)
if mid is None or tid is None:
return jsonify({"error": "invalid_id"}), 400
upload = request.files.get("file")
if upload is None or not upload.filename:
return jsonify({"error": "missing_file"}), 400
user = _current_user()
try:
view = evidence_svc.add_evidence(
mid,
tid,
file_stream=upload.stream,
original_filename=upload.filename,
mime=upload.mimetype or "application/octet-stream",
viewer_id=user.id,
viewer_is_admin=user.is_admin,
)
except svc.MissionNotFound:
return jsonify({"error": "not_found"}), 404
except test_svc.MissionTestNotFound:
return jsonify({"error": "not_found"}), 404
except evidence_svc.EvidenceValidationError as e:
return jsonify({"error": e.code, "message": str(e)}), 400
except evidence_svc.EvidenceStorageError as e:
return jsonify({"error": "storage_failed", "message": str(e)}), 500
log.info(
"metamorph.api.evidence.uploaded",
extra={
"mission_id": str(mid),
"test_id": str(tid),
"evidence_id": str(view.id),
"size_bytes": view.size_bytes,
},
)
return jsonify(_serialize_evidence(view)), 201
@bp.get("/<mission_id>/activity")
@require_auth
@require_perm("mission.read")
def mission_activity(mission_id: str):
"""Polled by the per-test page to drive the "modified by X" badge.
Accepts an optional `since=<ISO datetime>` filter. Returns only mission
tests, not auth/templates — those are out of scope for this indicator.
"""
mid = _parse_uuid_or_400(mission_id)
if mid is None:
return jsonify({"error": "invalid_id"}), 400
since_raw = request.args.get("since")
since: datetime | None = None
if since_raw:
try:
since = datetime.fromisoformat(since_raw)
except ValueError:
return jsonify({"error": "invalid_since"}), 400
user = _current_user()
try:
entries = test_svc.list_activity_since(
mid,
viewer_id=user.id,
viewer_is_admin=user.is_admin,
since=since,
)
except svc.MissionNotFound:
return jsonify({"error": "not_found"}), 404
return jsonify(
{
"items": [_serialize_activity(e) for e in entries],
"server_time": datetime.now(tz=timezone.utc).isoformat(),
}
)

View File

@@ -5,7 +5,9 @@ from __future__ import annotations
from flask import Blueprint
from app.api.auth import bp as auth_bp
from app.api.detection_levels import bp as detection_levels_bp
from app.api.diag import bp as diag_bp
from app.api.evidence import bp as evidence_bp
from app.api.groups import bp as groups_bp
from app.api.health import bp as health_bp
from app.api.invitations import bp as invitations_bp
@@ -30,3 +32,5 @@ bp.register_blueprint(mitre_bp)
bp.register_blueprint(test_templates_bp)
bp.register_blueprint(scenario_templates_bp)
bp.register_blueprint(missions_bp)
bp.register_blueprint(detection_levels_bp)
bp.register_blueprint(evidence_bp)