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>
This commit is contained in:
Knacky
2026-05-15 14:45:18 +02:00
parent d679ff34d8
commit 447f15213a
16 changed files with 517 additions and 34 deletions

View File

@@ -18,12 +18,13 @@ 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 Any
from typing import Annotated, Any
from flask import Blueprint, abort, g, jsonify, request
from pydantic import BaseModel, Field, ValidationError
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
@@ -34,6 +35,39 @@ 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
# --------------------------------------------------------------------------- #
@@ -130,6 +164,28 @@ def _serialize_test(t: svc.MissionTestView) -> dict[str, Any]:
"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(),
}
@@ -300,7 +356,7 @@ 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()}), 400
return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400
user = _current_user()
try:
view = svc.create_mission(
@@ -345,7 +401,7 @@ def update_mission(mission_id: str):
try:
payload = UpdateMissionPayload.model_validate(raw)
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
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:
@@ -383,7 +439,7 @@ def add_scenarios(mission_id: str):
try:
payload = AddScenariosPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
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(
@@ -416,7 +472,7 @@ def set_members(mission_id: str):
try:
payload = SetMembersPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400
user = _current_user()
try:
view = svc.set_mission_members(
@@ -451,7 +507,7 @@ def transition(mission_id: str):
try:
payload = TransitionPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
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:
@@ -515,8 +571,20 @@ class UpdateMissionTestPayload(BaseModel):
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
# 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"}
@@ -572,6 +640,13 @@ def _serialize_test_detail(t: test_svc.MissionTestDetailView) -> dict[str, Any]:
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,
@@ -645,7 +720,7 @@ def update_mission_test(mission_id: str, test_id: str):
try:
payload = UpdateMissionTestPayload.model_validate(raw)
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400
kwargs: dict[str, Any] = {}
for field in (
@@ -656,6 +731,11 @@ def update_mission_test(mission_id: str, test_id: str):
"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)
@@ -710,7 +790,7 @@ def transition_mission_test(mission_id: str, test_id: str):
try:
payload = TestTransitionPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400
user = _current_user()
try: