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:
@@ -77,7 +77,7 @@ def login():
|
||||
try:
|
||||
payload = LoginPayload.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
|
||||
try:
|
||||
pair = auth_svc.login(payload.email, payload.password)
|
||||
except auth_svc.InvalidCredentials:
|
||||
@@ -144,7 +144,7 @@ def change_password():
|
||||
try:
|
||||
payload = ChangePasswordPayload.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
|
||||
try:
|
||||
auth_svc.change_password(g.current_user.id, payload.current_password, payload.new_password)
|
||||
except auth_svc.InvalidCredentials:
|
||||
|
||||
@@ -84,7 +84,7 @@ def create_group():
|
||||
try:
|
||||
payload = CreateGroupPayload.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
|
||||
try:
|
||||
g = groups_svc.create_group(name=payload.name, description=payload.description)
|
||||
except groups_svc.GroupNameConflict as e:
|
||||
@@ -106,7 +106,7 @@ def update_group(group_id: str):
|
||||
try:
|
||||
payload = UpdateGroupPayload.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
|
||||
description_unset = "description" not in raw
|
||||
try:
|
||||
g = groups_svc.update_group(
|
||||
@@ -153,7 +153,7 @@ def set_permissions(group_id: str):
|
||||
try:
|
||||
payload = SetPermissionsPayload.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
|
||||
try:
|
||||
g = groups_svc.set_group_permissions(gid, payload.codes)
|
||||
except groups_svc.GroupNotFound:
|
||||
|
||||
@@ -36,7 +36,7 @@ def create():
|
||||
try:
|
||||
payload = CreateInvitationPayload.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
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
@@ -124,7 +124,7 @@ def accept(token: str):
|
||||
try:
|
||||
payload = AcceptInvitationPayload.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
|
||||
try:
|
||||
user_id = inv_svc.accept(
|
||||
token,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -125,7 +125,7 @@ def create_scenario_template():
|
||||
try:
|
||||
payload = CreateScenarioPayload.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
|
||||
try:
|
||||
view = svc.create_scenario_template(
|
||||
name=payload.name,
|
||||
@@ -154,7 +154,7 @@ def update_scenario_template(scenario_id: str):
|
||||
try:
|
||||
payload = UpdateScenarioPayload.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] = {}
|
||||
if "name" in raw:
|
||||
kwargs["name"] = payload.name
|
||||
@@ -179,7 +179,7 @@ def set_scenario_tests(scenario_id: str):
|
||||
try:
|
||||
payload = SetTestsPayload.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
|
||||
try:
|
||||
view = svc.set_scenario_tests(sid, payload.test_template_ids)
|
||||
except svc.ScenarioTemplateNotFound:
|
||||
|
||||
@@ -47,7 +47,7 @@ def setup():
|
||||
try:
|
||||
payload = SetupPayload.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
|
||||
|
||||
try:
|
||||
result = bootstrap_admin(
|
||||
|
||||
@@ -176,7 +176,7 @@ def create_test_template():
|
||||
try:
|
||||
payload = CreateTestTemplatePayload.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
|
||||
try:
|
||||
view = svc.create_test_template(
|
||||
name=payload.name,
|
||||
@@ -213,7 +213,7 @@ def update_test_template(template_id: str):
|
||||
try:
|
||||
payload = UpdateTestTemplatePayload.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
|
||||
|
||||
# Only forward keys actually present in the body — model_validate leaves
|
||||
# missing fields as None and we can't distinguish "explicitly null" from
|
||||
|
||||
@@ -147,7 +147,7 @@ def update_user(user_id: str):
|
||||
try:
|
||||
payload = UpdateUserPayload.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 "key absent" (no change) from "key=null" (clear) for display_name.
|
||||
display_name_unset = "display_name" not in raw
|
||||
@@ -200,7 +200,7 @@ def set_groups(user_id: str):
|
||||
try:
|
||||
payload = SetGroupsPayload.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
|
||||
try:
|
||||
u = users_svc.set_user_groups(uid, payload.group_ids)
|
||||
except users_svc.UserNotFound:
|
||||
|
||||
Reference in New Issue
Block a user