Files
Metamorph/backend/app/api/missions.py
Knacky 447f15213a feat(m7): blue review fields + spec amendment + reviewer follow-ups
User feedback after the M7 ship: blue team's Excel workflow had 5 extra
fields we didn't capture. Per-test page also doesn't match their
workflow — they need a tabular view, one table per scenario.

Spec
- tasks/spec.md amended (`revised: 2026-05-15`): §4 in-scope, §F6, §8
  model bullet. §F6 now pins the column matrix, single-row-edit
  semantics, Esc-cancel, blur-confirm, and reconciles detection_level
  as a pill inside the Commentaires cell (no 8th column).
- tasks/todo.md M7 section grew an "Amendement 2026-05-15" sub-block
  tracking backend ☑ and frontend ☐.

Backend
- Migration c2a8f4b1d6e9: 5 nullable columns on mission_tests
  (blue_log_source, blue_siem_logs, blue_incident_at,
  blue_incident_number, blue_incident_recipient_email).
- _BLUE_FIELDS extended; update_mission_test_fields propagates each
  field; MissionTestDetailView + MissionTestView (the nested view in
  GET /missions/{id}) surface every annotation field, plus
  last_actor_*, updated_at, detection_level_key — O(1) batch lookup
  for detection-level keys and last-actor users keeps it scalable.
- UpdateMissionTestPayload accepts each field with length caps
  (120/200_000/120/255).

Reviewer follow-ups applied
- blue_incident_at + executed_at now reject naïve datetimes
  (_ensure_aware_datetime) — Postgres would otherwise interpret
  them in the session TZ, defeating the M7 verbatim-time contract.
- blue_incident_recipient_email goes through a permissive RFC-shape
  regex (_validate_email_shape) so internal/lab TLDs like .local
  / .corp / .test pass — Pydantic EmailStr is too strict (lessons.md
  M2 trap).
- Project-wide: switched `e.errors()` to
  `e.errors(include_context=False, include_url=False)` because the
  AfterValidator-raised ValueError lands in ctx and Flask can't
  serialize it.

Tests
- 5 new pytest cases: blue user writes the 5 new fields, red user is
  individually 403'd on each, round-trip via GET, naïve datetime
  rejected, email shape validated (.local accepted, bad shape 400).
- 138 pytest green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:45:18 +02:00

913 lines
33 KiB
Python

"""Missions API.
Per spec §4: a non-admin user can only see (or edit) missions they are a
member of. The decorator stack here gates the *action type* by permission
code; the service layer applies the membership filter. Both layers fail
closed.
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 re
import uuid
from datetime import date, datetime, timezone
from typing import Annotated, Any
from flask import Blueprint, abort, g, jsonify, request
from pydantic import AfterValidator, 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")
log = logging.getLogger("metamorph.api.missions")
# RFC-shaped email regex — permissive on TLDs so internal/lab domains
# (`.local`, `.corp`, `.test`) pass. We deliberately don't use Pydantic
# `EmailStr`: `email-validator` runs `globally_deliverable=True` by
# default, which rejects everything that's not on the public TLD list
# (lessons.md M2 + the same trap the recipient-email field would hit).
_EMAIL_SHAPE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def _validate_email_shape(value: str | None) -> str | None:
if value is None or value == "":
return value
v = value.strip()
if not _EMAIL_SHAPE.match(v):
raise ValueError("invalid email shape")
return v
def _ensure_aware_datetime(value: datetime | None) -> datetime | None:
"""Reject naïve datetimes — the column is `timestamptz` and Postgres
would interpret a naïve value in the session timezone, which the M7
fixes deliberately moved away from. Clients must send an explicit
offset (or `Z` suffix). The same rule applies to `executed_at`."""
if value is None:
return value
if value.tzinfo is None:
raise ValueError("datetime must include a timezone offset (e.g. trailing Z)")
return value
_AwareDatetime = Annotated[datetime, AfterValidator(_ensure_aware_datetime)]
_EmailShape = Annotated[str, AfterValidator(_validate_email_shape)]
# --------------------------------------------------------------------------- #
# Payloads
# --------------------------------------------------------------------------- #
class MemberPayload(BaseModel):
user_id: uuid.UUID
role_hint: str = Field(min_length=1, max_length=8)
model_config = {"extra": "forbid"}
class CreateMissionPayload(BaseModel):
name: str = Field(min_length=1, max_length=255)
client_target: str | None = Field(default=None, max_length=255)
date_start: date | None = None
date_end: date | None = None
description_md: str | None = Field(default=None, max_length=20_000)
scenario_template_ids: list[uuid.UUID] = Field(default_factory=list, max_length=64)
members: list[MemberPayload] = Field(default_factory=list, max_length=128)
model_config = {"extra": "forbid"}
class UpdateMissionPayload(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=255)
client_target: str | None = Field(default=None, max_length=255)
date_start: date | None = None
date_end: date | None = None
description_md: str | None = Field(default=None, max_length=20_000)
model_config = {"extra": "forbid"}
class AddScenariosPayload(BaseModel):
scenario_template_ids: list[uuid.UUID] = Field(default_factory=list, max_length=64)
model_config = {"extra": "forbid"}
class SetMembersPayload(BaseModel):
members: list[MemberPayload] = Field(default_factory=list, max_length=128)
model_config = {"extra": "forbid"}
class TransitionPayload(BaseModel):
status: str = Field(min_length=1, max_length=16)
model_config = {"extra": "forbid"}
# --------------------------------------------------------------------------- #
# Serialisers
# --------------------------------------------------------------------------- #
def _serialize_member(m: svc.MissionMemberView) -> dict[str, Any]:
return {
"user_id": str(m.user_id),
"user_email": m.user_email,
"user_display_name": m.user_display_name,
"role_hint": m.role_hint,
}
def _serialize_mitre_tag(tag: svc.MissionMitreTagView) -> dict[str, Any]:
return {
"kind": tag.kind,
"external_id": tag.external_id,
"name": tag.name,
"url": tag.url,
}
def _serialize_test(t: svc.MissionTestView) -> dict[str, Any]:
return {
"id": str(t.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,
"mitre_tags": [_serialize_mitre_tag(tag) for tag in t.mitre_tags],
"source_test_template_id": (
str(t.source_test_template_id) if t.source_test_template_id else None
),
# Annotation fields (post-M7 feedback): included in the nested mission
# detail so the front-end scenario table renders without a per-test
# round trip.
"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,
"blue_log_source": t.blue_log_source,
"blue_siem_logs": t.blue_siem_logs,
"blue_incident_at": (
t.blue_incident_at.isoformat() if t.blue_incident_at else None
),
"blue_incident_number": t.blue_incident_number,
"blue_incident_recipient_email": t.blue_incident_recipient_email,
"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(),
}
def _serialize_scenario(sc: svc.MissionScenarioView) -> dict[str, Any]:
return {
"id": str(sc.id),
"position": sc.position,
"snapshot_name": sc.snapshot_name,
"snapshot_description": sc.snapshot_description,
"tests": [_serialize_test(t) for t in sc.tests],
"source_scenario_template_id": (
str(sc.source_scenario_template_id)
if sc.source_scenario_template_id
else None
),
}
def _serialize_list_item(m: svc.MissionListItemView) -> dict[str, Any]:
return {
"id": str(m.id),
"name": m.name,
"client_target": m.client_target,
"date_start": m.date_start.isoformat() if m.date_start else None,
"date_end": m.date_end.isoformat() if m.date_end else None,
"status": m.status,
"description_md": m.description_md,
"visibility_mode": m.visibility_mode,
"scenarios_count": m.scenarios_count,
"tests_count": m.tests_count,
"members_count": m.members_count,
"deleted_at": m.deleted_at.isoformat() if m.deleted_at else None,
"created_at": m.created_at.isoformat(),
"updated_at": m.updated_at.isoformat(),
}
def _serialize_detail(m: svc.MissionView) -> dict[str, Any]:
base = {
"id": str(m.id),
"name": m.name,
"client_target": m.client_target,
"date_start": m.date_start.isoformat() if m.date_start else None,
"date_end": m.date_end.isoformat() if m.date_end else None,
"status": m.status,
"description_md": m.description_md,
"visibility_mode": m.visibility_mode,
"scenarios_count": m.scenarios_count,
"tests_count": m.tests_count,
"members_count": m.members_count,
"deleted_at": m.deleted_at.isoformat() if m.deleted_at else None,
"created_at": m.created_at.isoformat(),
"updated_at": m.updated_at.isoformat(),
}
base["scenarios"] = [_serialize_scenario(sc) for sc in m.scenarios]
base["members"] = [_serialize_member(mb) for mb in m.members]
return base
# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def _parse_uuid_or_400(raw: str) -> uuid.UUID | None:
try:
return uuid.UUID(raw)
except ValueError:
return None
def _pagination_args() -> tuple[int, int] | tuple[None, tuple[int, str]]:
try:
limit = int(request.args.get("limit", "100"))
offset = int(request.args.get("offset", "0"))
except ValueError:
return None, (400, "invalid_pagination")
return max(1, min(limit, 500)), max(0, offset)
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 _to_assignments(payload_members: list[MemberPayload]) -> list[svc.MemberAssignment]:
return [
svc.MemberAssignment(user_id=m.user_id, role_hint=m.role_hint)
for m in payload_members
]
# --------------------------------------------------------------------------- #
# Endpoints
# --------------------------------------------------------------------------- #
@bp.get("")
@require_auth
@require_perm("mission.read")
def list_missions():
paging = _pagination_args()
if paging[0] is None:
return jsonify({"error": paging[1][1]}), paging[1][0]
limit, offset = paging
q = request.args.get("q") or None
status = request.args.get("status") or None
client = request.args.get("client") or None
include_deleted = request.args.get("include_deleted", "false").lower() == "true"
user = _current_user()
if include_deleted and not user.is_admin:
return jsonify({"error": "forbidden"}), 403
try:
items, total = svc.list_missions(
viewer_id=user.id,
viewer_is_admin=user.is_admin,
q=q,
status=status,
client=client,
include_deleted=include_deleted,
limit=limit,
offset=offset,
)
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
return jsonify(
{
"items": [_serialize_list_item(it) for it in items],
"total": total,
"limit": limit,
"offset": offset,
}
)
@bp.get("/<mission_id>")
@require_auth
@require_perm("mission.read")
def get_mission(mission_id: str):
mid = _parse_uuid_or_400(mission_id)
if mid is None:
return jsonify({"error": "invalid_id"}), 400
include_deleted = request.args.get("include_deleted", "false").lower() == "true"
user = _current_user()
if include_deleted and not user.is_admin:
return jsonify({"error": "forbidden"}), 403
try:
view = svc.get_mission(
mid,
viewer_id=user.id,
viewer_is_admin=user.is_admin,
include_deleted=include_deleted,
)
except svc.MissionNotFound:
return jsonify({"error": "not_found"}), 404
return jsonify(_serialize_detail(view))
@bp.post("")
@require_auth
@require_perm("mission.create")
def create_mission():
try:
payload = CreateMissionPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400
user = _current_user()
try:
view = svc.create_mission(
name=payload.name,
creator_id=user.id,
creator_is_admin=user.is_admin,
client_target=payload.client_target,
date_start=payload.date_start,
date_end=payload.date_end,
description_md=payload.description_md,
scenario_template_ids=list(payload.scenario_template_ids),
members=_to_assignments(payload.members),
)
except svc.UnknownScenarioTemplate as e:
return jsonify({"error": "unknown_scenario_template", "message": str(e)}), 400
except svc.UnknownUser as e:
return jsonify({"error": "unknown_user", "message": str(e)}), 400
except svc.InvalidMemberPayload as e:
return jsonify({"error": "invalid_member", "message": str(e)}), 400
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
log.info(
"metamorph.mission.created",
extra={
"mission_id": str(view.id),
"scenarios": view.scenarios_count,
"tests": view.tests_count,
"members": view.members_count,
},
)
return jsonify(_serialize_detail(view)), 201
@bp.put("/<mission_id>")
@require_auth
@require_perm("mission.update")
def update_mission(mission_id: str):
mid = _parse_uuid_or_400(mission_id)
if mid is None:
return jsonify({"error": "invalid_id"}), 400
raw = request.get_json(silent=True) or {}
try:
payload = UpdateMissionPayload.model_validate(raw)
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400
# Distinguish "not provided" from "explicitly null" by looking at the raw body.
kwargs: dict[str, Any] = {}
if "name" in raw and payload.name is not None:
kwargs["name"] = payload.name
if "client_target" in raw:
kwargs["client_target"] = payload.client_target
if "date_start" in raw:
kwargs["date_start"] = payload.date_start
if "date_end" in raw:
kwargs["date_end"] = payload.date_end
if "description_md" in raw:
kwargs["description_md"] = payload.description_md
user = _current_user()
try:
view = svc.update_mission_metadata(
mid,
viewer_id=user.id,
viewer_is_admin=user.is_admin,
**kwargs,
)
except svc.MissionNotFound:
return jsonify({"error": "not_found"}), 404
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
return jsonify(_serialize_detail(view))
@bp.post("/<mission_id>/scenarios")
@require_auth
@require_perm("mission.update")
def add_scenarios(mission_id: str):
mid = _parse_uuid_or_400(mission_id)
if mid is None:
return jsonify({"error": "invalid_id"}), 400
try:
payload = AddScenariosPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400
user = _current_user()
try:
view = svc.add_scenarios_to_mission(
mid,
list(payload.scenario_template_ids),
viewer_id=user.id,
viewer_is_admin=user.is_admin,
)
except svc.MissionNotFound:
return jsonify({"error": "not_found"}), 404
except svc.UnknownScenarioTemplate as e:
return jsonify({"error": "unknown_scenario_template", "message": str(e)}), 400
log.info(
"metamorph.mission.scenarios_added",
extra={
"mission_id": str(mid),
"added": len(payload.scenario_template_ids),
},
)
return jsonify(_serialize_detail(view))
@bp.put("/<mission_id>/members")
@require_auth
@require_perm("mission.update")
def set_members(mission_id: str):
mid = _parse_uuid_or_400(mission_id)
if mid is None:
return jsonify({"error": "invalid_id"}), 400
try:
payload = SetMembersPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400
user = _current_user()
try:
view = svc.set_mission_members(
mid,
_to_assignments(payload.members),
viewer_id=user.id,
viewer_is_admin=user.is_admin,
)
except svc.MissionNotFound:
return jsonify({"error": "not_found"}), 404
except svc.UnknownUser as e:
return jsonify({"error": "unknown_user", "message": str(e)}), 400
except svc.InvalidMemberPayload as e:
return jsonify({"error": "invalid_member", "message": str(e)}), 400
return jsonify(_serialize_detail(view))
@bp.post("/<mission_id>/transition")
@require_auth
@require_perm("mission.update", "mission.archive")
def transition(mission_id: str):
"""Status transition. The outer decorator gates the endpoint on holding
EITHER `mission.update` or `mission.archive` — so a request with neither
perm sees 403 before its body is even parsed (no shape leak via 400).
The inner refinement then enforces the per-target rule: `mission.archive`
is required when the target is `archived`; `mission.update` covers the
other transitions. Admins bypass via the decorator's `is_admin` check.
"""
mid = _parse_uuid_or_400(mission_id)
if mid is None:
return jsonify({"error": "invalid_id"}), 400
try:
payload = TransitionPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400
user = _current_user()
required = "mission.archive" if payload.status == "archived" else "mission.update"
if not user.is_admin and required not in user.permissions:
log.info(
"metamorph.auth.permission_denied",
extra={
"user_id": str(user.id),
"required": [required],
"had": sorted(user.permissions),
},
)
return jsonify({"error": "forbidden"}), 403
try:
view = svc.transition_mission_status(
mid,
payload.status,
viewer_id=user.id,
viewer_is_admin=user.is_admin,
)
except svc.MissionNotFound:
return jsonify({"error": "not_found"}), 404
except svc.InvalidTransition as e:
return jsonify({"error": "invalid_transition", "message": str(e)}), 409
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
log.info(
"metamorph.mission.transitioned",
extra={"mission_id": str(mid), "status": view.status},
)
return jsonify(_serialize_detail(view))
@bp.delete("/<mission_id>")
@require_auth
@require_perm("mission.delete")
def soft_delete_mission(mission_id: str):
mid = _parse_uuid_or_400(mission_id)
if mid is None:
return jsonify({"error": "invalid_id"}), 400
user = _current_user()
try:
svc.soft_delete_mission(
mid,
viewer_id=user.id,
viewer_is_admin=user.is_admin,
)
except svc.MissionNotFound:
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
# Both timestamps must be aware (Z or explicit offset). See
# `_ensure_aware_datetime` for why we reject naïve.
executed_at: _AwareDatetime | None = None
executed_at_overridden: bool | None = None
# Post-M7 blue review fields (cf. user feedback 2026-05-15). Free-form
# short text for log_source / incident_number; long text for siem_logs;
# email goes through `_validate_email_shape` (permissive RFC regex —
# we serve internal/lab domains so strict TLD lists are too tight,
# cf. tasks/lessons.md M2).
blue_log_source: str | None = Field(default=None, max_length=120)
blue_siem_logs: str | None = Field(default=None, max_length=200_000)
blue_incident_at: _AwareDatetime | None = None
blue_incident_number: str | None = Field(default=None, max_length=120)
blue_incident_recipient_email: _EmailShape | None = Field(default=None, max_length=255)
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,
"blue_log_source": t.blue_log_source,
"blue_siem_logs": t.blue_siem_logs,
"blue_incident_at": (
t.blue_incident_at.isoformat() if t.blue_incident_at else None
),
"blue_incident_number": t.blue_incident_number,
"blue_incident_recipient_email": t.blue_incident_recipient_email,
"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(include_context=False, include_url=False)}), 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",
"blue_log_source",
"blue_siem_logs",
"blue_incident_at",
"blue_incident_number",
"blue_incident_recipient_email",
):
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(include_context=False, include_url=False)}), 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(),
}
)