Compare commits

..

29 Commits

Author SHA1 Message Date
Knacky
c9d52e3b50 fix(m7): red write always lands on executed + state pill out of Commentaires
User reported two issues on the scenario table:

1. After filling only red_command + executed_at, the pill read
   "Reviewed" instead of "Awaiting review". Cause: the auto-promotion
   was gated on `state in {pending, skipped, blocked}`, so a test that
   blue had already reviewed stayed in `reviewed_by_blue` after a red
   edit. That contradicts the implicit lifecycle (red modifying a
   reviewed test invalidates the review).
2. The state pill lived inside the Commentaires column, polluting a
   cell meant for blue-team comments.

Fixes
- backend/app/services/mission_tests.py: any red-side write now lands
  the state on `executed` unconditionally (including from
  `reviewed_by_blue`). Same-PUT red+blue still flows
  executed → reviewed_by_blue.
- frontend/src/pages/MissionScenarioTable.tsx: state pill moves from
  CommentairesCell to the Test column (top of the row, above the
  snapshot name + MITRE chips). Commentaires now holds only the
  detection_level pill + blue_comment_md.

Tests
- New pytest: `test_red_write_on_reviewed_reverts_to_executed` —
  reviewed_by_blue + red edit ⇒ executed. 143 pytest green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:02:52 +02:00
Knacky
28b8855e88 feat(m7-amend2): implicit lifecycle — writes drive state, no workflow UI
User: «Enlève également le workflow d'un test, quand on saisit des
informations côtés redteam cela signifie qu'il a été exécuté et donc
en attente d'une review blueteam.»

Backend (update_mission_test_fields)
- At the end of every PUT, inspect the touched-field set:
  - any red write on state in {pending, skipped, blocked} → state=executed
    + auto-stamp executed_at=now() if absent
  - any blue write on state=executed → state=reviewed_by_blue
- /transition endpoint kept for back-fill/admin use, not called from UI.

Frontend MissionTestPage
- Removed the transition-buttons header block and the `transition`
  mutation. State pill stays as a passive indicator.
- New labels: "Not started" / "Awaiting review" / "Reviewed" describe
  the implicit lifecycle, no longer exposing the state-machine concept.

E2E
- The SPA test that clicked `transition-executed` now verifies the
  implicit promotion: typing red fields and saving flips the pill from
  "Not started" → "Awaiting review", no button click required.

Spec
- §4 reword: "Cycle de vie implicite, piloté par les écritures" replaces
  the old "Workflow par test instance" bullet.

Tests
- 3 new pytest: red_command-alone implicit execute + auto-stamp,
  blue write promotes executed→reviewed, blue write on pending no-op.
- 142 pytest + 49 Playwright green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:09:26 +02:00
Knacky
40114d041b fix(m7): stamping executed_at no longer requires a prior state transition
User reported `HTTP 400 — executed_at can only be set when state is
executed/reviewed_by_blue` when typing the timestamp inline in the new
scenario table. The state-gate predates the simplified UX — it made
sense back when the workflow was "Mark executed button + override
toggle", but the user has since asked for a single freely-typeable
datetime input.

- update_mission_test_fields drops the state check. Stamping a non-null
  executed_at while state ∈ {pending, skipped, blocked} now auto-promotes
  the state to `executed` in the same write. The promotion is gated by
  the same mission.write_red_fields perm that executed_at already
  required — no privilege escalation.
- MissionTestPage.tsx drops the state-based UI gate on canEditExecutedAt;
  red perm alone now unlocks the input regardless of state.
- Replaced the old "rejection while pending" test with two new tests:
  pending→executed via inline stamp + blue 403, and skipped→executed via
  inline stamp.
- 139 pytest green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:20:25 +02:00
Knacky
9fc78e0832 feat(m7-amend): full-bleed scenario table with inline edit + docs
Frontend half of the 2026-05-15 amendment (backend shipped in 447f152).

- `MissionScenarioTable` component: per-scenario <table> with 7 cols
  (Test | Procédure | Exécution | Source de log | Commentaires | Logs
  SIEM | Cyber Incident) + Actions cell. Read mode truncates; double-
  click toggles a row into edit mode where each cell becomes the right
  control. detection_level lives inside the Commentaires cell as a
  pill + select (no 8th column).
- MissionDetailPage Tests tab uses the new component, lifts
  `editingTestId` so only one row across the whole mission is editable
  at a time. Esc reverts (prompt if dirty), double-click on a different
  row with a dirty draft also prompts.
- Full-bleed escape via `calc(50% - 50vw)` (same recipe as the M4 MITRE
  picker). 7 dense columns breathe on wide screens, no horizontal scroll.
- `draftDiff(test, draft)` returns `null` when nothing changed → no PUT
  on a no-op save. The diff carries only touched fields so the server's
  per-field perm gate stays clean.
- Datetime semantics: both datetime-local inputs reuse the M7 verbatim
  recipe (`iso.slice(0, 16)` + `${local}:00Z`), zero TZ shift.

Docs
- tasks/testing-m7.md §3.0 documents the column matrix + edit workflow.
- tasks/lessons.md captures the Pydantic ctx-serialisation pitfall, the
  naïve-datetime guard, the table-edit pattern.
- CHANGELOG section moves "Frontend (in progress)" → "Frontend (shipped)"
  and details the diff.

49 Playwright tests still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:51:28 +02:00
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
Knacky
d679ff34d8 fix(m7): drop override UI + verbatim executed_at, no timezone shift
User feedback: only the date+time matters. No override toggle, no
"overridden" badge, no UTC/local-time conversion. What you type is what
gets stored is what you see.

- Removed the `override` state, the checkbox label, the conditional show
  of the input, and the "auto-stamped at" hint.
- Single always-on datetime-local input under the "Executed at" label,
  disabled only while the test is `pending` (backend rejects timestamp
  writes until the state machine reaches executed/reviewed_by_blue).
- `isoToInputValue` and `inputValueToIso` now strip/append the time
  segment verbatim — `iso.slice(0, 16)` and `${local}:00Z`. No more
  round-trip through `new Date(...).toISOString()` that pulled values
  through the browser's local TZ.
- Any edit of the input is implicitly an override at submit time
  (`executed_at_overridden = true` if non-empty). The flag is purely
  internal bookkeeping — never surfaced in the UI per user request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:16:32 +02:00
Knacky
a26034e1ca style(m7): plain executed_at field at the top, no red sub-card
User feedback: the tinted red border around the executed_at block was
visually heavy — same form-field treatment as Command/Output/Comment
is enough, position alone carries the priority.

Kept the label/value/override-toggle/datetime-input layout, just dropped
the border + background tint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:46:18 +02:00
Knacky
db9313a1e1 fix(m7): pin executed_at block to the top of the red form
User feedback: the execution timestamp is the anchor the blue team
correlates their logs against, so it should be the *first* thing in the
red form, not the last (where it lived alongside the override toggle).

Moved the executed-at block above Command/Output/Comment and wrapped it
in a tinted red sub-card (border-red/40 bg-red/5) so it reads as the
form's headline. The block now shows:
- the current `executed_at` (with an `· overridden` hint when applicable),
  or a "Not yet executed" stub when the test is still pending,
- the override toggle (disabled until the test reaches executed/reviewed),
- the local datetime-local input + a small "Browser local time — server
  stores UTC" hint so an operator typing 10:30 in Paris isn't surprised
  to see 08:30Z in the JSON.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:37:16 +02:00
Knacky
5030f4bd83 docs(m7): backfill changelog + testing-m7 for the two post-merge UX fixes
User feedback flagged that the doc didn't reflect the two hotfixes shipped
after the M7 PR:
- evidence whitelist surfaced in the dropzone + OS picker pre-filter
- executed_at override fixed in non-UTC timezones (no more time-snap)

Added a CHANGELOG entry per fix and a §3.5 in tasks/testing-m7.md walking
through the timezone semantics of the datetime-local input. spec.md is
left untouched — these are UX/implementation fixes, not contract changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:51:23 +02:00
Knacky
cfcc06cf14 fix(m7): surface evidence whitelist in UI + filter the OS file picker
Reported by the user: the blue-side dropzone said "≤ 25 MB each" but
nowhere did it list the accepted extensions, and the OS file picker
showed "All files" — so an operator could spend the time picking a
`.exe` only to get a 400 back.

- New constants `EVIDENCE_ALLOWED_EXTENSIONS` + `EVIDENCE_MAX_BYTES` in
  `lib/missions.ts`. Manual mirror of the backend whitelist (commented
  cross-reference). One source of truth on the client.
- Dropzone now prints `Accepted: .png · .jpg · .jpeg · .pdf · .txt ·
  .log · .json · .csv · .evtx · .zip · max 25 MB / file`
  (testid `evidence-allowed-formats`).
- File input gains `accept=".png,.jpg,..."` so the OS picker pre-filters
  to those extensions instead of "All files".
- `handleFiles` rejects drag-and-drops of unsupported extensions on the
  client too (still re-checked server-side — defence in depth, not a
  security boundary).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:48:23 +02:00
Knacky
5974a181fd fix(m7): make executed_at override editable in non-UTC timezones
The naive `new Date(executedAt).toISOString().slice(0, 16)` round-trip on
every keystroke silently shifted the hour by the local TZ offset (Europe
input field is local-time but we kept reformatting via UTC), so the user
could only edit the date — the time component snapped back to UTC every
render.

Fix: keep the local state in `YYYY-MM-DDTHH:MM` form (`executedAtLocal`)
and only convert to/from a UTC ISO at the boundaries — initial sync from
server and submit. Two small helpers `isoToLocalInputValue` /
`localInputValueToIso` carry the conversion explicitly.

Also tightened the useEffect on both Red and Blue zones to depend on
`test.id` instead of the whole `test` object, so polling refetches no
longer wipe an in-progress edit (the 15 s activity poll returns a fresh
object reference even when the row's contents are unchanged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:05:48 +02:00
Knacky
ed70458d8f 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>
2026-05-14 08:16:48 +02:00
3c1675966d Merge pull request 'feature/m6-missions' (#3) from feature/m6-missions into main
Reviewed-on: #3
2026-05-14 05:40:56 +00:00
Knacky
b62651a215 fix(m6): mission detail page can now edit metadata, append scenarios, edit members
The M6 SPA shipped the create wizard but the detail page was read-only —
even though the backend already exposed PUT /missions/{id}, POST
/missions/{id}/scenarios, and PUT /missions/{id}/members. So once a
mission was created you couldn't fix a typo in the client name, add a
scenario you forgot, or change member assignments without curl.

Added three modals on the detail page, gated by `is_admin ||
mission.update`:

- Edit metadata (header button, 3xl modal): name + client + dates +
  markdown description, same validation as the wizard step 1.
- Add scenarios (Tests tab): scenario picker matching wizard step 2,
  calls POST /missions/{id}/scenarios which appends snapshots at
  current_max_position + 1.
- Edit members (Members tab): roster + red/blue toggles, calls
  PUT /missions/{id}/members (full-set replace), pre-populated with
  the current member set.

The detail page now imports useAuth so `canEdit` is computed once and
shared between the three buttons.

E2E: new "detail page edits metadata, appends scenarios, edits members"
spec exercises the three modals end-to-end. M6 e2e count is now 6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 07:37:06 +02:00
Knacky
4d2b6731ac chore(m6): bump HomePage hero + roadmap card to M6
The home page still advertised "M4 milestone (MITRE ATT&CK)" in the hero
and "M5 — Test & scenario templates" as the next milestone on the
roadmap card — so a visitor landing on `/` couldn't tell M5 and M6 had
shipped, even though the Missions nav entry was wired and the M6 routes
were reachable.

Hero now reads "M6 milestone (Missions & snapshot)" and the roadmap card
points at M7 — Red & blue execution as next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 06:18:06 +02:00
Knacky
e1b51db25f fix(m6): post-review pass — cache prefix, snapshot lock, perm-before-parse, LIKE escape
Addresses spec-reviewer + code-reviewer feedback on the M6 bundle:

Critical:
- frontend/src/lib/missions.ts: add `listPrefix()` so TanStack invalidation
  catches every filtered list variant; the previous `list()` returned
  `['missions','list',{}]` and only matched the exact empty-filter cache,
  leaving filtered tables stale after create/transition/delete.
- backend/app/services/missions.py: acquire the same per-scenario
  `pg_advisory_xact_lock` key used by `set_scenario_tests` before
  snapshotting; without it a concurrent M5 reorder could freeze a torn
  snapshot under READ COMMITTED. Sorted by key to avoid deadlocks with
  another snapshotter.

Important:
- backend/app/api/missions.py: `@require_perm("mission.update",
  "mission.archive")` on the transition endpoint so users without either
  perm get 403 before the body is parsed (no shape leak via 400).
- backend/app/services/missions.py: escape `%` / `_` / `\` in user-typed
  `q` / `client` LIKE search; users can no longer trigger wildcard
  semantics by typing literal `%`. Added `escape='\\'` arg on every .like().
- backend/app/services/missions.py: filter `MissionTest.deleted_at` and
  `MissionScenario.deleted_at` in the list-item and detail counts so M7+
  soft-deletes don't drift the totals silently.

Nits:
- backend/app/api/users.py: order `/users/roster` by email for stable
  rendering + deterministic e2e selectors.
- frontend/src/pages/MissionDetailPage.tsx: distinct accent per
  transition target (cyan/orange/green/teal) matching the status legend.
- e2e/tests/m6-missions.spec.ts: switch fragile `getByRole(name=/In
  Progress/i)` to the stable `mission-transition-in_progress` data-testid.

New tests:
- test_create_mission_rejects_soft_deleted_scenario
- test_transition_perm_gate_runs_before_payload_parse
- test_search_treats_wildcards_as_literals

Suite: 106 pytest passing (was 103), 43 Playwright passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:14:57 +02:00
Knacky
00b7557e30 feat(m6): missions + snapshot CRUD, membership visibility, status state machine
Adds the mission layer that materialises template snapshots, plus the SPA
list / 3-step wizard / detail page.

Backend:
- app/services/missions.py — create_mission snapshots scenarios, tests, MITRE
  tags in a 4-query write; list/get apply a non-admin membership filter that
  collapses to 404 (no existence leak); status state machine enforces
  draft → in_progress → completed → archived with archived as a sink; the
  non-admin creator is auto-added as role_hint='red' to retain visibility.
- app/api/missions.py — 8 endpoints (list, get, create, update, add
  scenarios, set members, transition, soft-delete) with strict pydantic
  schemas. The transition endpoint splits the perm gate manually so
  archive requires mission.archive while other targets use mission.update.
- app/api/users.py — new GET /users/roster returning (id, email,
  display_name) only, gated by user.read OR mission.create OR
  mission.update — lets non-admin wizard users see assignable peers
  without exposing the admin /users payload.
- app/api/diag.py — /diag/reset truncates the mission_* tables before the
  template tables because the source_*_template_id FKs are ON DELETE SET
  NULL, which is cheaper to short-circuit by removing the children first.

Frontend:
- lib/missions.ts — typed client, queryKey factory, status accent map.
- pages/MissionsListPage.tsx — list cards with status accent + filters
  (q, client, status).
- pages/MissionsCreatePage.tsx — 3-step wizard (meta → scenarios → members)
  with member roster fed by /users/roster.
- pages/MissionDetailPage.tsx — header + transition buttons (legal next
  states only) + Tests/Members/Synthesis/Export tabs.
- Routes + nav entry (visible to anyone with mission.read or admin).

Tests:
- backend/tests/test_missions.py — 22 pytest covering snapshot fidelity,
  MITRE propagation, membership visibility, transition state machine,
  perm gating, member set replace, append scenarios, soft-delete, partial
  update, inverted-date rejection.
- e2e/tests/m6-missions.spec.ts — 5 Playwright (snapshot freezing, non-admin
  visibility, status transitions + 409, SPA wizard end-to-end, list filter).

Docs:
- CHANGELOG, tasks/testing-m6.md, tasks/lessons.md (snapshot tradeoffs,
  membership=404 pattern, /diag/reset order, auto-creator add).
- README + tasks/todo.md updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:07:32 +02:00
a57d91f176 Merge pull request 'feature/m5-templates' (#2) from feature/m5-templates into main
Reviewed-on: #2
2026-05-13 09:19:54 +00:00
Knacky
a7e5bc030f fix(m5): scenario reorder 500 — wrong pg_advisory_xact_lock overload
Editing a scenario and saving (with or without changes) returned 500:
  function pg_advisory_xact_lock(smallint, bigint) does not exist

Postgres only ships (int4, int4) and (bigint) variants. The two-arg call
passed `m = hash(uuid) & 0xFFFFFFFF` which can reach 2^32-1, so psycopg
promoted it to bigint and no overload matched.

Switched to the single-arg bigint form. While there, replaced Python's
built-in hash() with hashlib.blake2b(...) — the built-in is randomised
per process via PYTHONHASHSEED, so gunicorn workers were computing
different lock keys for the same scenario and the lock wasn't actually
serialising across workers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 09:29:27 +02:00
Knacky
873aa3774a fix(m5): modal layout for the test-template editor
The +New test modal capped at max-w-2xl rendered the 15-column MITRE matrix
in a 672px frame with no height cap, so the matrix spilled to the right of
the dialog, the form bottom dropped below the viewport, and neither scroll
direction worked — buttons were unreachable.

- Modal: add a `size` prop (default 2xl, back-compat) with a `7xl` preset.
  Cap height at calc(100vh-2rem), make the header sticky, and wrap children
  in a min-w-0 flex-1 overflow-y-auto body so tall content scrolls inside.
- MitreTagPicker: move overflow-x-auto from the grid itself to a dedicated
  scroller wrapper, and add `min-w-0` so the constraint propagates from the
  modal body. The grid's 1680px intrinsic min-width previously prevented
  the parent's overflow-x-auto from kicking in.
- AdminTestsPage: switch the form layout from `grid gap-3` to `flex flex-col
  gap-3 min-w-0` and set the modal size to 7xl. The CSS Grid form was
  propagating min-width: auto to all its items, which let the picker drag
  the body past the modal width.
- AdminScenariosPage: bump the modal to size 3xl for breathing room around
  the catalogue picker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:31:13 +02:00
Knacky
ce4bd40551 fix(m5): post-review pass — AND filter, advisory lock, N+1, item caps, mutation cache
Spec-reviewer + code-reviewer findings applied:

Must-fix
- Filter combinator AND-semantics: tactic+technique+subtechnique now intersect
  (one IN subquery per facet) instead of being pooled into one OR. Reviewers
  flagged both the wrong default semantics and the theoretical UUID-collision
  risk of pooling tactic/technique/sub UUIDs into a shared list across
  three columns.
- Front-end mutation cache hygiene: updateMeta + setTests both
  `onSettled: invalidate` so a partial failure leaves the cache consistent.

Should-fix
- Per-scenario pg_advisory_xact_lock on set_scenario_tests — serialises
  concurrent reorders, mirrors M4 /mitre/sync pattern.
- Backend/front consistency on duplicate tests in a scenario: the
  UNIQUE(scenario_id, position) constraint already allows the same
  test_template multiple times (chained ops), so the catalogue picker no
  longer excludes already-picked items.

Nice-to-have
- N+1 eradicated in test_template view rendering: _to_views_batch
  builds {uuid → MitreRow} maps in 3 queries up-front; list endpoint
  now issues 4 queries total regardless of list size.
- Wire-level item length caps on tags (64) and expected_iocs (255)
  via Annotated[str, StringConstraints(...)] — returns 400 instead of
  bubbling up StringDataRightTruncation.
- 4 new pytest covering the AND-filter, extra="forbid" rejection,
  empty mitre_tags clearing, and the 65-char tag cap. Total now
  81 pytest + 38 e2e pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:05:00 +02:00
Knacky
a559823386 test(m5): playwright spec + docs (CHANGELOG, README, lessons, testing-m5)
- 4 Playwright tests: API CRUD round-trip, scenario reorder via PUT, SPA
  list + opsec filter, SPA scenario list rendering with ordered tests.
- afterAll restores the stable admin (admin@metamorph.local) per the
  test_admin memory rule.
- CHANGELOG M5 section + Fixed subsections for the LogRecord 'name'
  collision and the React `currentTarget` vs `target` quirk.
- README status bumps to M0-M5.
- tasks/lessons.md captures the new patterns (sentinel pattern for
  partial-update, FK ordering in /diag/reset, dnd-kit stable IDs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:57:51 +02:00
Knacky
2781ce4117 feat(m5): admin SPA pages for the template catalogue
- AdminTestsPage with filters (q, tactic, opsec, tag), modal-based CRUD,
  markdown textareas for procedure/result/detection, embedded MitreTagPicker
  for tagging.
- AdminScenariosPage with @dnd-kit/sortable drag-and-drop on the ordered
  test list, two-step save (PATCH metadata + PUT tests), catalogue picker
  excluding soft-deleted items.
- lib/templates.ts typed client + queryKey factory.
- MarkdownField helper (textarea with markdown hint label).
- Layout adds Tests + Scenarios admin nav links; App.tsx routes both
  behind RequireAdmin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:57:41 +02:00
Knacky
b8fd99a5f4 feat(m5): test_template + scenario_template CRUD with MITRE tags and ordered tests
- Service `app/services/test_templates.py`: CRUD with MITRE tag resolution
  (kind, external_id) → polymorphic join, filters by tactic/technique/
  subtechnique/opsec/tag, `_UNSET` sentinel for partial-update semantics.
- Service `app/services/scenario_templates.py`: ordered test list, reorder
  via full-replace (atomic w.r.t. UNIQUE(position) constraint), soft-delete.
- REST endpoints on /api/v1/test-templates and /scenario-templates with
  pydantic schemas + perm gating (test_template.* and scenario_template.*).
- /diag/reset truncates the 4 new tables before MITRE (FK ordering).
- 19 pytest covering CRUD, MITRE tag merge, soft-delete chaining, perm
  enforcement, and reorder atomicity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:57:33 +02:00
e5f3de8f55 Merge pull request 'feature/m4-mitre' (#1) from feature/m4-mitre into main
Reviewed-on: #1
2026-05-12 17:24:14 +00:00
Knacky
2c85f9b57e docs(m4): reconcile CHANGELOG + testing-m4 with the flat matrix + CR fixes
- CHANGELOG M4 Added: rewrote the frontend bullet to describe the actual
  flat ATT&CK matrix that ships (full-bleed, 15-col grid with minmax(7rem,
  1fr), name-only cells, ▸/▾ chevron). The original entry still described
  the abandoned 3-column drill-down picker.
- New "Fixed (post-M4 code-review pass)" subsection enumerating the six
  CR-driven fixes that landed in this branch (SSRF allowlist, advisory
  lock, typed contract, N+1 elimination, version clearing, error scrub +
  the test additions and e2e count pinning).
- DoD counts: 53 → 58 pytest, 34 e2e unchanged. testing-m4.md follows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:19:44 +02:00
Knacky
8b1de6e258 test(m4): cover the new security guards + pin e2e to exact MITRE v19 counts
- 5 new pytest covering paths the code-reviewer flagged as un-asserted:
    * `test_seed_refuses_file_url` — `file://` scheme rejected before I/O
      (was the SSRF-to-local-FS vector).
    * `test_seed_refuses_disallowed_https_host` — non-allowlisted HTTPS
      host rejected with `MitreSourceForbidden`.
    * `test_seed_refuses_custom_url_without_sha` — end-to-end guard that
      `seed_mitre(source=<custom URL>, expected_sha256=None,
      allow_unverified=False)` raises `MitreSeedError`.
    * `test_dotted_id_fallback_resolves_orphan_subtechnique` — STIX bundle
      without `relationship[subtechnique-of]` still attaches T1059.001 to
      T1059 via the dotted-id convention.
    * `test_seed_clears_version_when_source_is_not_default` — seed from a
      local path leaves `settings.mitre_version` NULL (no stale pin).
- Existing `test_checksum_mismatch_aborts` reworked to monkey-patch
  `_ensure_host_allowed` so `file://` can drive the test past the allowlist
  gate (was relying on file:// being accepted before CR1).
- Removed unused `uuid` import.
- e2e: assertions on `tactics_upserted`/`techniques_upserted`/
  `subtechniques_upserted` switched from `>= 14/180/400` thresholds to
  `=== 15/222/475` exact counts pinned to MITRE Enterprise v19.0 + 0
  orphans. Catches parser regressions that would silently include revoked
  rows. Bump alongside MITRE_VERSION when re-pinning.
- e2e: `Math.random()` → `crypto.randomUUID().slice(0, 8)` for unique
  test-run emails (collision-safe across parallel CI workers).

DoD: 58 pytest pass (was 53), 34 Playwright pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:19:34 +02:00
Knacky
54adfee690 fix(m4): typed MitreSyncResult interface — drop the as cast
Mirrors the backend Pydantic `SyncResultOut` in TS so the mutation result is
properly typed end-to-end. `(res as { duration_ms: number })` cast removed
from MitrePage.tsx; `apiPost<MitreSyncResult>` carries the contract.

Also annotated the unused query-key factories in mitre.ts so the next reader
knows they're parked for M5 template-form consumption (not dead).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:19:19 +02:00
Knacky
63b48addc0 fix(m4): code-review pass — SSRF allowlist + advisory lock + typed contract
Six post-code-review fixes, applied before opening the PR per project
workflow (spec-review + code-review both gate the merge):

1. SSRF allowlist on `/mitre/sync`. Host must be in MITRE_ALLOWED_HOSTS
   (defaults to `raw.githubusercontent.com`, env-overridable). Closes "admin
   holding `mitre.sync` pivots api container at 169.254.169.254 / internal
   mirrors" via a typo'd URL. New `MitreSourceForbidden` → 400
   `source_forbidden`; checked at the top of `_download()` so it kicks in
   before any I/O.

2. `pg_advisory_xact_lock(hashtext('mitre.seed'))` at the top of the seed
   transaction. Two concurrent `/mitre/sync` requests now serialise across
   the DELETE+INSERT of `mitre_technique_tactics`; previously they could
   both wipe the M2M and one would fail the unique constraint on re-insert.

3. Typed SyncResult contract. Pydantic `SyncResultOut` on the Flask side
   `model_validate`s the dict before returning — single source of truth
   for the response shape, mirrored by a `MitreSyncResult` TS interface
   (next commit). The `as Record<string, unknown>` + `as { duration_ms }`
   cast in MitrePage is gone.

4. N+1 in dotted sub-technique fallback removed. Built
   `{external_id → technique_id}` once at function entry. Currently a
   no-op against MITRE official (0 orphans), but a latent footgun for
   partial / older bundles.

5. `SETTING_VERSION` cleared explicitly when `source != MITRE_DEFAULT_URL`.
   Previously it kept the stale pin label, so `/mitre/status` lied after
   a custom-URL re-sync.

6. `/mitre/sync` 500s no longer echo `str(e)` to the client — URLError /
   psycopg / Pydantic text now lives in the JSON log only. Public response
   stays `{"error": "internal_error"}`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:19:11 +02:00
59 changed files with 13660 additions and 77 deletions

View File

@@ -4,6 +4,235 @@ All notable changes to this project will be documented here. Format: [Keep a Cha
## [Unreleased] ## [Unreleased]
### Changed (amendement 2026-05-15 bis) — explicit test workflow removed, lifecycle now driven by writes
User feedback: «Enlève également le workflow d'un test, quand on saisit
des informations côtés redteam cela signifie qu'il a été exécuté et donc
en attente d'une review blueteam.»
- **Backend `update_mission_test_fields`**: at the end of every PUT, the
service inspects the touched field set. Any red-side write on a non-
executed test (`pending` / `skipped` / `blocked`) promotes the state to
`executed`; if no `executed_at` was supplied, it auto-stamps `now()`.
Any blue-side write on an `executed` test promotes to `reviewed_by_blue`.
The `/transition` endpoint stays operational for back-fill / admin use
but is no longer the primary path.
- **`MISSION_TEST_STATE_LABEL`** rephrased to describe the implicit
lifecycle instead of the workflow: `Pending → Not started`, `Executed
→ Awaiting review`, `Reviewed_by_blue → Reviewed`, `Skipped`, `Blocked`.
- **`MissionTestPage.tsx`**: transition buttons in the header are gone.
The state pill remains as a passive indicator. The `transition`
mutation and its imports are dropped; `useMutation` is still used for
the red / blue field saves.
- **E2E**: the SPA test that previously clicked `transition-executed`
now exercises the implicit promotion — it just types in the red fields
and asserts that the state-pill flips from `Not started` to `Awaiting
review` on save.
- **Tests**: 3 new pytest cases — `test_red_writing_any_red_field_implicitly_executes_and_stamps`
(red_command alone bumps state + auto-stamps executed_at),
`test_blue_writing_any_blue_field_promotes_executed_to_reviewed`,
`test_blue_write_on_pending_does_not_auto_execute` (blue-on-pending is
a no-op — only red drives execution per the user's mental model).
- Total: **142 pytest** + 49 Playwright green.
### Fixed (post-amendement 2026-05-15) — stamping executed_at no longer needs a prior state transition
User feedback: when a red user typed `executed_at` inline on a pending test
in the new scenario table, the backend rejected with `HTTP 400 — executed_at
can only be set when state is executed/reviewed_by_blue`. The state-gate
was a holdover from the original "Mark executed button + override toggle"
workflow; it made no sense once the UX let the operator type the time
directly.
- `update_mission_test_fields` (`backend/app/services/mission_tests.py`) no
longer rejects writes based on the source state. Stamping a non-null
`executed_at` while state ∈ {`pending`, `skipped`, `blocked`} now
auto-promotes the state to `executed` in the same write. The promotion
rides on the same `mission.write_red_fields` perm that the executed_at
field already required — no privilege escalation.
- `MissionTestPage.tsx` drops the state-based gate on `canEditExecutedAt`:
the field is editable any time the viewer holds `mission.write_red_fields`.
- Tests: `test_executed_at_override_requires_red_perm_and_state` was the
old guard; it's split into two new cases — `test_red_setting_executed_at_on_pending_auto_transitions_to_executed`
(pending → executed via inline stamp, blue still 403'd) and
`test_red_setting_executed_at_from_skipped_state_auto_transitions`
(skipped → executed via the same path).
- Total: **139 pytest** green.
### Added — M7 amendment (2026-05-15) — blue review fields + full-width scenario table
User feedback after the M7 ship: the blue team used to maintain 5 extra
fields in Excel that we didn't capture, and the per-test page didn't fit
their workflow — they wanted a tabular view (one table per scenario, one
row per test) with double-click inline edit.
#### Reviewer follow-ups (applied)
- **`blue_incident_at` rejects naïve datetimes** (`backend/app/api/missions.py:_ensure_aware_datetime`): a request with `"2026-05-15T11:00:00"` (no offset) now returns 400 instead of silently letting Postgres interpret it in the session timezone — same rule applied to `executed_at` for consistency. Clients must send `Z` or an explicit `+HH:MM`.
- **`blue_incident_recipient_email` is shape-validated** (`backend/app/api/missions.py:_validate_email_shape`): permissive RFC regex (`/^[^@\s]+@[^@\s]+\.[^@\s]+$/`) that allows `.local` / `.corp` / `.test` internal domains. We deliberately don't use Pydantic `EmailStr``email-validator`'s `globally_deliverable=True` rejects those (lessons.md M2 captured the same trap for the user signup).
- **`MissionTestView` payload expansion documented** as a deliberate F6 enabler — surfacing every annotation in the nested GET means the scenario table renders in a single round trip. Without this, the table would have to call `GET /missions/{id}/tests/{test_id}` once per row.
#### Backend (shipped)
- **Migration `c2a8f4b1d6e9`** adds five nullable columns to `mission_tests`:
- `blue_log_source` (varchar 120) — short text like `Firewall`, `NDR`, `Proxy`, `AV`, `EDR`.
- `blue_siem_logs` (text) — long-form SIEM excerpt (raw log lines).
- `blue_incident_at` (timestamptz) — cyber-incident notification timestamp.
- `blue_incident_number` (varchar 120) — incident reference (`INC-2026-1234`).
- `blue_incident_recipient_email` (varchar 255) — SOC recipient of the alert.
- **All five fields are blue-side** — added to `_BLUE_FIELDS` in `app/services/mission_tests.py` so the existing per-field perm classifier rejects red-only writers with 403, no field-by-field special case.
- **`update_mission_test_fields`** accepts each new field via the same `_UNSET` sentinel pattern; `blue_siem_logs` uses the command-style normaliser (`_opt_cmd`) to preserve leading whitespace in log table excerpts; the other text fields use `_opt_md`.
- **`MissionTestView`** (the nested view returned by `GET /missions/{id}`) now exposes every annotation field plus `last_actor_*` + `updated_at` + `detection_level_key`. The two FK lookups (detection-level keys, last-actor user labels) are batch-loaded once per request so the call stays O(1) regardless of how many tests the mission contains. Lets the front-end scenario table render in a single GET — no per-row round-trip.
- **API**: `UpdateMissionTestPayload` and `_serialize_test` / `_serialize_test_detail` updated. Length caps per spec (120 / 200_000 / 120 / 255).
- **Tests**: 3 new pytest cases — `test_blue_user_writes_new_blue_review_fields`, `test_red_user_cannot_write_new_blue_review_fields` (loops each of the 5 fields), `test_blue_review_fields_survive_round_trip_via_get`. Total: **136 pytest** green.
#### Spec & docs
- **`tasks/spec.md` amended** — §4 in-scope bullet on blue saisie now lists the 5 fields, §F6 describes the tabular UX (full-bleed, one table per scenario, double-click inline edit), §8 model bullet enumerates the new columns. Header carries a `revised: 2026-05-15` note pointing readers at the amendment.
- **`tasks/todo.md` M7 section** carries a dedicated "Amendement 2026-05-15" sub-block tracking the backend (☑) and frontend (☐) items.
#### Frontend (shipped)
- **`MissionScenarioTable` component** (`frontend/src/pages/MissionScenarioTable.tsx`): per-scenario `<table>` with 7 columns (`Test | Procédure | Exécution | Source de log | Commentaires | Logs SIEM | Cyber Incident`) plus an `Actions` cell that links to the full per-test page. Read mode shows truncated values; double-click toggles a row into edit mode where each cell becomes the right input (text, textarea, datetime-local, select). The `detection_level` lives **inside the Commentaires cell** as a pill + select — no 8th column.
- **Single-row-edit invariant**: `editingTestId` state lives in `MissionDetailPage`'s tests tab so only one row across the whole mission is editable at a time. Double-clicking another row while dirty surfaces a `Discard unsaved changes?` prompt; Esc reverts; Save commits the diff.
- **Diff-only PUT**: `draftDiff(test, draft)` walks every field and only includes the ones that changed; submitting an unchanged form is a no-op `onEditRequest(null)`. Keeps the per-field perm gate on the server cleanly applicable.
- **Full-bleed layout**: the tests tab escapes the layout's `max-w-page` via the canonical `calc(50% - 50vw)` recipe (same as the M4 MITRE picker) so the 7-column table breathes on wide screens without horizontal scroll.
- **Per-test page kept** at `/missions/<id>/tests/<test_id>` for evidence upload and the full procedure view — every row's "open ↗" link routes there.
- **Datetime semantics consistent**: the table's two datetime-local inputs (executed_at + blue_incident_at) reuse the M7 verbatim recipe (`iso.slice(0, 16)` + `${local}:00Z`), no TZ shift on read or write.
#### Tests
- E2E: existing m6 + m7 specs unaffected (all 49 still green). The new table reuses the `mission-add-scenarios` testid for the modal trigger so the wizard test still works. The old `mission-test-${id}` rows are gone but were never wired into any e2e selector.
### Fixed (post-M7 UX feedback — evidence whitelist visibility)
- **Evidence dropzone didn't tell the operator which extensions are accepted, and the OS file picker showed "All files"** (`frontend/src/pages/MissionTestPage.tsx`): an operator could spend the time picking a `.exe` only to receive a 400 back. Surfaced the whitelist in the UI:
- Dropzone now prints `Accepted: .png · .jpg · .jpeg · .pdf · .txt · .log · .json · .csv · .evtx · .zip · max 25 MB / file` (testid `evidence-allowed-formats`).
- `<input type="file" accept=".png,.jpg,…">` pre-filters the OS picker to those extensions.
- `handleFiles` rejects drag-and-drops of unsupported extensions client-side (still re-checked server-side — defence in depth, not a security boundary).
- Constants `EVIDENCE_ALLOWED_EXTENSIONS` + `EVIDENCE_MAX_BYTES` in `frontend/src/lib/missions.ts` keep a single source of truth client-side. Manual mirror of `app/services/evidence.py:ALLOWED_EXTS` + `MAX_BYTES`; cross-referenced via comments so the next bump touches both files.
### Fixed (post-M7 UX feedback — executed_at override editable in any timezone)
- **Time portion of the `executed_at` override was un-editable in non-UTC timezones** (`frontend/src/pages/MissionTestPage.tsx:RedZone`): the naive `new Date(executedAt).toISOString().slice(0, 16)` round-trip on every keystroke silently shifted the hour by the local TZ offset, snapping the time field back to UTC each render. The date could be changed (offset shifts both source and target by the same amount), but the hour couldn't stick.
- Fix: keep the local state in `YYYY-MM-DDTHH:MM` form (`executedAtLocal`) and only convert to/from UTC ISO at the boundaries — initial sync from server (`isoToLocalInputValue`) and submit (`localInputValueToIso`).
- Also tightened the `useEffect` reset on both Red and Blue zones to depend on `test.id` instead of the whole `test` object so a polling refetch (every 15 s) no longer wipes an in-progress edit. The 15 s activity poll returns a fresh object reference even when the row's content is unchanged.
### Fixed (post-M7 review pass — spec-reviewer + code-reviewer)
- **Idempotent transition leaked false success to a wrong-side user** (`backend/app/services/mission_tests.py:570`): a blue-only viewer POSTing `target_state="executed"` while the test was already executed got a 200 idempotent response, falsely advertising that they held `mission.write_red_fields`. Reordered the gate so the side-perm check runs *before* the idempotency short-circuit, with a new `_IDEMPOTENT_SIDE` table that asks "which side originally produced this state?" — re-asserting that perm even on no-op replays. Test `test_idempotent_transition_still_checks_side_perm`.
- **Cross-mission evidence access not pinned by a test** (`backend/tests/test_mission_tests.py:test_evidence_member_of_other_mission_gets_404`): added explicit coverage that a user who is a blue member of mission B sees 404 on an evidence row attached to mission A. The chain walk in `_resolve_evidence_chain` already enforced this, but the regression test was missing.
- **`shutil.move` swapped for `os.replace`** (`backend/app/services/evidence.py:240`): `os.replace` is the documented atomic-rename primitive on POSIX and Windows when src/dst share a volume — and our tmpfile is always staged inside the destination directory, so the guarantee holds. Removes the implicit copy+remove fallback from `shutil.move` that would silently break atomicity on a cross-fs `EVIDENCE_DIR`.
- **SHA256 path component now hex-validated** (`backend/app/services/evidence.py:227`): the hash always comes from hashlib so it's already hex, but if a future caller ever passes pre-computed bytes we want to fail loudly rather than write to a path like `..something.evtx`. Cheap `re.fullmatch(r"[0-9a-f]{64}", sha256)` guard.
- **`EVIDENCE_DIR` filesystem-root guard** (`backend/app/services/evidence.py:_test_dir`): refuse to create per-mission directories when `EVIDENCE_DIR` resolves to `/` (or the equivalent on Windows). Stops a mis-configured operator from laying down content-addressed evidence files at the filesystem root.
- **`/diag/reset` evidence cleanup now skips symlinks** (`backend/app/api/diag.py:127`): switched from `is_dir()` to `is_symlink() or not is_dir()` so a hostile or accidental symlink inside `EVIDENCE_DIR` is unlinked rather than `rmtree`'d through.
- **N+1 in `_to_detail_view`** (`backend/app/services/mission_tests.py:_to_detail_view`): the last-actor and detection-level lookups each issued their own `s.get()`. Replaced with `select(columns)` queries that return just the needed scalar fields — same SQL count but fewer ORM round-trips, and every PUT/transition exercises this path so it adds up.
- **Mission detail row `onClick` removed in favour of the wrapped `Link`** (`frontend/src/pages/MissionDetailPage.tsx:684`): the `tr onClick` + nested `Link` with `stopPropagation` worked but was fragile to accessibility tooling. The link on the test name + the explicit hover class is enough.
### Added — M7 (Red & blue execution on a mission test)
- **Per-mission-test write API** (`app/api/missions.py` + `app/services/mission_tests.py`):
- `GET /missions/{id}/tests/{test_id}` — full detail view with snapshot, state, red/blue fields, MITRE tags, evidence list, last-actor metadata.
- `PUT /missions/{id}/tests/{test_id}` — patch any subset of `red_command` / `red_output` / `red_comment_md` / `blue_comment_md` / `detection_level_id` / `executed_at` / `executed_at_overridden`. The service classifies each touched field as red-side or blue-side and rejects with 403 if the caller lacks the matching perm. `executed_at*` only writable when the test sits in `executed` or `reviewed_by_blue`.
- `POST /missions/{id}/tests/{test_id}/transition` — drives the state machine `pending↔skipped/blocked` + `pending→executed→reviewed_by_blue` (allows undo back to `pending`). Side-aware perm gating: `pending→executed` and `executed→pending` require `write_red_fields`; `executed↔reviewed_by_blue` requires `write_blue_fields`; `pending↔skipped/blocked` accepts either side. Transitioning into `executed` stamps `executed_at=now()` and clears the override; transitioning out (to `pending`) wipes the timestamp.
- `GET /missions/{id}/activity?since=<ISO>` — returns mission_tests whose `updated_at > since`, freshest first. Drives the SPA's 15-second polling badge. Response includes `server_time` so the client can chain calls without clock drift.
- **Evidence storage pipeline** (`app/services/evidence.py` + `app/api/evidence.py`):
- `POST /missions/{id}/tests/{test_id}/evidence` (multipart, gated on `mission.write_blue_fields`): streams the upload into a tmpfile next to the final location, hashing chunk-by-chunk and aborting at the 25 MB cap. Validates extension (whitelist: png/jpg/jpeg/pdf/txt/log/json/csv/evtx/zip) and MIME (permissive allowlist + `application/octet-stream` fallback for `.evtx`). Content-addressed storage: `${EVIDENCE_DIR}/<mission>/<test>/<sha256><ext>` — re-uploading byte-identical content reuses the file on disk and inserts a fresh row.
- `GET /evidence/{id}` — JSON metadata view; `?download=true` switches to `send_file` with the original filename in `Content-Disposition` and the SHA256 as the ETag.
- `DELETE /evidence/{id}` — soft delete (only flips `deleted_at`; physical purge lands in M12).
- All three routes are membership-aware via the same chain walk (`evidence → test → scenario → mission`), collapsing "not found" / "not visible" into 404 to prevent existence leaks.
- **Activity tracking column** (`backend/alembic/versions/20260514_1000_91a4e7c6d2f3_m7_mission_test_last_actor.py`): added `mission_tests.last_actor_id` (FK `users.id` `ON DELETE SET NULL`) + `ix_mission_tests_updated_at` to support the polling endpoint. Every red/blue write or transition stamps the actor so the "modified by X Ns ago" indicator can resolve a human label.
- **Detection-level seed + read** (`app/services/detection_levels.py` + `app/api/detection_levels.py`):
- 4 default rows seeded at boot — `detected_blocked` / `detected_alert` / `logged_only` / `not_detected` — colored on the design-system accent palette. The seed is idempotent and never mutates existing rows; new keys added to `DEFAULT_LEVELS` in future releases surface on next boot.
- `GET /detection-levels` (gated on `detection_level.read`) returns the catalogue ordered by position. CRUD is M8's territory.
- **Per-test page** (`frontend/src/pages/MissionTestPage.tsx`): two-zone layout with the red border on the red half (command, output, markdown comment, mark-executed button, override toggle) and the cyan border on the blue half (detection-level select, comment, drag-and-drop evidence dropzone). Per-field disable based on `mission.write_red_fields` / `mission.write_blue_fields`; server is the ultimate arbiter so the UI is purely advisory. The "Last touched Xs ago by Y" badge polls `/activity` every 15 s while the document is visible.
- **Mission detail page wires through to the per-test page** (`frontend/src/pages/MissionDetailPage.tsx`): every row in the Tests tab is now clickable (cursor + hover state) and links to `/missions/<id>/tests/<test_id>`. The route is registered in `App.tsx` behind `RequireAuth`.
- **TanStack query keys** (`frontend/src/lib/missions.ts`): added `missionTestKeys.detail()` / `.activity()` / `.detectionLevels()` so the per-test page invalidations stay surgical (don't blow away the whole missions list).
- **`/diag/reset` extended** (`app/api/diag.py`): test mode now wipes `${EVIDENCE_DIR}/*` so e2e uploads don't accumulate across runs. Detection levels are preserved (reference data, not catalogue) and the seed is re-run as a safety net.
- **Tests**:
- **`backend/tests/test_mission_tests.py`** — 25 pytest tests covering: detection-level seed + perm gating; red/blue field-level perms (red user blocked on blue fields and vice-versa); mark-executed stamps `executed_at`; override gating (forbidden while pending, blue-side blocked); state-machine matrix + side perm refinement; membership 404 vs admin bypass; evidence 24 MB ok / 26 MB rejected; SHA256 verification; MIME/extension whitelist; soft-delete hides bytes from detail view; activity polling with `since=` URL-encoded; future `since` returns empty.
- **`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 sees the 404 alert instead of mission content. `afterAll` restores the stable admin and re-syncs MITRE.
- **HomePage**: hero + roadmap card bumped to `M7 — Red & blue execution on a mission test (done). Next: M8`.
### Fixed (post-M6 SPA — mission detail page was read-only)
- **Mission detail page couldn't edit metadata, append scenarios, or change members** (`frontend/src/pages/MissionDetailPage.tsx`): the M6 SPA shipped the 3-step *creation* wizard but no edit affordance on the detail page — even though the backend already exposed `PUT /missions/{id}`, `POST /missions/{id}/scenarios`, and `PUT /missions/{id}/members`. Added three modals gated by `is_admin || mission.update`:
- **Edit metadata** (header button, opens a 3xl modal): name / client_target / dates / description_md, full inline validation (empty name, inverted dates) mirroring the wizard's step 1.
- **Add scenarios** (in the Tests tab): scenario picker reusing the wizard step-2 visual, calls `POST /missions/{id}/scenarios` which appends snapshots at `current_max_position + 1`. The footer line tells the user how many tests will be appended.
- **Edit members** (in the Members tab): roster + red/blue toggles, calls `PUT /missions/{id}/members` (full-set replace) — same UX as the wizard step 3, pre-populated with the current member set.
- Detail page now imports `useAuth` to compute `canEdit` once and reuses it across all three buttons.
- E2E spec extended: new test `SPA — detail page edits metadata, appends scenarios, edits members` exercises the three modals end-to-end against a pre-seeded mission. Suite is now 44 Playwright tests (6 in M6).
### Fixed (post-M6 review pass — spec-reviewer + code-reviewer)
- **SPA cache invalidation only refreshed the empty-filter list** (`frontend/src/lib/missions.ts:136`): `missionKeys.list()` returns `['missions','list',{}]`. TanStack v5's `invalidateQueries({queryKey})` is prefix-based, but `{}` is treated as an atomic final element — so create / transition / delete called with that key only invalidated the *exact* empty-filter list, leaving any filtered variant stale until manual refetch. Added `missionKeys.listPrefix()` returning `['missions','list']` and switched all three mutation `onSuccess` paths to it.
- **Snapshot lacked the per-scenario advisory lock** (`backend/app/services/missions.py:467`): a concurrent `PUT /scenario-templates/{id}/tests` (M5 reorder, which deletes-then-reinserts join rows) running while `_snapshot_scenarios` walked `sc.tests` could freeze a torn snapshot — `selectinload` re-queries under READ COMMITTED so a partial view was possible. Added `_lock_scenario_ids_for_snapshot` that acquires the same `pg_advisory_xact_lock` key used by `set_scenario_tests` (blake2b digest of the scenario UUID, sorted to avoid deadlocks). Snapshot and reorder now serialise per scenario.
- **Transition endpoint leaked its body shape via 400 before the perm gate** (`backend/app/api/missions.py:441`): a user without `mission.update` or `mission.archive` POSTing `{"status":"x"}` got a Pydantic 400 instead of 403. Added `@require_perm("mission.update", "mission.archive")` so the gate fires before the parse; the inner refinement still enforces the per-target perm. Test `test_transition_perm_gate_runs_before_payload_parse`.
- **LIKE wildcards in user-typed search were honoured as SQL wildcards** (`backend/app/services/missions.py:632,637`): `?q=%` matched every mission. Added `_escape_like` that pre-escapes `%`, `_`, `\` and a matching `escape='\\'` argument on every `.like(...)` call. Test `test_search_treats_wildcards_as_literals`.
- **Counts ignored soft-deleted mission children** (`backend/app/services/missions.py:587,597`): `tests_count` and the detail view summed `len(sc.tests)` without filtering `MissionTest.deleted_at`. Harmless today (M6 doesn't soft-delete mission tests), but would drift silently once M7+ surfaces `state=skipped/blocked`. Added the filter in both `_to_list_item` and `_scenario_views`.
- **`/users/roster` was unordered** (`backend/app/api/users.py:73`): the wizard's member list shuffled rows on every refetch. Sorted by `email` for predictable rendering + stable e2e selectors.
- **Frontend transition button accent collapsed `in_progress` and `completed` into one colour** (`frontend/src/pages/MissionDetailPage.tsx:97`): both rendered cyan, so the status legend in the list didn't match the transition button. Added a `TRANSITION_BUTTON_ACCENT` map mirroring `MISSION_STATUS_ACCENT` (cyan/orange/green/teal).
- **Soft-deleted source scenario was a silent foot-gun**: `_load_scenario_templates_for_snapshot` already rejected it, but no test pinned the behaviour. Added `test_create_mission_rejects_soft_deleted_scenario` so future refactors can't regress to "freeze a tombstoned scenario into a fresh mission".
- **E2E wizard assertion used `getByRole('button', { name: /In Progress/i })`** (`e2e/tests/m6-missions.spec.ts:287`): the accessible name is `→ In Progress` and the arrow Unicode is brittle. Switched to `getByTestId('mission-transition-in_progress')`.
### Added — M6 (Missions & snapshot)
- **CRUD `missions`** (`app/services/missions.py` + `app/api/missions.py`):
- Fields: name, client_target, date_start, date_end, status (`draft/in_progress/completed/archived`), description (markdown), visibility_mode (frozen to `whitebox` in v1).
- On creation/append, the service **snapshots** the selected `scenario_templates` and all their `test_templates` into `mission_scenarios` / `mission_tests` (every template field — including OPSEC level, tags, expected IOCs, MITRE tags). The denormalised `mission_test_mitre_tags` table copies `external_id`, `name`, `url` so a later MITRE re-sync that drops the entry can't alter a mission's tags (spec §11).
- `source_*_template_id` FKs survive template soft-deletes (`ON DELETE SET NULL`); the mission's frozen content is unaffected.
- **Membership visibility**: non-admin viewers see only missions where they are a `mission_members` row. The service maps "not visible" → 404 (no existence leak via 403). Admins bypass via the `admin` group.
- **Status state machine**: `draft → in_progress → completed → archived`; `archived → ∅`. The transition endpoint accepts the target status, validates the move, and rejects invalid jumps with 409. Idempotent (target=current) is a no-op 200.
- Auto-creator-membership: a non-admin caller of `POST /missions` is auto-added as `role_hint='red'` if not already in the `members[]` payload — so they retain visibility on the mission they just created.
- REST: `GET/POST /missions`, `GET/PUT/DELETE /missions/{id}`, `POST /missions/{id}/scenarios` (append snapshots at the end), `PUT /missions/{id}/members` (replace set), `POST /missions/{id}/transition`.
- Filters on list: `q` (LIKE on name/description), `status`, `client` (LIKE on client_target). `include_deleted=true` is admin-only (403 otherwise).
- **`GET /users/roster`** (`app/api/users.py`): a deliberately minimal listing — `id`, `email`, `display_name` of active users only — accessible to any holder of `user.read`, `mission.create`, or `mission.update`. Lets a non-admin red teamer populate the wizard's member picker without exposing the admin-grade `/users` endpoint (which leaks `is_admin`, `is_active`, group memberships).
- **Frontend**:
- `lib/missions.ts` — typed client + queryKey factory + status accent map + filter query-string builder.
- `pages/MissionsListPage.tsx` — list cards (one per mission) with status accent, scenario/test/member counts, date range, plus filters (q, client, status).
- `pages/MissionsCreatePage.tsx`**3-step wizard**: metadata → scenario picker → member roster (red/blue toggles + auto-include the non-admin creator). Submits via `POST /missions` and redirects to the detail page.
- `pages/MissionDetailPage.tsx` — header with transition buttons (only the legal next states are rendered), soft-delete with confirm prompt, and 4 tabs: **Tests** (table of snapshotted tests with MITRE tags, OPSEC, state), **Members** (role-coloured pills), **Synthesis** (placeholder for M10), **Export** (placeholder for M11).
- Nav adds **Missions** link visible to anyone with `mission.read` or admin.
- **/diag/reset** truncates the mission tables before the template tables — `mission_scenarios.source_scenario_template_id` and `mission_tests.source_test_template_id` are `ON DELETE SET NULL`, so wiping missions first avoids the round-trip through the null-update path.
- **Testing**:
- `backend/tests/test_missions.py`**22 pytest** covering snapshot fidelity (rename source template after snapshot → mission unchanged), MITRE tag propagation, membership-based 404, perm gating (create vs read vs archive), status transition chain + invalid jumps (409), member set replace + role-hint validation, scenario append at correct position, soft-delete, partial metadata update, inverted-date rejection, admin-only `include_deleted`.
- `e2e/tests/m6-missions.spec.ts`**5 Playwright** (snapshot freezing, membership visibility for non-admin red, status transition + 409, SPA wizard end-to-end, SPA list + status filter).
- `tasks/testing-m6.md`.
### Added — M5 (Test & scenario templates)
- **CRUD `test_templates`** (`app/services/test_templates.py` + `app/api/test_templates.py`):
- Fields: name, description, objective, procedure (markdown), prerequisites (markdown), expected result red, expected detection blue, OPSEC level (`low/medium/high`), free tags (TEXT[]), expected IOCs (TEXT[]).
- Polymorphic MITRE tag set (`(kind, external_id)` ↔ exactly one of `tactic_id`/`technique_id`/`subtechnique_id`). The wire payload uses ATT&CK external IDs — server resolves to UUIDs.
- Filters: `q` (LIKE on name/description), `tactic`/`technique`/`subtechnique` (joined via subquery on the polymorphic tag table), `opsec`, `tag` (array contains).
- REST: `GET /test-templates`, `GET /test-templates/{id}`, `POST /test-templates`, `PUT /test-templates/{id}` (partial, with explicit `_UNSET` sentinel so omitted fields stay untouched), `DELETE /test-templates/{id}` (soft).
- **CRUD `scenario_templates`** (`app/services/scenario_templates.py` + `app/api/scenario_templates.py`):
- Ordered list of test_templates with `position` (UNIQUE `scenario_template_id, position`).
- Reorder via full replace: `PUT /scenario-templates/{id}/tests` deletes the join rows and re-inserts at positions `0..N-1` — clean atomic op that respects the UNIQUE constraint without a 2-phase position shuffle.
- The same test can appear multiple times (chained operations).
- REST: `GET`/`POST`/`PATCH` (metadata) / `DELETE` (soft) on `/scenario-templates`.
- **Frontend**:
- `lib/templates.ts` — typed client + queryKey factory.
- `pages/AdminTestsPage.tsx` — list + filters (q, tactic, opsec, tag) + modal with full field set + embedded `<MitreTagPicker>` for tags.
- `pages/AdminScenariosPage.tsx` — list + modal with **@dnd-kit/sortable** vertical drag-and-drop on the ordered test list. New deps: `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities`.
- `components/MarkdownField.tsx` — lean textarea with markdown hint (no heavy editor dep; rendering happens at display time in M7).
- Nav adds **Tests** and **Scenarios** links (admin-gated).
- **/diag/reset** truncates the 4 new tables before the MITRE block — the `scenario_template_tests.test_template_id` FK is `ON DELETE RESTRICT`, so the order matters.
- **Testing**:
- `backend/tests/test_templates.py`**19 pytest** (create/list/filter by tactic+opsec+tag, MITRE tag resolution + replacement on update, soft-delete, perm gating, scenario create+reorder+delete, soft-deleted test linking semantics).
- `e2e/tests/m5-templates.spec.ts`**4 Playwright** (API CRUD round-trip, scenario reorder, SPA list + opsec filter, SPA scenario list rendering with ordered tests).
- `tasks/testing-m5.md`.
### Fixed (M5 implementation)
- **`LogRecord` key collision**: `log.info(..., extra={"name": ...})` raises `KeyError("Attempt to overwrite 'name' in LogRecord")` because `name` is reserved by Python's stdlib logging. Renamed to `template_name`.
- **React `currentTarget` null in deferred state updaters**: `onChange={(e) => setX((prev) => ({ ...prev, q: e.currentTarget.value }))}` blanked the page on the first user input because `currentTarget` is cleared after the listener bubble ends, before React invokes the updater. Switched all M5 handlers to `e.target.value`, which persists on the synthetic event.
### Fixed (post-M5 — scenario reorder 500 + cross-worker lock correctness)
- **`PUT /scenario-templates/{id}/tests` returned 500** (`backend/app/services/scenario_templates.py:218`): the two-argument form `pg_advisory_xact_lock(:n, :m)` failed with `function pg_advisory_xact_lock(smallint, bigint) does not exist`. Postgres only provides `(int4, int4)` and `(bigint)` overloads — psycopg promoted `m = hash(uuid) & 0xFFFFFFFF` (up to 2^32-1) to bigint and there's no matching overload. Switched to the single-argument bigint form with `CAST(:key AS bigint)`.
- **Cross-worker lock was a no-op** (same site): Python's built-in `hash()` is randomised per process via `PYTHONHASHSEED`, so each gunicorn worker computed a different key for the same `scenario_id`, and concurrent reorders on different workers acquired independent locks — defeating the serialisation. Replaced with `blake2b(scenario_id.bytes, digest_size=8)` interpreted as a signed int64. Stable, deterministic, fits in `bigint`.
### Fixed (post-M5 UI — modal layout for the test-template editor)
- **Modal box capped its width at `max-w-2xl` and had no vertical scroll** (`frontend/src/components/ui/Modal.tsx`): opening **+ New test** rendered the 15-column MITRE matrix inside a 672 px frame with no height cap, so the matrix spilled to the right and the form bottom dropped below the viewport — buttons unreachable, no scroll. Added a `size` prop (default `2xl` for back-compat), `max-h-[calc(100vh-2rem)]` + `flex flex-col` on the dialog, and an inner `min-w-0 flex-1 overflow-y-auto` body so the header stays pinned while the form scrolls inside the modal.
- **MITRE matrix overflow-x failed to scroll inside the modal body** (`frontend/src/components/MitreTagPicker.tsx`): `overflow-x-auto` sat directly on the grid element, but the grid's intrinsic min-width (`15 × minmax(7rem, …)` = 1680 px) prevented it from shrinking below its content, so the grid spilled outside its parent instead of scrolling. Wrapped the grid in a dedicated `overflow-x-auto rounded min-w-0 w-full` scroller and added `min-w-0` to the picker root so the constraint propagates from the modal body. The grid now scrolls horizontally inside the modal.
- **`grid gap-3` form layout in the test-template modal propagated `min-width: auto`** (`frontend/src/pages/AdminTestsPage.tsx`): each grid item refused to shrink below its widest child, so the picker dragged the form (and the body) past the modal width. Switched the form to `flex flex-col gap-3 min-w-0`, which breaks the propagation while preserving vertical spacing.
- **Test-template modal now uses `size="7xl"`** and the scenario-template modal `size="3xl"` to match their content density.
### Fixed (post-M5 review pass — spec-reviewer + code-reviewer)
- **Filter combinator was OR, not AND** (`backend/app/services/test_templates.py:235`): `?tactic=TA0002&technique=T1059` returned templates matching *either* facet instead of *both*. Pre-fix also pooled all three UUIDs into a shared `IN` list across three columns, theoretically allowing a UUID collision to match across kinds. Refactored to one IN-subquery per facet, ANDed together via repeated `WHERE id IN (...)`.
- **Concurrent reorder race on `set_scenario_tests`** (`backend/app/services/scenario_templates.py:207`): two parallel reorders on the same scenario could deadlock on the `UNIQUE(scenario_id, position)` constraint under READ COMMITTED. Added a per-scenario `pg_advisory_xact_lock(0x5C3, hash(scenario_id))` mirroring the M4 `/mitre/sync` pattern; different scenarios don't contend.
- **N+1 on `_to_view` MITRE resolution** (`backend/app/services/test_templates.py:160`): rendering K templates with ~T tags each fired up to K×T `s.get(...)` calls. Added `_to_views_batch` that pre-builds `{uuid → MitreRow}` maps in 3 queries and feeds them to per-template view assembly; `list_test_templates` now issues 4 queries total regardless of list size.
- **Wire-level item length cap on `tags` / `expected_iocs`** (`backend/app/api/test_templates.py:18-21`): the DB columns are `ARRAY(String(64))` / `ARRAY(String(255))` but the API layer only capped the LIST length, not item strings — long inputs hit the driver with `StringDataRightTruncation`. Added `Annotated[str, StringConstraints(...)]` types so the API returns 400 with a clean validation error.
- **Front-end mutation cache hygiene** (`frontend/src/pages/AdminScenariosPage.tsx:148-156`): `updateMeta` and `setTests` mutations are run sequentially in `submit()`; on partial failure (metadata saved but reorder failed) the cache stayed stale. Both mutations now `onSettled: invalidate` so whatever step landed is reflected without manual refresh.
- **Backend vs front-end consistency on duplicate tests in a scenario** (`frontend/src/pages/AdminScenariosPage.tsx:227-231`): the backend allows the same `test_template` to appear multiple times (chained ops; the UNIQUE constraint is `(scenario_id, position)` not `(scenario_id, test_template_id)`), but the catalogue picker was filtering out already-picked items. Removed the filter — only soft-deleted tests are excluded now.
- **Test coverage closure** (`backend/tests/test_templates.py`): +4 pytest (tactic+technique AND-semantics, `extra="forbid"` rejection, empty `mitre_tags` explicit clear, 65-char tag length cap → 400). Total backend now 23 M5 tests + 39 elsewhere = 81 pass.
### Added — M4 (MITRE ATT&CK Enterprise) ### Added — M4 (MITRE ATT&CK Enterprise)
- **STIX 2.1 parser + upsert** (`app/services/mitre_seed.py`): stdlib-only (`urllib.request` + `hashlib`), pinned to Enterprise v19.0 (`enterprise-attack-19.0.json`, sha256 `df520ea0…`). Parses 25k+ STIX objects → 15 tactics, 222 techniques, 475 sub-techniques in ~1.1 s. Skips revoked + deprecated, resolves sub-technique parents via `relationship[subtechnique-of]` with a `T1003.001 → T1003` dotted-id fallback, copies kill-chain phases into the `mitre_technique_tactics` M2M. - **STIX 2.1 parser + upsert** (`app/services/mitre_seed.py`): stdlib-only (`urllib.request` + `hashlib`), pinned to Enterprise v19.0 (`enterprise-attack-19.0.json`, sha256 `df520ea0…`). Parses 25k+ STIX objects → 15 tactics, 222 techniques, 475 sub-techniques in ~1.1 s. Skips revoked + deprecated, resolves sub-technique parents via `relationship[subtechnique-of]` with a `T1003.001 → T1003` dotted-id fallback, copies kill-chain phases into the `mitre_technique_tactics` M2M.
- **CLI**: `flask metamorph seed-mitre [--source <path|url>] [--checksum-sha256 <hex>] [--skip-checksum]` (`app/cli.py`). `make seed-mitre` wraps it. - **CLI**: `flask metamorph seed-mitre [--source <path|url>] [--checksum-sha256 <hex>] [--skip-checksum]` (`app/cli.py`). `make seed-mitre` wraps it.
@@ -14,8 +243,8 @@ All notable changes to this project will be documented here. Format: [Keep a Cha
- **Persisted metadata** in `settings`: `mitre_last_sync`, `mitre_version`, `mitre_source_url`. - **Persisted metadata** in `settings`: `mitre_last_sync`, `mitre_version`, `mitre_source_url`.
- **Compose volume `metamorph_mitre`** mounted at `/data/mitre/` in the api container — caches the downloaded bundle across restarts. Owned by `metamorph:metamorph`. - **Compose volume `metamorph_mitre`** mounted at `/data/mitre/` in the api container — caches the downloaded bundle across restarts. Owned by `metamorph:metamorph`.
- **Frontend**: - **Frontend**:
- `<MitreTagPicker>` component: 3-column tactic → technique → sub-technique with multi-select chips, autocomplete on each column. Returns `MitreTag[]` (`kind`, `id`, `external_id`, `name`), ready for M5 templates. - `<MitreTagPicker>` component: flat ATT&CK matrix matching `attack.mitre.org/#` — full-bleed beyond `max-w-page`, 15 equal-width columns via `grid-template-columns: repeat(N, minmax(7rem, 1fr))`, sans-serif 12px, **name-only cells** (external_id surfaces on hover via `title` and in selection chips), `▸/▾` chevron expands sub-techniques inline within the column, multi-select with chip-removal at the top. Returns `MitreTag[]` (`kind`, `id`, `external_id`, `name`), ready for M5 templates.
- `/mitre` showcase page with status card, admin-gated **Trigger sync** button, picker preview, and `<pre>` payload preview. - `/mitre` showcase page with status card, admin-gated **Trigger sync** button, the picker, and a JSON `<pre>` preview of the current selection.
- Nav adds **MITRE** link for any logged-in user. - Nav adds **MITRE** link for any logged-in user.
- **Testing**: - **Testing**:
- `backend/tests/test_mitre.py`**12 pytest** (parser, idempotence, checksum mismatch, persisted settings, endpoint variants, perm enforcement) using a hand-crafted minimal STIX bundle (no network in tests). - `backend/tests/test_mitre.py`**12 pytest** (parser, idempotence, checksum mismatch, persisted settings, endpoint variants, perm enforcement) using a hand-crafted minimal STIX bundle (no network in tests).
@@ -27,6 +256,17 @@ All notable changes to this project will be documented here. Format: [Keep a Cha
- **`/diag/reset` consistency**: now truncates the `mitre_*` tables alongside `settings` so `GET /mitre/status` and `GET /mitre/tactics` agree after a reset (previously: catalogue rows persisted, but `mitre_last_sync` got wiped → status lied). - **`/diag/reset` consistency**: now truncates the `mitre_*` tables alongside `settings` so `GET /mitre/status` and `GET /mitre/tactics` agree after a reset (previously: catalogue rows persisted, but `mitre_last_sync` got wiped → status lied).
- **Spec drift §10 #4**: amended "14 tactics" → "≥ 14 tactics (v19 ships 15)" to reflect MITRE v8+ reality. - **Spec drift §10 #4**: amended "14 tactics" → "≥ 14 tactics (v19 ships 15)" to reflect MITRE v8+ reality.
### Fixed (post-M4 code-review pass)
- **SSRF allowlist on `/mitre/sync`**: host must be in `MITRE_ALLOWED_HOSTS` (defaults to `raw.githubusercontent.com`, comma-separated env override). Closes the "admin holding `mitre.sync` can pivot the api container at cloud metadata (`169.254.169.254`) or internal mirrors" vector. New `MitreSourceForbidden` exception → 400 with `source_forbidden` error code.
- **Concurrent sync race**: `seed_mitre()` now acquires `pg_advisory_xact_lock(hashtext('mitre.seed'))` at the top of the transaction so two `/mitre/sync` calls serialise cleanly across the `DELETE` + re-`INSERT` of `mitre_technique_tactics`.
- **Typed sync contract end-to-end**: Pydantic `SyncResultOut` on the backend (`app/api/mitre.py`) mirrored by a `MitreSyncResult` TS interface (`frontend/src/lib/mitre.ts`). The MitrePage mutation no longer uses an `as Record<string, unknown>` escape hatch.
- **N+1 in dotted sub-technique fallback**: pre-built `{external_id → id}` dict at function entry; was firing one extra SELECT per orphan (currently 0 with MITRE, but a latent footgun for partial bundles).
- **`SETTING_VERSION` cleared explicitly when source != default**: previously kept the stale pinned version after a custom-URL re-sync; now `_upsert_setting(..., None)` so `/mitre/status` doesn't lie.
- **Internal error scrub on `/mitre/sync`**: 500 responses no longer leak URLError / DB driver text via `str(e)` — stack lands in JSON logs only.
- **E2E pinned to exact MITRE v19 counts** (15/222/475/0 orphans) for parser-regression detection; previously `>=` thresholds could mask "revoked tactics silently included".
- **E2E uses `crypto.randomUUID()`** instead of `Math.random()` for unique test emails.
- **Test coverage for security guards**: `file://` rejection, disallowed HTTPS host, custom-URL-without-sha refusal, dotted-id fallback, version-clearing semantics — 5 new pytest covering paths the spec-review demanded but no test enforced.
### Decisions (intentional) ### Decisions (intentional)
- **Bundle "embarqué" interpreted as seed-time download + named-volume cache**, not "binary baked into the Docker image". Keeps the image ~150 MB, makes version bumps a constant edit, plays nicely with `make seed-mitre` re-runs. Air-gapped operators copy the file into the volume + pass `--source /data/mitre/<file>`. - **Bundle "embarqué" interpreted as seed-time download + named-volume cache**, not "binary baked into the Docker image". Keeps the image ~150 MB, makes version bumps a constant edit, plays nicely with `make seed-mitre` re-runs. Air-gapped operators copy the file into the volume + pass `--source /data/mitre/<file>`.
- **Read endpoints unauthenticated-perm-wise but auth-required**: MITRE data is public reference material — no `mitre.read` perm. Status endpoint is similarly open (under `@require_auth`) to keep `/mitre/status` simple for the UI badge. - **Read endpoints unauthenticated-perm-wise but auth-required**: MITRE data is public reference material — no `mitre.read` perm. Status endpoint is similarly open (under `@require_auth`) to keep `/mitre/status` simple for the UI badge.
@@ -34,7 +274,7 @@ All notable changes to this project will be documented here. Format: [Keep a Cha
### Validated end-to-end (M4 DoD) ### Validated end-to-end (M4 DoD)
- `make clean && make up && make migrate && make seed-mitre` → 15 tactics / 222 techniques / 475 sub-techniques / 254 links / 0 orphans / ~1.1 s. - `make clean && make up && make migrate && make seed-mitre` → 15 tactics / 222 techniques / 475 sub-techniques / 254 links / 0 orphans / ~1.1 s.
- `make test-api`**53 pytest pass** (1 health + 8 schema + 15 auth + 15 RBAC + 14 MITRE) in ~5 s. - `make test-api`**58 pytest pass** (1 health + 8 schema + 15 auth + 15 RBAC + 19 MITRE) in ~5 s.
- `make e2e`**34 Playwright pass** (8 M0 + 4 M1 + 8 M2 + 8 M3 + 6 M4) in ~18 s. - `make e2e`**34 Playwright pass** (8 M0 + 4 M1 + 8 M2 + 8 M3 + 6 M4) in ~18 s.
- Spec-reviewer PASS after fixes applied. - Spec-reviewer PASS after fixes applied.

View File

@@ -2,7 +2,7 @@
Collaborative purple-team platform. Red team logs the tests they execute (procedure, command, timestamp); blue team annotates each test with detection evidence (alerts, logs, files). At the end of an engagement, Metamorph generates a standalone reveal.js slide deck classified by MITRE ATT&CK tactic. Collaborative purple-team platform. Red team logs the tests they execute (procedure, command, timestamp); blue team annotates each test with detection evidence (alerts, logs, files). At the end of an engagement, Metamorph generates a standalone reveal.js slide deck classified by MITRE ATT&CK tactic.
> **Status**: M0M4 delivered (bootstrap → DB schema → auth → RBAC → MITRE ATT&CK reference). See `tasks/spec.md` for the full specification and `tasks/todo.md` for the milestone-by-milestone plan. > **Status**: M0M7 delivered (bootstrap → DB schema → auth → RBAC → MITRE ATT&CK reference → test & scenario templates → missions snapshot → red/blue execution on a mission test). See `tasks/spec.md` for the full specification and `tasks/todo.md` for the milestone-by-milestone plan.
## Stack ## Stack
@@ -11,6 +11,9 @@ Collaborative purple-team platform. Red team logs the tests they execute (proced
- **Auth (M2+)**: JWT access (1h) + refresh (30d), Argon2id, invite-link enrollment. - **Auth (M2+)**: JWT access (1h) + refresh (30d), Argon2id, invite-link enrollment.
- **RBAC (M3+)**: atomic permissions (31 codes) bundled into custom groups; 3 system groups seeded (`admin` / `redteam` / `blueteam`). - **RBAC (M3+)**: atomic permissions (31 codes) bundled into custom groups; 3 system groups seeded (`admin` / `redteam` / `blueteam`).
- **MITRE ATT&CK (M4+)**: Enterprise reference catalogue pinned to v19.0, seedable via `make seed-mitre`. - **MITRE ATT&CK (M4+)**: Enterprise reference catalogue pinned to v19.0, seedable via `make seed-mitre`.
- **Template catalogue (M5+)**: reusable `test_templates` (markdown procedure, OPSEC level, free tags, expected IOCs, MITRE tags) + ordered `scenario_templates` with drag-and-drop reordering. Admin pages at `/admin/tests` and `/admin/scenarios`.
- **Missions (M6+)**: `missions` snapshot one or more scenario templates at creation time; template edits don't drift live missions (`mission_*` tables freeze every field, including MITRE tags). Non-admin members see only their own missions (membership filter, 404 on existence-leak attempts). Status state machine `draft → in_progress → completed → archived`, archive perm gated separately. SPA: list/filter at `/missions`, 3-step create wizard at `/missions/new`, detail page with Tests / Members / Synthesis / Export tabs.
- **Execution (M7+)**: per-test page `/missions/<id>/tests/<test_id>` with two zones — red (command/output/comment + mark-executed with override) and blue (detection-level select / comment / drag-and-drop evidence upload). Field-level perm gating: `mission.write_red_fields` / `mission.write_blue_fields` are server-enforced *per field*. State machine `pending↔skipped/blocked` + `pending→executed→reviewed_by_blue` with side-aware perms. Evidence pipeline: streaming upload to `${EVIDENCE_DIR}/<mission>/<test>/<sha256><ext>`, SHA256 + MIME + extension + 25 MB cap. 15 s activity polling via `/missions/<id>/activity?since=…` drives the "modified by X" badge. 4 default `detection_levels` seeded at boot.
- **Delivery**: docker-compose. TLS termination is expected to be handled by an external reverse proxy in production. - **Delivery**: docker-compose. TLS termination is expected to be handled by an external reverse proxy in production.
## Quickstart ## Quickstart
@@ -93,7 +96,7 @@ See `.env.example`. The most important ones:
## Testing ## Testing
- **Manual + automated checklist for the current milestone**: see [`tasks/testing-m<N>.md`](tasks/testing-m4.md) (current: `testing-m4.md`). - **Manual + automated checklist for the current milestone**: see [`tasks/testing-m<N>.md`](tasks/testing-m7.md) (current: `testing-m7.md`).
- **Backend unit tests**: `make test-api` - **Backend unit tests**: `make test-api`
- **End-to-end (Playwright)**: `make e2e-install` (once), then `make up && make e2e`. Reports land in `e2e/playwright-report/` (HTML + JUnit XML); open with `make e2e-report`. - **End-to-end (Playwright)**: `make e2e-install` (once), then `make up && make e2e`. Reports land in `e2e/playwright-report/` (HTML + JUnit XML); open with `make e2e-report`.
@@ -135,7 +138,7 @@ The hooks run `ruff` + `ruff-format` on the backend and `eslint` / `tsc --noEmit
## Roadmap ## Roadmap
See `tasks/todo.md`. Current milestone: **M4MITRE ATT&CK Enterprise**. Next: M5 (test & scenario templates). See `tasks/todo.md`. Current milestone: **M7Red & blue execution on a mission test** (done). Next: M8 (custom detection-level CRUD).
## License ## License

View File

@@ -0,0 +1,53 @@
"""m7 mission test last actor tracking
Revision ID: 91a4e7c6d2f3
Revises: 24765a5014b6
Create Date: 2026-05-14 10:00:00.000000
Adds the `last_actor_id` column to `mission_tests` so the activity polling
endpoint can answer "modified by X" without joining the audit log (M14 owns
the full audit story). FK to `users.id` with `ON DELETE SET NULL` so deleting
a user does not wipe the history of their writes.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "91a4e7c6d2f3"
down_revision: str | None = "24765a5014b6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"mission_tests",
sa.Column("last_actor_id", sa.Uuid(), nullable=True),
)
op.create_foreign_key(
op.f("fk_mission_tests_last_actor_id_users"),
"mission_tests",
"users",
["last_actor_id"],
["id"],
ondelete="SET NULL",
)
op.create_index(
"ix_mission_tests_updated_at",
"mission_tests",
["updated_at"],
unique=False,
)
def downgrade() -> None:
op.drop_index("ix_mission_tests_updated_at", table_name="mission_tests")
op.drop_constraint(
op.f("fk_mission_tests_last_actor_id_users"),
"mission_tests",
type_="foreignkey",
)
op.drop_column("mission_tests", "last_actor_id")

View File

@@ -0,0 +1,59 @@
"""m7 blue review fields on mission_tests
Revision ID: c2a8f4b1d6e9
Revises: 91a4e7c6d2f3
Create Date: 2026-05-15 09:00:00.000000
User feedback after the M7 ship: the blue team's review workflow needs five
more fields they used to maintain in Excel — log source, raw SIEM excerpt,
plus a small cyber-incident sub-record (timestamp, number, recipient email).
All five are blue-side and gated by `mission.write_blue_fields`.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "c2a8f4b1d6e9"
down_revision: str | None = "91a4e7c6d2f3"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"mission_tests",
sa.Column("blue_log_source", sa.String(length=120), nullable=True),
)
op.add_column(
"mission_tests",
sa.Column("blue_siem_logs", sa.Text(), nullable=True),
)
op.add_column(
"mission_tests",
sa.Column(
"blue_incident_at",
sa.DateTime(timezone=True),
nullable=True,
),
)
op.add_column(
"mission_tests",
sa.Column("blue_incident_number", sa.String(length=120), nullable=True),
)
op.add_column(
"mission_tests",
sa.Column(
"blue_incident_recipient_email", sa.String(length=255), nullable=True
),
)
def downgrade() -> None:
op.drop_column("mission_tests", "blue_incident_recipient_email")
op.drop_column("mission_tests", "blue_incident_number")
op.drop_column("mission_tests", "blue_incident_at")
op.drop_column("mission_tests", "blue_siem_logs")
op.drop_column("mission_tests", "blue_log_source")

View File

@@ -77,7 +77,7 @@ def login():
try: try:
payload = LoginPayload.model_validate(request.get_json(silent=True) or {}) payload = LoginPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e: 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: try:
pair = auth_svc.login(payload.email, payload.password) pair = auth_svc.login(payload.email, payload.password)
except auth_svc.InvalidCredentials: except auth_svc.InvalidCredentials:
@@ -144,7 +144,7 @@ def change_password():
try: try:
payload = ChangePasswordPayload.model_validate(request.get_json(silent=True) or {}) payload = ChangePasswordPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e: 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: try:
auth_svc.change_password(g.current_user.id, payload.current_password, payload.new_password) auth_svc.change_password(g.current_user.id, payload.current_password, payload.new_password)
except auth_svc.InvalidCredentials: except auth_svc.InvalidCredentials:

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 from __future__ import annotations
import logging import logging
import shutil
from pathlib import Path
from flask import Blueprint, abort, jsonify from flask import Blueprint, abort, jsonify
from sqlalchemy import text from sqlalchemy import text
@@ -16,6 +18,7 @@ from sqlalchemy.exc import SQLAlchemyError
from app.core.config import settings from app.core.config import settings
from app.core.install_token import regenerate_install_token from app.core.install_token import regenerate_install_token
from app.db.session import get_engine from app.db.session import get_engine
from app.services.detection_levels import seed_detection_levels
bp = Blueprint("diag", __name__, url_prefix="/diag") bp = Blueprint("diag", __name__, url_prefix="/diag")
log = logging.getLogger("metamorph.diag") log = logging.getLogger("metamorph.diag")
@@ -73,6 +76,31 @@ def reset_test_state():
"user_groups, settings, groups RESTART IDENTITY CASCADE" "user_groups, settings, groups RESTART IDENTITY CASCADE"
) )
) )
# Mission catalogue reset (M6). Truncated before the template tables
# below because `mission_scenarios.source_scenario_template_id` and
# `mission_tests.source_test_template_id` are ON DELETE SET NULL — a
# cascade-truncate of templates would attempt to null those columns
# and stall on the constraint check. Wiping the mission tables first
# avoids that round-trip; cascades from `missions` then take care of
# members, scenarios, tests, mitre_tags, categories.
conn.execute(
text(
"TRUNCATE mission_test_mitre_tags, mission_tests, "
"mission_scenarios, mission_categories, mission_members, "
"missions RESTART IDENTITY CASCADE"
)
)
# Template catalogue reset (M5). The MITRE truncate below cascades to
# the polymorphic tag join, but the template rows themselves must be
# wiped first because `scenario_template_tests.test_template_id` is
# ON DELETE RESTRICT.
conn.execute(
text(
"TRUNCATE scenario_template_tests, scenario_templates, "
"test_template_mitre_tags, test_templates "
"RESTART IDENTITY CASCADE"
)
)
# MITRE reference reset — kept in sync with `settings` so a freshly # MITRE reference reset — kept in sync with `settings` so a freshly
# reset stack has `GET /mitre/status` and `GET /mitre/tactics` agree # reset stack has `GET /mitre/status` and `GET /mitre/tactics` agree
# ("no data, no last_sync"). The e2e suite re-syncs via /mitre/sync # ("no data, no last_sync"). The e2e suite re-syncs via /mitre/sync
@@ -83,10 +111,39 @@ def reset_test_state():
"mitre_techniques, mitre_tactics RESTART IDENTITY CASCADE" "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: except SQLAlchemyError as e:
log.error("metamorph.diag.reset_failed", extra={"error": str(e)}) log.error("metamorph.diag.reset_failed", extra={"error": str(e)})
return jsonify({"reset": False, "error": "database_error"}), 500 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() token = regenerate_install_token()
# Clear the in-memory rate-limit counters so the e2e suite that follows can # 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

@@ -84,7 +84,7 @@ def create_group():
try: try:
payload = CreateGroupPayload.model_validate(request.get_json(silent=True) or {}) payload = CreateGroupPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e: 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: try:
g = groups_svc.create_group(name=payload.name, description=payload.description) g = groups_svc.create_group(name=payload.name, description=payload.description)
except groups_svc.GroupNameConflict as e: except groups_svc.GroupNameConflict as e:
@@ -106,7 +106,7 @@ def update_group(group_id: str):
try: try:
payload = UpdateGroupPayload.model_validate(raw) payload = UpdateGroupPayload.model_validate(raw)
except ValidationError as e: 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 description_unset = "description" not in raw
try: try:
g = groups_svc.update_group( g = groups_svc.update_group(
@@ -153,7 +153,7 @@ def set_permissions(group_id: str):
try: try:
payload = SetPermissionsPayload.model_validate(request.get_json(silent=True) or {}) payload = SetPermissionsPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e: 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: try:
g = groups_svc.set_group_permissions(gid, payload.codes) g = groups_svc.set_group_permissions(gid, payload.codes)
except groups_svc.GroupNotFound: except groups_svc.GroupNotFound:

View File

@@ -36,7 +36,7 @@ def create():
try: try:
payload = CreateInvitationPayload.model_validate(request.get_json(silent=True) or {}) payload = CreateInvitationPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e: 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 from datetime import timedelta
@@ -124,7 +124,7 @@ def accept(token: str):
try: try:
payload = AcceptInvitationPayload.model_validate(request.get_json(silent=True) or {}) payload = AcceptInvitationPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e: 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: try:
user_id = inv_svc.accept( user_id = inv_svc.accept(
token, token,

912
backend/app/api/missions.py Normal file
View File

@@ -0,0 +1,912 @@
"""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(),
}
)

View File

@@ -9,6 +9,7 @@ from __future__ import annotations
import logging import logging
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify, request
from pydantic import BaseModel
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from app.core.auth_decorators import require_auth, require_perm from app.core.auth_decorators import require_auth, require_perm
@@ -16,6 +17,21 @@ from app.db.session import session_scope
from app.models.mitre import MitreSubtechnique, MitreTactic, MitreTechnique from app.models.mitre import MitreSubtechnique, MitreTactic, MitreTechnique
from app.services import mitre_seed as mitre_seed_svc from app.services import mitre_seed as mitre_seed_svc
class SyncResultOut(BaseModel):
"""Response schema for `POST /mitre/sync`. Mirrors `SeedResult.as_dict()`."""
tactics_upserted: int
techniques_upserted: int
subtechniques_upserted: int
subtechniques_skipped_orphan: int
technique_tactic_links: int
version: str | None
source: str
started_at: str
finished_at: str
duration_ms: int
bp = Blueprint("mitre", __name__, url_prefix="/mitre") bp = Blueprint("mitre", __name__, url_prefix="/mitre")
log = logging.getLogger("metamorph.api.mitre") log = logging.getLogger("metamorph.api.mitre")
@@ -248,7 +264,8 @@ def sync():
Custom `source` URLs MUST be paired with either `expected_sha256` (integrity Custom `source` URLs MUST be paired with either `expected_sha256` (integrity
guarantee) or `allow_unverified: true` (explicit opt-out) — the seed service guarantee) or `allow_unverified: true` (explicit opt-out) — the seed service
will raise otherwise. will raise otherwise. The host is allowlisted (defaults to
raw.githubusercontent.com, overridable via the MITRE_ALLOWED_HOSTS env).
""" """
payload = request.get_json(silent=True) or {} payload = request.get_json(silent=True) or {}
source = payload.get("source") # optional URL override source = payload.get("source") # optional URL override
@@ -261,12 +278,19 @@ def sync():
or (mitre_seed_svc.MITRE_DEFAULT_SHA256 if source is None else None), or (mitre_seed_svc.MITRE_DEFAULT_SHA256 if source is None else None),
allow_unverified=allow_unverified, allow_unverified=allow_unverified,
) )
except mitre_seed_svc.MitreSourceForbidden as e:
return jsonify({"error": "source_forbidden", "message": str(e)}), 400
except mitre_seed_svc.MitreChecksumMismatch as e: except mitre_seed_svc.MitreChecksumMismatch as e:
return jsonify({"error": "checksum_mismatch", "message": str(e)}), 502 return jsonify({"error": "checksum_mismatch", "message": str(e)}), 502
except mitre_seed_svc.MitreSeedError as e: except mitre_seed_svc.MitreSeedError as e:
return jsonify({"error": "seed_failed", "message": str(e)}), 502 return jsonify({"error": "seed_failed", "message": str(e)}), 502
except Exception as e: # noqa: BLE001 except Exception: # noqa: BLE001
# Do NOT leak the internal error string to the client (URLError stack,
# DB driver text). The stack lands in our JSON logs.
log.exception("metamorph.api.mitre.sync_failed") log.exception("metamorph.api.mitre.sync_failed")
return jsonify({"error": "internal_error", "message": str(e)}), 500 return jsonify({"error": "internal_error"}), 500
log.warning("metamorph.api.mitre.sync_done", extra=result.as_dict()) # Validate via the Pydantic Out model so the response contract is
return jsonify(result.as_dict()) # explicit (single source of truth shared with the TS interface).
payload_out = SyncResultOut.model_validate(result.as_dict()).model_dump()
log.info("metamorph.api.mitre.sync_done", extra=payload_out)
return jsonify(payload_out)

View File

@@ -0,0 +1,208 @@
"""Scenario-template CRUD + reorder endpoints.
`PUT /<id>/tests` is the reorder/replace endpoint — it takes the full ordered
list and rewrites the join rows. There's no partial mutation API for the test
list: the wire contract is simpler and the client (drag-and-drop) already
holds the full ordering.
"""
from __future__ import annotations
import logging
import uuid
from typing import Any
from flask import Blueprint, jsonify, request
from pydantic import BaseModel, Field, ValidationError
from app.core.auth_decorators import require_auth, require_perm
from app.services import scenario_templates as svc
bp = Blueprint("scenario_templates", __name__, url_prefix="/scenario-templates")
log = logging.getLogger("metamorph.api.scenario_templates")
class CreateScenarioPayload(BaseModel):
name: str = Field(min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=4000)
test_template_ids: list[uuid.UUID] = Field(default_factory=list, max_length=512)
model_config = {"extra": "forbid"}
class UpdateScenarioPayload(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=4000)
model_config = {"extra": "forbid"}
class SetTestsPayload(BaseModel):
test_template_ids: list[uuid.UUID] = Field(default_factory=list, max_length=512)
model_config = {"extra": "forbid"}
def _serialize(sc: svc.ScenarioTemplateView) -> dict[str, Any]:
return {
"id": str(sc.id),
"name": sc.name,
"description": sc.description,
"tests": [
{
"position": t.position,
"test_template_id": str(t.test_template_id),
"test_template_name": t.test_template_name,
"test_template_deleted": t.test_template_deleted,
}
for t in sc.tests
],
"tests_count": sc.tests_count,
"deleted_at": sc.deleted_at.isoformat() if sc.deleted_at else None,
"created_at": sc.created_at.isoformat(),
"updated_at": sc.updated_at.isoformat(),
}
def _parse_uuid_or_400(raw: str):
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)
@bp.get("")
@require_auth
@require_perm("scenario_template.read")
def list_scenario_templates():
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
include_deleted = request.args.get("include_deleted", "false").lower() == "true"
items, total = svc.list_scenario_templates(
q=q, include_deleted=include_deleted, limit=limit, offset=offset
)
return jsonify(
{
"items": [_serialize(it) for it in items],
"total": total,
"limit": limit,
"offset": offset,
}
)
@bp.get("/<scenario_id>")
@require_auth
@require_perm("scenario_template.read")
def get_scenario_template(scenario_id: str):
sid = _parse_uuid_or_400(scenario_id)
if sid is None:
return jsonify({"error": "invalid_id"}), 400
include_deleted = request.args.get("include_deleted", "false").lower() == "true"
try:
view = svc.get_scenario_template(sid, include_deleted=include_deleted)
except svc.ScenarioTemplateNotFound:
return jsonify({"error": "not_found"}), 404
return jsonify(_serialize(view))
@bp.post("")
@require_auth
@require_perm("scenario_template.create")
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(include_context=False, include_url=False)}), 400
try:
view = svc.create_scenario_template(
name=payload.name,
description=payload.description,
test_template_ids=payload.test_template_ids,
)
except svc.UnknownTestTemplate as e:
return jsonify({"error": "unknown_test_template", "message": str(e)}), 400
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
log.info(
"metamorph.scenario_template.created",
extra={"id": str(view.id), "tests": len(view.tests)},
)
return jsonify(_serialize(view)), 201
@bp.patch("/<scenario_id>")
@require_auth
@require_perm("scenario_template.update")
def update_scenario_template(scenario_id: str):
sid = _parse_uuid_or_400(scenario_id)
if sid is None:
return jsonify({"error": "invalid_id"}), 400
raw = request.get_json(silent=True) or {}
try:
payload = UpdateScenarioPayload.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] = {}
if "name" in raw:
kwargs["name"] = payload.name
if "description" in raw:
kwargs["description"] = payload.description
try:
view = svc.update_scenario_template(sid, **kwargs)
except svc.ScenarioTemplateNotFound:
return jsonify({"error": "not_found"}), 404
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
return jsonify(_serialize(view))
@bp.put("/<scenario_id>/tests")
@require_auth
@require_perm("scenario_template.update")
def set_scenario_tests(scenario_id: str):
sid = _parse_uuid_or_400(scenario_id)
if sid is None:
return jsonify({"error": "invalid_id"}), 400
try:
payload = SetTestsPayload.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
try:
view = svc.set_scenario_tests(sid, payload.test_template_ids)
except svc.ScenarioTemplateNotFound:
return jsonify({"error": "not_found"}), 404
except svc.UnknownTestTemplate as e:
return jsonify({"error": "unknown_test_template", "message": str(e)}), 400
log.info(
"metamorph.scenario_template.tests_set",
extra={"id": str(sid), "tests": len(view.tests)},
)
return jsonify(_serialize(view))
@bp.delete("/<scenario_id>")
@require_auth
@require_perm("scenario_template.delete")
def soft_delete_scenario_template(scenario_id: str):
sid = _parse_uuid_or_400(scenario_id)
if sid is None:
return jsonify({"error": "invalid_id"}), 400
try:
svc.soft_delete_scenario_template(sid)
except svc.ScenarioTemplateNotFound:
return jsonify({"error": "not_found"}), 404
log.info("metamorph.scenario_template.soft_deleted", extra={"id": str(sid)})
return jsonify({"ok": True})

View File

@@ -47,7 +47,7 @@ def setup():
try: try:
payload = SetupPayload.model_validate(request.get_json(silent=True) or {}) payload = SetupPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e: 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: try:
result = bootstrap_admin( result = bootstrap_admin(

View File

@@ -0,0 +1,257 @@
"""Test-template CRUD endpoints.
Reads gated by `test_template.read`. Writes gated by `test_template.{create,
update,delete}`. Service layer handles all DB work; this module only validates
the wire payload and shapes the JSON response.
"""
from __future__ import annotations
import logging
import uuid
from typing import Any
from flask import Blueprint, jsonify, request
from pydantic import BaseModel, Field, StringConstraints, ValidationError
from typing import Annotated
from app.core.auth_decorators import require_auth, require_perm
from app.services import test_templates as svc
# Tag and IOC entries are stored as PG ARRAY(String(N)). Cap items at the wire
# layer so over-sized inputs return 400 with a useful message rather than the
# bare StringDataRightTruncation from the driver.
TagStr = Annotated[str, StringConstraints(min_length=1, max_length=64)]
IocStr = Annotated[str, StringConstraints(min_length=1, max_length=255)]
bp = Blueprint("test_templates", __name__, url_prefix="/test-templates")
log = logging.getLogger("metamorph.api.test_templates")
# === Payload schemas ==========================================================
class MitreTagIn(BaseModel):
kind: str = Field(min_length=1)
external_id: str = Field(min_length=1, max_length=16)
model_config = {"extra": "forbid"}
class CreateTestTemplatePayload(BaseModel):
name: str = Field(min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=4000)
objective: str | None = Field(default=None, max_length=4000)
procedure_md: str | None = Field(default=None, max_length=32_000)
prerequisites_md: str | None = Field(default=None, max_length=32_000)
expected_result_red_md: str | None = Field(default=None, max_length=32_000)
expected_detection_blue_md: str | None = Field(default=None, max_length=32_000)
opsec_level: str = Field(default="medium")
tags: list[TagStr] = Field(default_factory=list, max_length=64)
expected_iocs: list[IocStr] = Field(default_factory=list, max_length=128)
mitre_tags: list[MitreTagIn] = Field(default_factory=list, max_length=64)
model_config = {"extra": "forbid"}
class UpdateTestTemplatePayload(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=4000)
objective: str | None = Field(default=None, max_length=4000)
procedure_md: str | None = Field(default=None, max_length=32_000)
prerequisites_md: str | None = Field(default=None, max_length=32_000)
expected_result_red_md: str | None = Field(default=None, max_length=32_000)
expected_detection_blue_md: str | None = Field(default=None, max_length=32_000)
opsec_level: str | None = None
tags: list[TagStr] | None = Field(default=None, max_length=64)
expected_iocs: list[IocStr] | None = Field(default=None, max_length=128)
mitre_tags: list[MitreTagIn] | None = Field(default=None, max_length=64)
model_config = {"extra": "forbid"}
# === Serializers ==============================================================
def _serialize(t: svc.TestTemplateView) -> dict[str, Any]:
return {
"id": str(t.id),
"name": t.name,
"description": t.description,
"objective": t.objective,
"procedure_md": t.procedure_md,
"prerequisites_md": t.prerequisites_md,
"expected_result_red_md": t.expected_result_red_md,
"expected_detection_blue_md": t.expected_detection_blue_md,
"opsec_level": t.opsec_level,
"tags": list(t.tags),
"expected_iocs": list(t.expected_iocs),
"mitre_tags": [
{"kind": tag.kind, "external_id": tag.external_id, "name": tag.name, "url": tag.url}
for tag in t.mitre_tags
],
"deleted_at": t.deleted_at.isoformat() if t.deleted_at else None,
"created_at": t.created_at.isoformat(),
"updated_at": t.updated_at.isoformat(),
}
def _parse_uuid_or_400(raw: str):
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)
# === Endpoints ================================================================
@bp.get("")
@require_auth
@require_perm("test_template.read")
def list_test_templates():
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
tactic = request.args.get("tactic") or None
technique = request.args.get("technique") or None
subtechnique = request.args.get("subtechnique") or None
opsec_level = request.args.get("opsec") or None
tag = request.args.get("tag") or None
include_deleted = request.args.get("include_deleted", "false").lower() == "true"
try:
items, total = svc.list_test_templates(
q=q,
tactic=tactic,
technique=technique,
subtechnique=subtechnique,
opsec_level=opsec_level,
tag=tag,
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(it) for it in items],
"total": total,
"limit": limit,
"offset": offset,
}
)
@bp.get("/<template_id>")
@require_auth
@require_perm("test_template.read")
def get_test_template(template_id: str):
tid = _parse_uuid_or_400(template_id)
if tid is None:
return jsonify({"error": "invalid_id"}), 400
include_deleted = request.args.get("include_deleted", "false").lower() == "true"
try:
view = svc.get_test_template(tid, include_deleted=include_deleted)
except svc.TestTemplateNotFound:
return jsonify({"error": "not_found"}), 404
return jsonify(_serialize(view))
@bp.post("")
@require_auth
@require_perm("test_template.create")
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(include_context=False, include_url=False)}), 400
try:
view = svc.create_test_template(
name=payload.name,
description=payload.description,
objective=payload.objective,
procedure_md=payload.procedure_md,
prerequisites_md=payload.prerequisites_md,
expected_result_red_md=payload.expected_result_red_md,
expected_detection_blue_md=payload.expected_detection_blue_md,
opsec_level=payload.opsec_level,
tags=payload.tags,
expected_iocs=payload.expected_iocs,
mitre_tags=[svc.MitreTagRef(kind=t.kind, external_id=t.external_id) for t in payload.mitre_tags],
)
except svc.UnknownMitreTag as e:
return jsonify({"error": "unknown_mitre_tag", "message": str(e)}), 400
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
log.info(
"metamorph.test_template.created",
extra={"id": str(view.id), "template_name": view.name},
)
return jsonify(_serialize(view)), 201
@bp.put("/<template_id>")
@require_auth
@require_perm("test_template.update")
def update_test_template(template_id: str):
tid = _parse_uuid_or_400(template_id)
if tid is None:
return jsonify({"error": "invalid_id"}), 400
raw = request.get_json(silent=True) or {}
try:
payload = UpdateTestTemplatePayload.model_validate(raw)
except ValidationError as e:
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
# "omitted". The set of keys in `raw` is the wire-level intent.
kwargs: dict[str, Any] = {}
for field_name in (
"name", "description", "objective", "procedure_md", "prerequisites_md",
"expected_result_red_md", "expected_detection_blue_md",
"opsec_level", "tags", "expected_iocs",
):
if field_name in raw:
kwargs[field_name] = getattr(payload, field_name)
if "mitre_tags" in raw:
kwargs["mitre_tags"] = (
[svc.MitreTagRef(kind=t.kind, external_id=t.external_id) for t in (payload.mitre_tags or [])]
)
try:
view = svc.update_test_template(tid, **kwargs)
except svc.TestTemplateNotFound:
return jsonify({"error": "not_found"}), 404
except svc.UnknownMitreTag as e:
return jsonify({"error": "unknown_mitre_tag", "message": str(e)}), 400
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
log.info("metamorph.test_template.updated", extra={"id": str(tid), "fields": sorted(kwargs.keys())})
return jsonify(_serialize(view))
@bp.delete("/<template_id>")
@require_auth
@require_perm("test_template.delete")
def soft_delete_test_template(template_id: str):
tid = _parse_uuid_or_400(template_id)
if tid is None:
return jsonify({"error": "invalid_id"}), 400
try:
svc.soft_delete_test_template(tid)
except svc.TestTemplateNotFound:
return jsonify({"error": "not_found"}), 404
log.info("metamorph.test_template.soft_deleted", extra={"id": str(tid)})
return jsonify({"ok": True})

View File

@@ -56,6 +56,37 @@ def _parse_uuid_or_400(raw: str):
return None return None
@bp.get("/roster")
@require_auth
@require_perm("user.read", "mission.create", "mission.update")
def list_roster():
"""Minimal user list for mission member assignment.
Returns only `id`, `email`, `display_name` of active, non-deleted users.
Accessible to anyone who can create or update a mission — strictly lighter
than `GET /users`, which leaks `is_admin` (via groups), `is_active`, and
group memberships and is therefore reserved to `user.read`.
"""
q = request.args.get("q") or None
rows = users_svc.list_users(q=q, is_active=True, limit=200, offset=0)[0]
# Sort by email for predictable rendering and stable e2e selectors.
return jsonify(
{
"items": [
{
"id": str(u.id),
"email": u.email,
"display_name": u.display_name,
}
for u in sorted(
(u for u in rows if u.deleted_at is None),
key=lambda x: x.email,
)
]
}
)
@bp.get("") @bp.get("")
@require_auth @require_auth
@require_perm("user.read") @require_perm("user.read")
@@ -116,7 +147,7 @@ def update_user(user_id: str):
try: try:
payload = UpdateUserPayload.model_validate(raw) payload = UpdateUserPayload.model_validate(raw)
except ValidationError as e: 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. # Distinguish "key absent" (no change) from "key=null" (clear) for display_name.
display_name_unset = "display_name" not in raw display_name_unset = "display_name" not in raw
@@ -169,7 +200,7 @@ def set_groups(user_id: str):
try: try:
payload = SetGroupsPayload.model_validate(request.get_json(silent=True) or {}) payload = SetGroupsPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e: 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: try:
u = users_svc.set_user_groups(uid, payload.group_ids) u = users_svc.set_user_groups(uid, payload.group_ids)
except users_svc.UserNotFound: except users_svc.UserNotFound:

View File

@@ -5,13 +5,18 @@ from __future__ import annotations
from flask import Blueprint from flask import Blueprint
from app.api.auth import bp as auth_bp 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.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.groups import bp as groups_bp
from app.api.health import bp as health_bp from app.api.health import bp as health_bp
from app.api.invitations import bp as invitations_bp from app.api.invitations import bp as invitations_bp
from app.api.missions import bp as missions_bp
from app.api.mitre import bp as mitre_bp from app.api.mitre import bp as mitre_bp
from app.api.permissions import bp as permissions_bp from app.api.permissions import bp as permissions_bp
from app.api.scenario_templates import bp as scenario_templates_bp
from app.api.setup import bp as setup_bp from app.api.setup import bp as setup_bp
from app.api.test_templates import bp as test_templates_bp
from app.api.users import bp as users_bp from app.api.users import bp as users_bp
bp = Blueprint("v1", __name__, url_prefix="/api/v1") bp = Blueprint("v1", __name__, url_prefix="/api/v1")
@@ -24,3 +29,8 @@ bp.register_blueprint(users_bp)
bp.register_blueprint(groups_bp) bp.register_blueprint(groups_bp)
bp.register_blueprint(permissions_bp) bp.register_blueprint(permissions_bp)
bp.register_blueprint(mitre_bp) 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)

View File

@@ -16,6 +16,7 @@ from app.core.install_token import (
from app.core.logging import configure_logging from app.core.logging import configure_logging
from app.core.rate_limit import limiter from app.core.rate_limit import limiter
from app.services.bootstrap import ensure_system_groups from app.services.bootstrap import ensure_system_groups
from app.services.detection_levels import seed_detection_levels
from app.services.permissions_seed import seed_all as seed_permissions_and_bindings from app.services.permissions_seed import seed_all as seed_permissions_and_bindings
@@ -29,6 +30,7 @@ def _try_bootstrap_at_boot(log: logging.Logger) -> None:
try: try:
ensure_system_groups() ensure_system_groups()
seed_permissions_and_bindings() seed_permissions_and_bindings()
seed_detection_levels()
token = ensure_install_token() token = ensure_install_token()
if token is not None: if token is not None:
log_install_token_banner(token) log_install_token_banner(token)

View File

@@ -212,12 +212,38 @@ class MissionTest(Base, UuidPkMixin, TimestampMixin, SoftDeleteMixin):
ForeignKey("detection_levels.id", ondelete="SET NULL"), ForeignKey("detection_levels.id", ondelete="SET NULL"),
nullable=True, nullable=True,
) )
# Post-M7 blue-side review fields (cf. user feedback 2026-05-15). The
# blue team historically maintained these in an Excel sheet — log source,
# raw SIEM excerpt, plus a small cyber-incident sub-record. All five are
# gated by `mission.write_blue_fields` at the service layer.
blue_log_source: Mapped[str | None] = mapped_column(String(120), nullable=True)
blue_siem_logs: Mapped[str | None] = mapped_column(Text, nullable=True)
blue_incident_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
blue_incident_number: Mapped[str | None] = mapped_column(
String(120), nullable=True
)
blue_incident_recipient_email: Mapped[str | None] = mapped_column(
String(255), nullable=True
)
category_id: Mapped[uuid.UUID | None] = mapped_column( category_id: Mapped[uuid.UUID | None] = mapped_column(
Uuid(as_uuid=True), Uuid(as_uuid=True),
ForeignKey("mission_categories.id", ondelete="SET NULL"), ForeignKey("mission_categories.id", ondelete="SET NULL"),
nullable=True, nullable=True,
) )
# --- Activity tracking (M7) ---
# Last user who wrote any red/blue field, flipped state, or uploaded
# evidence on this test. Used by the polling activity endpoint to drive
# the "modified by X Ns ago" badge. FK ON DELETE SET NULL so removing a
# user retains the history (the badge falls back to "<deleted>").
last_actor_id: Mapped[uuid.UUID | None] = mapped_column(
Uuid(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
scenario: Mapped[MissionScenario] = relationship(back_populates="tests") scenario: Mapped[MissionScenario] = relationship(back_populates="tests")
mitre_tags: Mapped[list["MissionTestMitreTag"]] = relationship( mitre_tags: Mapped[list["MissionTestMitreTag"]] = relationship(
back_populates="mission_test", back_populates="mission_test",
@@ -236,6 +262,7 @@ class MissionTest(Base, UuidPkMixin, TimestampMixin, SoftDeleteMixin):
), ),
UniqueConstraint("scenario_id", "position", name="uq_mission_tests_position"), UniqueConstraint("scenario_id", "position", name="uq_mission_tests_position"),
Index("ix_mission_tests_state", "state"), Index("ix_mission_tests_state", "state"),
Index("ix_mission_tests_updated_at", "updated_at"),
Index( Index(
"ix_mission_tests_active", "ix_mission_tests_active",
"deleted_at", "deleted_at",

View File

@@ -0,0 +1,140 @@
"""Detection-level taxonomy.
The 4 default levels are seeded at boot. M7 exposes read-only access so the
blue side of a mission test can pick a level; M8 will add CRUD.
The seed is idempotent and additive: rows whose `key` already exists are left
alone (operators may have renamed labels). Only missing keys are inserted.
"""
from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass
from sqlalchemy import select
from app.db.session import session_scope
from app.models.setting import DetectionLevel
log = logging.getLogger("metamorph.detection_levels")
@dataclass(frozen=True)
class DetectionLevelView:
id: uuid.UUID
key: str
label_fr: str
label_en: str
color_token: str
position: int
is_default: bool
is_system: bool
@dataclass(frozen=True)
class _DefaultLevel:
key: str
label_fr: str
label_en: str
color_token: str
position: int
is_default: bool
# Seed catalogue. Colors map onto the design-system accents (cf. tasks/design.md).
DEFAULT_LEVELS: tuple[_DefaultLevel, ...] = (
_DefaultLevel(
key="detected_blocked",
label_fr="Bloqué",
label_en="Blocked",
color_token="red",
position=0,
is_default=False,
),
_DefaultLevel(
key="detected_alert",
label_fr="Alerte détectée",
label_en="Alert detected",
color_token="orange",
position=1,
is_default=False,
),
_DefaultLevel(
key="logged_only",
label_fr="Loggé uniquement",
label_en="Logged only",
color_token="yellow",
position=2,
is_default=False,
),
_DefaultLevel(
key="not_detected",
label_fr="Non détecté",
label_en="Not detected",
color_token="rose",
position=3,
is_default=True,
),
)
def _to_view(r: DetectionLevel) -> DetectionLevelView:
return DetectionLevelView(
id=r.id,
key=r.key,
label_fr=r.label_fr,
label_en=r.label_en,
color_token=r.color_token,
position=r.position,
is_default=r.is_default,
is_system=r.is_system,
)
def seed_detection_levels() -> dict[str, int]:
"""Insert any default level whose `key` is missing. Idempotent.
We never mutate existing rows here — operators are free to rename labels
or change the default flag. Adding a new entry to `DEFAULT_LEVELS` in a
future release will surface it on the next boot.
"""
created = 0
with session_scope() as s:
existing_keys = set(s.scalars(select(DetectionLevel.key)).all())
for lvl in DEFAULT_LEVELS:
if lvl.key in existing_keys:
continue
s.add(
DetectionLevel(
key=lvl.key,
label_fr=lvl.label_fr,
label_en=lvl.label_en,
color_token=lvl.color_token,
position=lvl.position,
is_default=lvl.is_default,
is_system=True,
)
)
created += 1
# `created` is a reserved LogRecord attribute (timestamp) — use a prefixed key.
log.info(
"metamorph.detection_levels.seeded",
extra={"rows_created": created, "total": len(DEFAULT_LEVELS)},
)
return {"created": created, "total": len(DEFAULT_LEVELS)}
def list_detection_levels() -> list[DetectionLevelView]:
with session_scope() as s:
rows = s.scalars(
select(DetectionLevel).order_by(DetectionLevel.position, DetectionLevel.key)
).all()
return [_to_view(r) for r in rows]
def get_detection_level(level_id: uuid.UUID) -> DetectionLevelView | None:
with session_scope() as s:
r = s.get(DetectionLevel, level_id)
return _to_view(r) if r is not None else None

View File

@@ -0,0 +1,391 @@
"""Blue-side evidence storage service (M7).
Files live under `${EVIDENCE_DIR}/<mission_id>/<test_id>/<sha256><ext>`.
The path is content-addressed: re-uploading byte-identical content into the
same test reuses the existing file on disk and inserts a fresh row (so we
keep history of who uploaded what without duplicating bytes).
The upload pipeline streams to a tmpfile inside the same per-test directory
(`atomic move` semantics on POSIX), computing the SHA256 chunk-by-chunk and
aborting when the byte count crosses `MAX_BYTES`. We refuse files whose
extension is not in the whitelist; MIME is also validated but with a more
permissive fallback (browsers and `file(1)` disagree on `.evtx`).
Soft delete only flips `deleted_at`. The bytes are kept on disk so a future
admin `/admin/purge` (M12) can remove them physically. Until then, the path
is still queryable but the API hides it from non-admins.
"""
from __future__ import annotations
import hashlib
import logging
import os
import re
import tempfile
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import BinaryIO
from app.core.config import settings
from app.db.session import session_scope
from app.models.auth import User
from app.models.evidence import EvidenceFile
from app.models.mission import MissionScenario, MissionTest
from app.services.mission_tests import (
EvidenceView,
_ensure_mission_visible,
_load_test,
_to_evidence_view,
_touch,
)
log = logging.getLogger("metamorph.evidence")
# --------------------------------------------------------------------------- #
# Validation rules
# --------------------------------------------------------------------------- #
MAX_BYTES: int = 25 * 1024 * 1024 # 25 MB per spec §M7
# Filename extensions accepted at the upload boundary. Lowercased; the upload
# handler downcases the original filename's tail before comparing.
ALLOWED_EXTS: frozenset[str] = frozenset(
{
".png",
".jpg",
".jpeg",
".pdf",
".txt",
".log",
".json",
".csv",
".evtx",
".zip",
}
)
# Accept a permissive MIME set so common browser/OS combos clear validation.
# `.evtx` is canonically `application/octet-stream`; some Windows clients send
# `application/x-msexcel` for csv; etc. We trust the extension first and use
# the MIME as a secondary signal.
ALLOWED_MIMES: frozenset[str] = frozenset(
{
"image/png",
"image/jpeg",
"image/jpg",
"application/pdf",
"text/plain",
"text/csv",
"application/csv",
"application/json",
"application/octet-stream",
"application/zip",
"application/x-zip-compressed",
}
)
# --------------------------------------------------------------------------- #
# Exceptions
# --------------------------------------------------------------------------- #
class EvidenceNotFound(Exception):
"""Evidence row missing, soft-deleted, or not visible to the viewer."""
class EvidenceValidationError(Exception):
"""Extension/MIME/size invalid at the upload boundary."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
class EvidenceStorageError(Exception):
"""Disk I/O failure during upload — bytes left on disk are best-effort cleaned."""
# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def _evidence_dir() -> Path:
return Path(settings.EVIDENCE_DIR).resolve()
def _test_dir(mission_id: uuid.UUID, test_id: uuid.UUID) -> Path:
root = _evidence_dir()
# Refuse to lay down per-mission directories at filesystem roots — an
# operator who set EVIDENCE_DIR=/ would otherwise write into / itself.
if root in (Path("/"), Path(root.anchor)):
raise EvidenceStorageError("EVIDENCE_DIR cannot be a filesystem root")
return root / str(mission_id) / str(test_id)
def _sniff_ext(filename: str) -> str:
"""Lowercased extension including the leading dot, or '' if none."""
name = filename.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
if "." not in name:
return ""
return "." + name.rsplit(".", 1)[-1].lower()
def _validate_meta(filename: str, mime: str) -> str:
ext = _sniff_ext(filename)
if not ext:
raise EvidenceValidationError(
"missing_extension", "filename must have an extension"
)
if ext not in ALLOWED_EXTS:
raise EvidenceValidationError(
"unsupported_extension", f"extension {ext!r} is not allowed"
)
normalised_mime = (mime or "application/octet-stream").lower().split(";", 1)[0].strip()
if normalised_mime not in ALLOWED_MIMES:
raise EvidenceValidationError(
"unsupported_mime", f"mime {normalised_mime!r} is not allowed"
)
return ext
def _stream_to_tmpfile(
src: BinaryIO, target_dir: Path
) -> tuple[Path, str, int]:
"""Stream the upload into a tmpfile under `target_dir`, capping size.
Returns (tmp_path, sha256_hex, total_bytes). Raises
`EvidenceValidationError("too_large", …)` once the cumulative count goes
above `MAX_BYTES`. The tmpfile is *always* removed on error.
"""
target_dir.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(prefix=".upload-", dir=str(target_dir))
tmp_path = Path(tmp_name)
hasher = hashlib.sha256()
total = 0
try:
with os.fdopen(fd, "wb") as fh:
while True:
chunk = src.read(64 * 1024)
if not chunk:
break
total += len(chunk)
if total > MAX_BYTES:
raise EvidenceValidationError(
"too_large",
f"file exceeds the {MAX_BYTES} byte limit",
)
hasher.update(chunk)
fh.write(chunk)
return tmp_path, hasher.hexdigest(), total
except Exception:
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
raise
# --------------------------------------------------------------------------- #
# Public API
# --------------------------------------------------------------------------- #
def add_evidence(
mission_id: uuid.UUID,
test_id: uuid.UUID,
*,
file_stream: BinaryIO,
original_filename: str,
mime: str,
viewer_id: uuid.UUID,
viewer_is_admin: bool,
) -> EvidenceView:
"""Persist the upload and return a view of the new evidence row.
Pre-conditions:
- The caller already verified that the viewer holds `mission.write_blue_fields`.
- Mission + test visibility is enforced here (404, not 403).
Disk layout:
${EVIDENCE_DIR}/<mission_id>/<test_id>/<sha256><ext>
"""
ext = _validate_meta(original_filename, mime)
target_dir = _test_dir(mission_id, test_id)
# Visibility/existence check BEFORE we touch disk.
with session_scope() as s:
_ensure_mission_visible(s, mission_id, viewer_id, viewer_is_admin)
_load_test(s, mission_id, test_id) # raises MissionTestNotFound on miss
tmp_path, sha256, size_bytes = _stream_to_tmpfile(file_stream, target_dir)
# Defence in depth — the hash comes from hashlib but if any caller ever
# passes pre-computed bytes we want to fail loudly rather than write to a
# path like `..something.evtx`.
if not re.fullmatch(r"[0-9a-f]{64}", sha256):
tmp_path.unlink(missing_ok=True)
raise EvidenceStorageError("computed sha256 is malformed")
final_path = target_dir / f"{sha256}{ext}"
try:
if final_path.exists():
# Same bytes already on disk — drop the tmp and reuse the canonical path.
tmp_path.unlink(missing_ok=True)
else:
# `os.replace` is the atomic rename primitive on POSIX (and the
# documented atomic rename on Windows when src/dst live on the
# same volume). We stage the tmpfile in `target_dir` so it
# always shares a filesystem with the destination.
os.replace(str(tmp_path), str(final_path))
except OSError as e:
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
log.warning(
"metamorph.evidence.storage_failed",
extra={"mission_id": str(mission_id), "test_id": str(test_id), "error": str(e)},
)
raise EvidenceStorageError(str(e)) from e
with session_scope() as s:
# Re-load + double-check visibility (defence in depth: the membership
# set could have changed between the pre-check and now).
_ensure_mission_visible(s, mission_id, viewer_id, viewer_is_admin)
test = _load_test(s, mission_id, test_id)
ev = EvidenceFile(
mission_test_id=test.id,
sha256=sha256,
mime=(mime or "application/octet-stream").lower().split(";", 1)[0].strip(),
size_bytes=size_bytes,
storage_path=str(final_path),
original_filename=original_filename[:255],
uploaded_by_user_id=viewer_id,
uploaded_at=datetime.now(tz=timezone.utc),
)
s.add(ev)
_touch(test, viewer_id)
s.flush()
s.refresh(ev)
uploader = s.get(User, viewer_id)
log.info(
"metamorph.evidence.added",
extra={
"evidence_id": str(ev.id),
"mission_id": str(mission_id),
"test_id": str(test_id),
"sha256": sha256,
"size_bytes": size_bytes,
"mime": ev.mime,
},
)
return _to_evidence_view(ev, uploader)
def _resolve_evidence_chain(
s, evidence_id: uuid.UUID
) -> tuple[EvidenceFile, MissionTest, MissionScenario] | None:
"""Walk evidence → test → scenario, returning None if any link is missing or deleted."""
ev = s.get(EvidenceFile, evidence_id)
if ev is None or ev.deleted_at is not None:
return None
test = s.get(MissionTest, ev.mission_test_id)
if test is None or test.deleted_at is not None:
return None
scenario = s.get(MissionScenario, test.scenario_id)
if scenario is None or scenario.deleted_at is not None:
return None
return ev, test, scenario
def get_evidence(
evidence_id: uuid.UUID,
*,
viewer_id: uuid.UUID,
viewer_is_admin: bool,
) -> EvidenceView:
"""Read a single evidence record. Membership-aware (404 on miss/forbidden)."""
with session_scope() as s:
chain = _resolve_evidence_chain(s, evidence_id)
if chain is None:
raise EvidenceNotFound()
ev, _, scenario = chain
try:
_ensure_mission_visible(s, scenario.mission_id, viewer_id, viewer_is_admin)
except Exception as e:
raise EvidenceNotFound() from e
uploader = s.get(User, ev.uploaded_by_user_id) if ev.uploaded_by_user_id else None
return _to_evidence_view(ev, uploader)
def get_evidence_for_download(
evidence_id: uuid.UUID,
*,
viewer_id: uuid.UUID,
viewer_is_admin: bool,
) -> tuple[EvidenceView, Path]:
"""Return view + on-disk path. Raises EvidenceNotFound if the bytes are gone."""
with session_scope() as s:
chain = _resolve_evidence_chain(s, evidence_id)
if chain is None:
raise EvidenceNotFound()
ev, _, scenario = chain
try:
_ensure_mission_visible(s, scenario.mission_id, viewer_id, viewer_is_admin)
except Exception as e:
raise EvidenceNotFound() from e
uploader = s.get(User, ev.uploaded_by_user_id) if ev.uploaded_by_user_id else None
view = _to_evidence_view(ev, uploader)
path = Path(ev.storage_path)
if not path.exists():
log.warning(
"metamorph.evidence.bytes_missing",
extra={"evidence_id": str(evidence_id), "path": str(path)},
)
raise EvidenceNotFound()
return view, path
def soft_delete_evidence(
evidence_id: uuid.UUID,
*,
viewer_id: uuid.UUID,
viewer_is_admin: bool,
) -> None:
"""Mark an evidence row deleted. Disk bytes are kept until admin purge (M12)."""
with session_scope() as s:
chain = _resolve_evidence_chain(s, evidence_id)
if chain is None:
raise EvidenceNotFound()
ev, test, scenario = chain
try:
_ensure_mission_visible(s, scenario.mission_id, viewer_id, viewer_is_admin)
except Exception as e:
raise EvidenceNotFound() from e
ev.deleted_at = datetime.now(tz=timezone.utc)
_touch(test, viewer_id)
s.flush()
log.info(
"metamorph.evidence.soft_deleted",
extra={"evidence_id": str(evidence_id), "mission_id": str(scenario.mission_id)},
)
__all__ = [
"MAX_BYTES",
"ALLOWED_EXTS",
"ALLOWED_MIMES",
"EvidenceNotFound",
"EvidenceValidationError",
"EvidenceStorageError",
"add_evidence",
"get_evidence",
"get_evidence_for_download",
"soft_delete_evidence",
]

View File

@@ -0,0 +1,740 @@
"""Per-mission-test execution service (M7).
Where M6 builds the snapshot, M7 brings the test to life:
- Red side: command, output, comment, mark-executed (auto + override).
- Blue side: detection level, comment, evidence (delegated to `evidence.py`).
- State machine: pending↔skipped/blocked, executed→reviewed_by_blue.
The caller is responsible for telling us which side it has perms for via
`has_red_perm` / `has_blue_perm`. The service refuses field/state writes that
require a side the caller does not hold, raising `MissingFieldPermission`.
Mission membership is enforced here (404 not 403) consistent with M6 to
prevent existence leaks.
"""
from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.db.session import session_scope
from app.db.types import MISSION_TEST_STATES
from app.models.auth import User
from app.models.evidence import EvidenceFile
from app.models.mission import (
Mission,
MissionScenario,
MissionTest,
)
from app.models.setting import DetectionLevel
from app.services.missions import (
MissionNotFound,
_is_member,
)
log = logging.getLogger("metamorph.mission_tests")
_UNSET: Any = object()
# --------------------------------------------------------------------------- #
# State machine
# --------------------------------------------------------------------------- #
#
# Per spec §M7: pending↔skipped/blocked, executed→reviewed_by_blue.
# We also allow `executed → pending` and `reviewed_by_blue → executed` so a
# red/blue user can revert a misclick without admin intervention. Soft-delete
# is the only forward-only sink (handled outside this service).
#
_VALID_TRANSITIONS: dict[str, frozenset[str]] = {
"pending": frozenset({"executed", "skipped", "blocked"}),
"executed": frozenset({"reviewed_by_blue", "pending"}),
"reviewed_by_blue": frozenset({"executed"}),
"skipped": frozenset({"pending"}),
"blocked": frozenset({"pending"}),
}
# Which side "owns" each transition for permission purposes:
# "red" → requires mission.write_red_fields
# "blue" → requires mission.write_blue_fields
# "any" → either side suffices
_TRANSITION_SIDE: dict[tuple[str, str], str] = {
("pending", "executed"): "red",
("pending", "skipped"): "any",
("pending", "blocked"): "any",
("executed", "reviewed_by_blue"): "blue",
("executed", "pending"): "red",
("reviewed_by_blue", "executed"): "blue",
("skipped", "pending"): "any",
("blocked", "pending"): "any",
}
# Same-state idempotent POSTs are still gated: a user replaying a "mark
# executed" must still hold red perms even if the row is already executed.
# This map answers "if you wanted to BE in state X, which side originally
# brought you here?" — and therefore what perm a no-op repeat should require.
_IDEMPOTENT_SIDE: dict[str, str] = {
"executed": "red",
"reviewed_by_blue": "blue",
"pending": "any",
"skipped": "any",
"blocked": "any",
}
# --------------------------------------------------------------------------- #
# Exceptions
# --------------------------------------------------------------------------- #
class MissionTestNotFound(Exception):
"""Test missing, soft-deleted, or not under the given mission/viewer."""
class InvalidTestTransition(Exception):
pass
class MissingFieldPermission(Exception):
"""Caller tried to write a field requiring a side perm they do not hold."""
class InvalidTestPayload(Exception):
"""Generic validation error (bad dates, unknown detection level, ...)."""
# --------------------------------------------------------------------------- #
# Views
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class EvidenceView:
id: uuid.UUID
mission_test_id: uuid.UUID
sha256: str
mime: str
size_bytes: int
original_filename: str
uploaded_by_user_id: uuid.UUID | None
uploaded_by_email: str | None
uploaded_by_display_name: str | None
uploaded_at: datetime
created_at: datetime
@dataclass(frozen=True)
class MissionTestMitreTagView:
kind: str
external_id: str
name: str
url: str | None
@dataclass(frozen=True)
class MissionTestDetailView:
id: uuid.UUID
mission_id: uuid.UUID
scenario_id: uuid.UUID
position: int
snapshot_name: str
snapshot_description: str | None
snapshot_objective: str | None
snapshot_procedure_md: str | None
snapshot_prerequisites_md: str | None
snapshot_expected_red_md: str | None
snapshot_expected_blue_md: str | None
snapshot_opsec_level: str
snapshot_tags: list[str]
snapshot_expected_iocs: list[str]
state: str
executed_at: datetime | None
executed_at_overridden: bool
red_command: str | None
red_output: str | None
red_comment_md: str | None
blue_comment_md: str | None
detection_level_id: uuid.UUID | None
detection_level_key: str | None
# Post-M7 blue review fields (cf. user feedback 2026-05-15).
blue_log_source: str | None
blue_siem_logs: str | None
blue_incident_at: datetime | None
blue_incident_number: str | None
blue_incident_recipient_email: str | None
last_actor_id: uuid.UUID | None
last_actor_email: str | None
last_actor_display_name: str | None
updated_at: datetime
mitre_tags: list[MissionTestMitreTagView]
evidence: list[EvidenceView]
@dataclass(frozen=True)
class ActivityEntryView:
test_id: uuid.UUID
scenario_id: uuid.UUID
state: str
updated_at: datetime
last_actor_id: uuid.UUID | None
last_actor_email: str | None
last_actor_display_name: str | None
# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def _opt_md(value: Any) -> str | None:
"""Normalise a markdown/text input: strip-then-collapse-to-None on empty."""
if value is None:
return None
if not isinstance(value, str):
raise InvalidTestPayload("text field must be a string")
v = value.strip()
return v or None
def _opt_cmd(value: Any) -> str | None:
"""Same as `_opt_md` but preserves trailing/leading whitespace inside the body."""
if value is None:
return None
if not isinstance(value, str):
raise InvalidTestPayload("text field must be a string")
return value if value != "" else None
def _ensure_state(value: str) -> str:
if value not in MISSION_TEST_STATES:
raise InvalidTestPayload(f"state must be one of {MISSION_TEST_STATES}")
return value
def _load_test(
s: Session, mission_id: uuid.UUID, test_id: uuid.UUID
) -> MissionTest:
"""Fetch a live mission_test guarded by mission id, raising on misses."""
stmt = (
select(MissionTest)
.join(MissionScenario, MissionTest.scenario_id == MissionScenario.id)
.options(selectinload(MissionTest.mitre_tags))
.where(
MissionTest.id == test_id,
MissionScenario.mission_id == mission_id,
MissionTest.deleted_at.is_(None),
MissionScenario.deleted_at.is_(None),
)
)
row = s.scalars(stmt).one_or_none()
if row is None:
raise MissionTestNotFound()
return row
def _ensure_mission_visible(
s: Session, mission_id: uuid.UUID, viewer_id: uuid.UUID, viewer_is_admin: bool
) -> Mission:
"""Confirm the mission exists, is live, and is visible to the viewer.
Returns the Mission row for reuse (e.g. to log the parent name in audit
extras). Raises `MissionNotFound` on any miss — we mirror M6's membership
visibility contract: leaking existence via 403 is forbidden.
"""
m = s.get(Mission, mission_id)
if m is None or m.deleted_at is not None:
raise MissionNotFound()
if not viewer_is_admin and not _is_member(s, mission_id, viewer_id):
raise MissionNotFound()
return m
def _to_evidence_view(ev: EvidenceFile, uploader: User | None) -> EvidenceView:
return EvidenceView(
id=ev.id,
mission_test_id=ev.mission_test_id,
sha256=ev.sha256,
mime=ev.mime,
size_bytes=ev.size_bytes,
original_filename=ev.original_filename,
uploaded_by_user_id=ev.uploaded_by_user_id,
uploaded_by_email=uploader.email if uploader is not None else None,
uploaded_by_display_name=uploader.display_name if uploader is not None else None,
uploaded_at=ev.uploaded_at,
created_at=ev.created_at,
)
def _load_evidence_for_test(s: Session, test_id: uuid.UUID) -> list[EvidenceView]:
rows = s.scalars(
select(EvidenceFile)
.where(
EvidenceFile.mission_test_id == test_id,
EvidenceFile.deleted_at.is_(None),
)
.order_by(EvidenceFile.uploaded_at.asc(), EvidenceFile.id.asc())
).all()
if not rows:
return []
uploader_ids = {r.uploaded_by_user_id for r in rows if r.uploaded_by_user_id}
uploaders: dict[uuid.UUID, User] = {}
if uploader_ids:
uploaders = {
u.id: u
for u in s.scalars(
select(User).where(User.id.in_(uploader_ids))
).all()
}
return [
_to_evidence_view(r, uploaders.get(r.uploaded_by_user_id) if r.uploaded_by_user_id else None)
for r in rows
]
def _to_detail_view(
s: Session, mission_id: uuid.UUID, test: MissionTest
) -> MissionTestDetailView:
# Batch the two FK lookups (last actor + detection level) into a single
# round trip instead of two `s.get` calls — every PUT/transition returns
# the detail view, so this matters.
last_actor_email: str | None = None
last_actor_display_name: str | None = None
level_key: str | None = None
if test.last_actor_id is not None:
actor = s.execute(
select(User.email, User.display_name).where(User.id == test.last_actor_id)
).first()
if actor is not None:
last_actor_email, last_actor_display_name = actor.email, actor.display_name
if test.detection_level_id is not None:
level_key = s.scalar(
select(DetectionLevel.key).where(DetectionLevel.id == test.detection_level_id)
)
tag_views = [
MissionTestMitreTagView(
kind=tag.mitre_kind,
external_id=tag.mitre_external_id,
name=tag.mitre_name,
url=tag.mitre_url,
)
for tag in sorted(
test.mitre_tags, key=lambda t: (t.mitre_kind, t.mitre_external_id)
)
]
return MissionTestDetailView(
id=test.id,
mission_id=mission_id,
scenario_id=test.scenario_id,
position=test.position,
snapshot_name=test.snapshot_name,
snapshot_description=test.snapshot_description,
snapshot_objective=test.snapshot_objective,
snapshot_procedure_md=test.snapshot_procedure_md,
snapshot_prerequisites_md=test.snapshot_prerequisites_md,
snapshot_expected_red_md=test.snapshot_expected_red_md,
snapshot_expected_blue_md=test.snapshot_expected_blue_md,
snapshot_opsec_level=test.snapshot_opsec_level,
snapshot_tags=list(test.snapshot_tags or []),
snapshot_expected_iocs=list(test.snapshot_expected_iocs or []),
state=test.state,
executed_at=test.executed_at,
executed_at_overridden=test.executed_at_overridden,
red_command=test.red_command,
red_output=test.red_output,
red_comment_md=test.red_comment_md,
blue_comment_md=test.blue_comment_md,
detection_level_id=test.detection_level_id,
detection_level_key=level_key,
blue_log_source=test.blue_log_source,
blue_siem_logs=test.blue_siem_logs,
blue_incident_at=test.blue_incident_at,
blue_incident_number=test.blue_incident_number,
blue_incident_recipient_email=test.blue_incident_recipient_email,
last_actor_id=test.last_actor_id,
last_actor_email=last_actor_email,
last_actor_display_name=last_actor_display_name,
updated_at=test.updated_at,
mitre_tags=tag_views,
evidence=_load_evidence_for_test(s, test.id),
)
def _touch(test: MissionTest, actor_id: uuid.UUID) -> None:
"""Stamp the actor + bump the activity clock.
`updated_at` is auto-managed by SQLAlchemy's `onupdate=func.now()` mixin
only when at least one mapped attribute changes. Assigning `last_actor_id`
triggers that, even when the actor is the same as the previous one
(Pydantic-clean payloads still flush the assignment).
"""
test.last_actor_id = actor_id
# --------------------------------------------------------------------------- #
# Public API — read
# --------------------------------------------------------------------------- #
def get_mission_test(
mission_id: uuid.UUID,
test_id: uuid.UUID,
*,
viewer_id: uuid.UUID,
viewer_is_admin: bool,
) -> MissionTestDetailView:
with session_scope() as s:
_ensure_mission_visible(s, mission_id, viewer_id, viewer_is_admin)
test = _load_test(s, mission_id, test_id)
return _to_detail_view(s, mission_id, test)
def list_activity_since(
mission_id: uuid.UUID,
*,
viewer_id: uuid.UUID,
viewer_is_admin: bool,
since: datetime | None = None,
limit: int = 200,
) -> list[ActivityEntryView]:
"""List mission_tests whose `updated_at > since`, freshest first.
Drives the "modified by X Ns ago" badge on the per-test page. Soft-deleted
tests/scenarios are excluded so a deletion does not appear as activity.
"""
with session_scope() as s:
_ensure_mission_visible(s, mission_id, viewer_id, viewer_is_admin)
stmt = (
select(MissionTest, MissionScenario)
.join(MissionScenario, MissionTest.scenario_id == MissionScenario.id)
.where(
MissionScenario.mission_id == mission_id,
MissionTest.deleted_at.is_(None),
MissionScenario.deleted_at.is_(None),
)
.order_by(MissionTest.updated_at.desc(), MissionTest.id.asc())
.limit(max(1, min(limit, 500)))
)
if since is not None:
stmt = stmt.where(MissionTest.updated_at > since)
rows = s.execute(stmt).all()
actor_ids = {r.MissionTest.last_actor_id for r in rows if r.MissionTest.last_actor_id}
actors: dict[uuid.UUID, User] = {}
if actor_ids:
actors = {
u.id: u
for u in s.scalars(select(User).where(User.id.in_(actor_ids))).all()
}
out: list[ActivityEntryView] = []
for row in rows:
t = row.MissionTest
actor = actors.get(t.last_actor_id) if t.last_actor_id else None
out.append(
ActivityEntryView(
test_id=t.id,
scenario_id=t.scenario_id,
state=t.state,
updated_at=t.updated_at,
last_actor_id=t.last_actor_id,
last_actor_email=actor.email if actor else None,
last_actor_display_name=actor.display_name if actor else None,
)
)
return out
# --------------------------------------------------------------------------- #
# Public API — write
# --------------------------------------------------------------------------- #
# Side membership for each writable field (mirror of the spec's red/blue split).
_RED_FIELDS = {"red_command", "red_output", "red_comment_md",
"executed_at", "executed_at_overridden"}
_BLUE_FIELDS = {
"blue_comment_md",
"detection_level_id",
"blue_log_source",
"blue_siem_logs",
"blue_incident_at",
"blue_incident_number",
"blue_incident_recipient_email",
}
def _classify_fields(touched: set[str]) -> tuple[bool, bool]:
"""Return (needs_red, needs_blue) for the set of field names being written."""
return (
bool(touched & _RED_FIELDS),
bool(touched & _BLUE_FIELDS),
)
def update_mission_test_fields(
mission_id: uuid.UUID,
test_id: uuid.UUID,
*,
viewer_id: uuid.UUID,
viewer_is_admin: bool,
has_red_perm: bool,
has_blue_perm: bool,
red_command: Any = _UNSET,
red_output: Any = _UNSET,
red_comment_md: Any = _UNSET,
blue_comment_md: Any = _UNSET,
detection_level_id: Any = _UNSET,
executed_at: Any = _UNSET,
executed_at_overridden: Any = _UNSET,
blue_log_source: Any = _UNSET,
blue_siem_logs: Any = _UNSET,
blue_incident_at: Any = _UNSET,
blue_incident_number: Any = _UNSET,
blue_incident_recipient_email: Any = _UNSET,
) -> MissionTestDetailView:
"""Patch any subset of the red/blue annotation fields.
Field-level perm enforcement happens *before* any write so a forbidden
field never even lands in the SQL transaction (cleaner audit logs).
"""
touched: set[str] = set()
if red_command is not _UNSET:
touched.add("red_command")
if red_output is not _UNSET:
touched.add("red_output")
if red_comment_md is not _UNSET:
touched.add("red_comment_md")
if blue_comment_md is not _UNSET:
touched.add("blue_comment_md")
if detection_level_id is not _UNSET:
touched.add("detection_level_id")
if executed_at is not _UNSET:
touched.add("executed_at")
if executed_at_overridden is not _UNSET:
touched.add("executed_at_overridden")
if blue_log_source is not _UNSET:
touched.add("blue_log_source")
if blue_siem_logs is not _UNSET:
touched.add("blue_siem_logs")
if blue_incident_at is not _UNSET:
touched.add("blue_incident_at")
if blue_incident_number is not _UNSET:
touched.add("blue_incident_number")
if blue_incident_recipient_email is not _UNSET:
touched.add("blue_incident_recipient_email")
needs_red, needs_blue = _classify_fields(touched)
if not viewer_is_admin:
if needs_red and not has_red_perm:
raise MissingFieldPermission(
"mission.write_red_fields required for red-side fields"
)
if needs_blue and not has_blue_perm:
raise MissingFieldPermission(
"mission.write_blue_fields required for blue-side fields"
)
with session_scope() as s:
_ensure_mission_visible(s, mission_id, viewer_id, viewer_is_admin)
test = _load_test(s, mission_id, test_id)
if not touched:
return _to_detail_view(s, mission_id, test)
if "red_command" in touched:
test.red_command = _opt_cmd(red_command)
if "red_output" in touched:
test.red_output = _opt_cmd(red_output)
if "red_comment_md" in touched:
test.red_comment_md = _opt_md(red_comment_md)
if "blue_comment_md" in touched:
test.blue_comment_md = _opt_md(blue_comment_md)
if "detection_level_id" in touched:
if detection_level_id is None:
test.detection_level_id = None
else:
if not isinstance(detection_level_id, uuid.UUID):
raise InvalidTestPayload("detection_level_id must be a UUID")
lvl = s.get(DetectionLevel, detection_level_id)
if lvl is None:
raise InvalidTestPayload("unknown detection_level_id")
test.detection_level_id = detection_level_id
# Post-M7 blue-side review fields — short text + long text + a small
# cyber-incident sub-record. Email is sanity-checked at the API layer
# via Pydantic; the service just normalises empty strings to NULL.
if "blue_log_source" in touched:
test.blue_log_source = _opt_md(blue_log_source)
if "blue_siem_logs" in touched:
# SIEM excerpts can legitimately have leading whitespace inside
# the body (table-like log lines), so use the command-style
# normaliser that only collapses purely-empty strings to NULL.
test.blue_siem_logs = _opt_cmd(blue_siem_logs)
if "blue_incident_at" in touched:
if blue_incident_at is not None and not isinstance(blue_incident_at, datetime):
raise InvalidTestPayload("blue_incident_at must be an ISO datetime")
test.blue_incident_at = blue_incident_at
if "blue_incident_number" in touched:
test.blue_incident_number = _opt_md(blue_incident_number)
if "blue_incident_recipient_email" in touched:
test.blue_incident_recipient_email = _opt_md(
blue_incident_recipient_email
)
if "executed_at_overridden" in touched or "executed_at" in touched:
# Editing executed_at is a red-only privilege (gated above via
# _RED_FIELDS). State auto-promotion is handled by the general
# block below; this branch just validates and applies the
# timestamp + override flag.
new_overridden = (
bool(executed_at_overridden)
if "executed_at_overridden" in touched
else test.executed_at_overridden
)
new_at = test.executed_at if "executed_at" not in touched else executed_at
if new_overridden and new_at is None:
raise InvalidTestPayload(
"executed_at_overridden=true requires a non-null executed_at"
)
if "executed_at" in touched and new_at is not None and not isinstance(new_at, datetime):
raise InvalidTestPayload("executed_at must be an ISO datetime")
test.executed_at = new_at
test.executed_at_overridden = new_overridden
# Implicit lifecycle (post-amendement 2026-05-15 bis): the explicit
# workflow is gone from the UI. A write to ANY red field implies the
# test was executed AND the review (if any) is potentially stale —
# so the state always lands on `executed`, even from `reviewed_by_blue`
# (red just changed something, blue needs to re-review). A write to
# ANY blue field on an executed test implies the blue team reviewed
# it. Note that a same-PUT red+blue (e.g. an admin filling both
# sides) flows `* → executed → reviewed_by_blue` and lands on
# `reviewed_by_blue`. The /transition endpoint stays for back-fill
# but is no longer the primary path.
red_touched = bool(touched & _RED_FIELDS)
blue_touched = bool(touched & _BLUE_FIELDS)
if red_touched:
if test.executed_at is None:
test.executed_at = datetime.now(tz=timezone.utc)
test.executed_at_overridden = False
test.state = "executed"
if blue_touched and test.state == "executed":
test.state = "reviewed_by_blue"
_touch(test, viewer_id)
s.flush()
s.refresh(test)
return _to_detail_view(s, mission_id, test)
def transition_mission_test(
mission_id: uuid.UUID,
test_id: uuid.UUID,
target_state: str,
*,
viewer_id: uuid.UUID,
viewer_is_admin: bool,
has_red_perm: bool,
has_blue_perm: bool,
) -> MissionTestDetailView:
"""Drive the test through its lifecycle and side-effect `executed_at`.
Transitioning *into* `executed` stamps `executed_at = now()` and clears
the override flag — the deliberate red-side action commits the timeline.
Transitioning *out of* `executed` (to `pending`) clears the timestamp so
a re-execution starts from a clean slate.
"""
_ensure_state(target_state)
with session_scope() as s:
_ensure_mission_visible(s, mission_id, viewer_id, viewer_is_admin)
test = _load_test(s, mission_id, test_id)
# Perm gate runs BEFORE the idempotency short-circuit. A blue-only
# user POSTing target_state="executed" while the test is already
# executed must NOT get a 200 — it would falsely advertise that they
# hold the red-side perm. We resolve the would-be transition's side
# (or, on a no-op, fall back to the source side which originally
# produced the state) and enforce it before any response shape.
allowed = _VALID_TRANSITIONS.get(test.state, frozenset())
if test.state != target_state and target_state not in allowed:
raise InvalidTestTransition(
f"cannot transition test from {test.state!r} to {target_state!r}"
)
side: str | None
if test.state == target_state:
# Idempotent path: require the perm the *forward* transition
# would have needed. For terminal-states (already executed →
# executed), this is the side that *brought* the test here.
side = _IDEMPOTENT_SIDE.get(target_state)
else:
side = _TRANSITION_SIDE.get((test.state, target_state))
if not viewer_is_admin and side is not None:
if side == "red" and not has_red_perm:
raise MissingFieldPermission(
"mission.write_red_fields required for this transition"
)
if side == "blue" and not has_blue_perm:
raise MissingFieldPermission(
"mission.write_blue_fields required for this transition"
)
if side == "any" and not (has_red_perm or has_blue_perm):
raise MissingFieldPermission(
"either mission.write_red_fields or mission.write_blue_fields "
"is required"
)
if test.state == target_state:
# Genuine no-op: idempotent 200 with the current snapshot.
return _to_detail_view(s, mission_id, test)
if target_state == "executed":
test.executed_at = datetime.now(tz=timezone.utc)
test.executed_at_overridden = False
elif target_state == "pending":
# Returning to pending wipes the execution timestamp so a re-run
# starts clean. Notes/comments are preserved (history value).
test.executed_at = None
test.executed_at_overridden = False
test.state = target_state
_touch(test, viewer_id)
s.flush()
s.refresh(test)
return _to_detail_view(s, mission_id, test)
__all__ = [
"EvidenceView",
"MissionTestDetailView",
"MissionTestMitreTagView",
"ActivityEntryView",
"MissionTestNotFound",
"InvalidTestTransition",
"MissingFieldPermission",
"InvalidTestPayload",
"get_mission_test",
"list_activity_since",
"update_mission_test_fields",
"transition_mission_test",
"_touch",
"_load_test",
"_ensure_mission_visible",
"_to_detail_view",
"_to_evidence_view",
]
# Re-export — used by `app/api/missions.py` to wire the
# 404 handling without importing the originals from M6 in two places.
__all__ += ["MissionNotFound"]

File diff suppressed because it is too large Load Diff

View File

@@ -29,7 +29,7 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Iterable from typing import Iterable
from sqlalchemy import delete, select from sqlalchemy import delete, select, text as sql_text
from app.db.session import session_scope from app.db.session import session_scope
from app.models.mitre import ( from app.models.mitre import (
@@ -59,6 +59,18 @@ MITRE_DEFAULT_SHA256 = "df520ea0775a57db7bff760145b02fed89290802913e056b7ed5970b
MITRE_BUNDLE_CACHE_PATH = Path(os.environ.get("MITRE_CACHE_DIR", "/data/mitre")) MITRE_BUNDLE_CACHE_PATH = Path(os.environ.get("MITRE_CACHE_DIR", "/data/mitre"))
MITRE_DOWNLOAD_TIMEOUT_SECONDS = 120 MITRE_DOWNLOAD_TIMEOUT_SECONDS = 120
# Hosts authorised as a source for a MITRE sync. An admin holding `mitre.sync`
# could otherwise pivot the in-container HTTP fetch to internal services
# (169.254.169.254, db, internal mirrors). Override via the `MITRE_ALLOWED_HOSTS`
# env (comma-separated) when running against a private mirror.
MITRE_ALLOWED_HOSTS: frozenset[str] = frozenset(
h.strip()
for h in os.environ.get(
"MITRE_ALLOWED_HOSTS", "raw.githubusercontent.com"
).split(",")
if h.strip()
)
# Settings keys used to expose the seed metadata to the operator UI/CLI. # Settings keys used to expose the seed metadata to the operator UI/CLI.
SETTING_LAST_SYNC = "mitre_last_sync" SETTING_LAST_SYNC = "mitre_last_sync"
SETTING_VERSION = "mitre_version" SETTING_VERSION = "mitre_version"
@@ -76,6 +88,10 @@ class MitreChecksumMismatch(MitreSeedError):
pass pass
class MitreSourceForbidden(MitreSeedError):
"""The provided source URL points to a host outside the allowlist."""
@dataclass @dataclass
class ParsedBundle: class ParsedBundle:
tactics: list[dict] = field(default_factory=list) tactics: list[dict] = field(default_factory=list)
@@ -123,6 +139,18 @@ def _is_url(source: str) -> bool:
return parsed.scheme in ("http", "https") return parsed.scheme in ("http", "https")
def _ensure_host_allowed(url: str) -> None:
"""Raise MitreSourceForbidden if the URL targets a non-allowlisted host."""
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in ("http", "https"):
raise MitreSourceForbidden(f"unsupported URL scheme: {parsed.scheme!r}")
host = (parsed.hostname or "").lower()
if host not in MITRE_ALLOWED_HOSTS:
raise MitreSourceForbidden(
f"host {host!r} not in MITRE_ALLOWED_HOSTS={sorted(MITRE_ALLOWED_HOSTS)}"
)
def _sha256_of(path: Path) -> str: def _sha256_of(path: Path) -> str:
h = hashlib.sha256() h = hashlib.sha256()
with path.open("rb") as f: with path.open("rb") as f:
@@ -132,6 +160,7 @@ def _sha256_of(path: Path) -> str:
def _download(url: str, dest: Path, *, expected_sha256: str | None = None) -> Path: def _download(url: str, dest: Path, *, expected_sha256: str | None = None) -> Path:
_ensure_host_allowed(url)
dest.parent.mkdir(parents=True, exist_ok=True) dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_suffix(dest.suffix + ".part") tmp = dest.with_suffix(dest.suffix + ".part")
log.info("metamorph.mitre.download.start", extra={"url": url, "dest": str(dest)}) log.info("metamorph.mitre.download.start", extra={"url": url, "dest": str(dest)})
@@ -331,8 +360,18 @@ def _upsert_subtechniques(
subtechniques: Iterable[dict], subtechniques: Iterable[dict],
stix_to_tech_id: dict, stix_to_tech_id: dict,
) -> tuple[int, int]: ) -> tuple[int, int]:
"""Returns (n_upserted, n_skipped_orphans).""" """Returns (n_upserted, n_skipped_orphans).
`n_upserted` is the count of rows whose state was applied (INSERT or
UPDATE) — matches Postgres upsert semantics.
"""
existing = {sb.external_id: sb for sb in s.scalars(select(MitreSubtechnique)).all()} existing = {sb.external_id: sb for sb in s.scalars(select(MitreSubtechnique)).all()}
# Pre-index techniques by external_id so the dotted-id fallback doesn't
# issue N+1 SELECTs (was a latent footgun for partial-bundle re-syncs).
parent_by_external: dict[str, object] = {
t.external_id: t.id
for t in s.scalars(select(MitreTechnique)).all()
}
n_upserted = 0 n_upserted = 0
n_skipped = 0 n_skipped = 0
for sb in subtechniques: for sb in subtechniques:
@@ -342,17 +381,7 @@ def _upsert_subtechniques(
# Fall back to the dotted external_id convention (T1003.001 → T1003). # Fall back to the dotted external_id convention (T1003.001 → T1003).
m = re.match(r"^(T\d+)\.\d+$", sb["external_id"]) m = re.match(r"^(T\d+)\.\d+$", sb["external_id"])
if m: if m:
parent_ext = m.group(1) parent_id = parent_by_external.get(m.group(1))
# We don't have a parent-by-external-id map here; query.
parent_row = next(
iter(
s.scalars(
select(MitreTechnique).where(MitreTechnique.external_id == parent_ext)
).all()
),
None,
)
parent_id = parent_row.id if parent_row else None
if parent_id is None: if parent_id is None:
log.warning( log.warning(
"metamorph.mitre.orphan_subtechnique", "metamorph.mitre.orphan_subtechnique",
@@ -433,6 +462,13 @@ def seed_mitre(
) )
with session_scope() as s: with session_scope() as s:
# Serialize concurrent /mitre/sync calls. The lock is transaction-scoped
# (released automatically at COMMIT/ROLLBACK), so a second sync arriving
# while the first is mid-DELETE+INSERT of `mitre_technique_tactics`
# blocks until the first commits. Avoids the unique-constraint race the
# code-reviewer flagged. hashtext() is stable across sessions.
s.execute(sql_text("SELECT pg_advisory_xact_lock(hashtext('mitre.seed'))"))
short_to_tactic_id, n_tactics = _upsert_tactics(s, parsed.tactics) short_to_tactic_id, n_tactics = _upsert_tactics(s, parsed.tactics)
stix_to_tech_id, n_techs, n_links = _upsert_techniques( stix_to_tech_id, n_techs, n_links = _upsert_techniques(
s, parsed.techniques, short_to_tactic_id s, parsed.techniques, short_to_tactic_id
@@ -441,10 +477,11 @@ def seed_mitre(
finished_at = datetime.now(tz=timezone.utc) finished_at = datetime.now(tz=timezone.utc)
_upsert_setting(s, SETTING_LAST_SYNC, finished_at.isoformat()) _upsert_setting(s, SETTING_LAST_SYNC, finished_at.isoformat())
# If the URL is the pinned one, we know the version; otherwise leave None. # `version` reflects the known pin only when seeded from MITRE_DEFAULT_URL;
# otherwise we explicitly clear it so /mitre/status doesn't lie about a
# stale version after a custom-URL re-sync.
version = MITRE_VERSION if source_label == MITRE_DEFAULT_URL else None version = MITRE_VERSION if source_label == MITRE_DEFAULT_URL else None
if version: _upsert_setting(s, SETTING_VERSION, version)
_upsert_setting(s, SETTING_VERSION, version)
_upsert_setting(s, SETTING_SOURCE_URL, source_label) _upsert_setting(s, SETTING_SOURCE_URL, source_label)
result = SeedResult( result = SeedResult(

View File

@@ -0,0 +1,259 @@
"""CRUD service for `scenario_templates` + their ordered test list.
Re-ordering is implemented as **full delete + re-insert** of the
`scenario_template_tests` rows. The UNIQUE (scenario_template_id, position)
constraint makes any naive position-swap fail mid-transaction; wiping the set
then re-inserting at positions 0..N-1 keeps the operation atomic and obvious.
The same test_template may legitimately appear multiple times in a scenario
(chained operations), so we key on `(scenario_id, position)`, not
`(scenario_id, test_template_id)`.
"""
from __future__ import annotations
import hashlib
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import func, or_, select, text
from sqlalchemy.orm import Session, selectinload
_UNSET: Any = object()
from app.db.session import session_scope
from app.models.template import (
ScenarioTemplate,
ScenarioTemplateTest,
TestTemplate,
)
class ScenarioTemplateNotFound(Exception):
pass
class UnknownTestTemplate(Exception):
"""Raised when a scenario references a non-existent or soft-deleted test."""
@dataclass(frozen=True)
class ScenarioTestView:
position: int
test_template_id: uuid.UUID
test_template_name: str
test_template_deleted: bool
@dataclass(frozen=True)
class ScenarioTemplateView:
id: uuid.UUID
name: str
description: str | None
tests: list[ScenarioTestView]
tests_count: int
deleted_at: datetime | None
created_at: datetime
updated_at: datetime
def _to_view(s: Session, sc: ScenarioTemplate) -> ScenarioTemplateView:
test_ids = [link.test_template_id for link in sc.tests]
name_by_id: dict[uuid.UUID, tuple[str, bool]] = {}
if test_ids:
rows = s.scalars(select(TestTemplate).where(TestTemplate.id.in_(test_ids))).all()
for row in rows:
name_by_id[row.id] = (row.name, row.deleted_at is not None)
tests = [
ScenarioTestView(
position=link.position,
test_template_id=link.test_template_id,
test_template_name=name_by_id.get(link.test_template_id, ("<missing>", True))[0],
test_template_deleted=name_by_id.get(link.test_template_id, ("<missing>", True))[1],
)
for link in sc.tests
]
return ScenarioTemplateView(
id=sc.id,
name=sc.name,
description=sc.description,
tests=tests,
tests_count=len(tests),
deleted_at=sc.deleted_at,
created_at=sc.created_at,
updated_at=sc.updated_at,
)
def _base_query():
return select(ScenarioTemplate).options(selectinload(ScenarioTemplate.tests))
def list_scenario_templates(
*,
q: str | None = None,
include_deleted: bool = False,
limit: int = 100,
offset: int = 0,
) -> tuple[list[ScenarioTemplateView], int]:
with session_scope() as s:
stmt = _base_query().order_by(ScenarioTemplate.name.asc())
count_stmt = select(func.count()).select_from(ScenarioTemplate)
if not include_deleted:
stmt = stmt.where(ScenarioTemplate.deleted_at.is_(None))
count_stmt = count_stmt.where(ScenarioTemplate.deleted_at.is_(None))
if q:
like = f"%{q.lower()}%"
cond = or_(
func.lower(ScenarioTemplate.name).like(like),
func.lower(ScenarioTemplate.description).like(like),
)
stmt = stmt.where(cond)
count_stmt = count_stmt.where(cond)
total = s.scalar(count_stmt) or 0
rows = s.scalars(stmt.limit(max(1, min(limit, 500))).offset(max(0, offset))).all()
return [_to_view(s, sc) for sc in rows], int(total)
def get_scenario_template(scenario_id: uuid.UUID, *, include_deleted: bool = False) -> ScenarioTemplateView:
with session_scope() as s:
sc = s.get(ScenarioTemplate, scenario_id)
if sc is None:
raise ScenarioTemplateNotFound()
if sc.deleted_at is not None and not include_deleted:
raise ScenarioTemplateNotFound()
return _to_view(s, sc)
def _validate_test_ids(s: Session, ids: list[uuid.UUID]) -> None:
"""Reject unknown or soft-deleted test_template ids before persisting."""
if not ids:
return
found = s.execute(
select(TestTemplate.id, TestTemplate.deleted_at).where(TestTemplate.id.in_(ids))
).all()
known = {row.id for row in found}
deleted = {row.id for row in found if row.deleted_at is not None}
missing = set(ids) - known
if missing:
raise UnknownTestTemplate(f"unknown test_template ids: {sorted(str(m) for m in missing)}")
if deleted:
raise UnknownTestTemplate(
f"cannot reference soft-deleted test_template ids: {sorted(str(d) for d in deleted)}"
)
def _opt_str(value: str | None) -> str | None:
if value is None:
return None
s = value.strip()
return s or None
def create_scenario_template(
*,
name: str,
description: str | None = None,
test_template_ids: list[uuid.UUID] | None = None,
) -> ScenarioTemplateView:
name_norm = (name or "").strip()
if not name_norm:
raise ValueError("name is required")
ids = list(test_template_ids or [])
with session_scope() as s:
_validate_test_ids(s, ids)
sc = ScenarioTemplate(
name=name_norm,
description=_opt_str(description),
)
s.add(sc)
s.flush()
for position, tid in enumerate(ids):
s.add(
ScenarioTemplateTest(
scenario_template_id=sc.id,
test_template_id=tid,
position=position,
)
)
s.flush()
s.refresh(sc)
return _to_view(s, sc)
def update_scenario_template(
scenario_id: uuid.UUID,
*,
name: str | None = None,
description: Any = _UNSET,
) -> ScenarioTemplateView:
with session_scope() as s:
sc = s.get(ScenarioTemplate, scenario_id)
if sc is None or sc.deleted_at is not None:
raise ScenarioTemplateNotFound()
if name is not None:
n = name.strip()
if not n:
raise ValueError("name cannot be empty")
sc.name = n
if description is not _UNSET:
sc.description = _opt_str(description)
s.flush()
s.refresh(sc)
return _to_view(s, sc)
def set_scenario_tests(
scenario_id: uuid.UUID,
test_template_ids: list[uuid.UUID],
) -> ScenarioTemplateView:
"""Replace the entire ordered test list. `position` becomes the index.
Acquires a per-scenario advisory lock to serialise concurrent reorders.
Without it, two parallel `PUT /scenario-templates/{id}/tests` calls would
race on the wipe-then-insert sequence and deadlock on the UNIQUE(position)
constraint under READ COMMITTED. Mirrors the M4 pattern on /mitre/sync.
"""
with session_scope() as s:
# Lock keyed on the scenario UUID — different scenarios don't block
# each other. Single bigint form so we don't have to juggle int32
# signed ranges. blake2b is used instead of Python's built-in hash()
# because the latter is randomised per-process (PYTHONHASHSEED), so
# two gunicorn workers would compute different keys for the same
# scenario and the lock wouldn't serialise across them.
digest = hashlib.blake2b(scenario_id.bytes, digest_size=8).digest()
lock_key = int.from_bytes(digest, "big", signed=True)
s.execute(
text("SELECT pg_advisory_xact_lock(CAST(:key AS bigint))"),
{"key": lock_key},
)
sc = s.get(ScenarioTemplate, scenario_id)
if sc is None or sc.deleted_at is not None:
raise ScenarioTemplateNotFound()
_validate_test_ids(s, test_template_ids)
# Wipe then re-insert. The UNIQUE(position) constraint forbids a
# naive UPDATE-swap; full-replace keeps the op atomic + readable.
for link in list(sc.tests):
s.delete(link)
s.flush()
for position, tid in enumerate(test_template_ids):
s.add(
ScenarioTemplateTest(
scenario_template_id=sc.id,
test_template_id=tid,
position=position,
)
)
s.flush()
s.refresh(sc)
return _to_view(s, sc)
def soft_delete_scenario_template(scenario_id: uuid.UUID) -> None:
with session_scope() as s:
sc = s.get(ScenarioTemplate, scenario_id)
if sc is None or sc.deleted_at is not None:
raise ScenarioTemplateNotFound()
sc.deleted_at = datetime.now(tz=timezone.utc)

View File

@@ -0,0 +1,495 @@
"""CRUD service for `test_templates` + their MITRE tags.
The MITRE tag set is **fully replaced** on every update — partial mutation of
the join rows would force the API client to track tag UUIDs they never created.
The polymorphic join (one of `tactic_id` / `technique_id` / `subtechnique_id`
populated) is owned here: callers pass `(kind, external_id)` tuples and we
resolve them to the matching MITRE row.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Iterable
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, selectinload
_UNSET: Any = object()
from app.db.session import session_scope
from app.db.types import MITRE_KINDS, OPSEC_LEVELS
from app.models.mitre import MitreSubtechnique, MitreTactic, MitreTechnique
from app.models.template import TestTemplate, TestTemplateMitreTag
class TestTemplateNotFound(Exception):
pass
class UnknownMitreTag(Exception):
"""Raised when an (kind, external_id) tuple doesn't resolve to a known MITRE row."""
@dataclass(frozen=True)
class MitreTagRef:
"""Inbound MITRE tag reference. `external_id` is the ATT&CK identifier
(TA…/T…/T….…) — we resolve it server-side, the client never sees UUIDs.
"""
kind: str # "tactic" | "technique" | "subtechnique"
external_id: str
@dataclass(frozen=True)
class MitreTagView:
kind: str
external_id: str
name: str
url: str | None
@dataclass(frozen=True)
class TestTemplateView:
id: uuid.UUID
name: str
description: str | None
objective: str | None
procedure_md: str | None
prerequisites_md: str | None
expected_result_red_md: str | None
expected_detection_blue_md: str | None
opsec_level: str
tags: list[str]
expected_iocs: list[str]
mitre_tags: list[MitreTagView]
deleted_at: datetime | None
created_at: datetime
updated_at: datetime
def _validate_opsec(value: str) -> str:
if value not in OPSEC_LEVELS:
raise ValueError(f"opsec_level must be one of {OPSEC_LEVELS}")
return value
def _normalize_string_list(values: Iterable[str] | None) -> list[str]:
if not values:
return []
seen: set[str] = set()
out: list[str] = []
for raw in values:
if not isinstance(raw, str):
raise ValueError("list items must be strings")
v = raw.strip()
if not v or v in seen:
continue
seen.add(v)
out.append(v)
return out
def _resolve_mitre_refs(s: Session, refs: list[MitreTagRef]) -> list[TestTemplateMitreTag]:
"""Translate `(kind, external_id)` pairs into half-populated join rows.
Validates that:
- `kind` is one of the supported values
- each external_id resolves to an existing MITRE row
- the combination is unique inside the payload (de-duped silently — same
tag twice is a no-op, not an error)
"""
if not refs:
return []
# Dedupe input
deduped: dict[tuple[str, str], MitreTagRef] = {}
for ref in refs:
if ref.kind not in MITRE_KINDS:
raise ValueError(f"mitre tag kind must be one of {MITRE_KINDS}")
if not ref.external_id:
raise ValueError("mitre tag external_id is required")
deduped[(ref.kind, ref.external_id)] = ref
tactic_ids = {r.external_id for r in deduped.values() if r.kind == "tactic"}
technique_ids = {r.external_id for r in deduped.values() if r.kind == "technique"}
subtechnique_ids = {r.external_id for r in deduped.values() if r.kind == "subtechnique"}
tactic_map = {
t.external_id: t.id
for t in s.scalars(select(MitreTactic).where(MitreTactic.external_id.in_(tactic_ids))).all()
}
technique_map = {
t.external_id: t.id
for t in s.scalars(select(MitreTechnique).where(MitreTechnique.external_id.in_(technique_ids))).all()
}
subtechnique_map = {
sb.external_id: sb.id
for sb in s.scalars(
select(MitreSubtechnique).where(MitreSubtechnique.external_id.in_(subtechnique_ids))
).all()
}
rows: list[TestTemplateMitreTag] = []
missing: list[tuple[str, str]] = []
for ref in deduped.values():
if ref.kind == "tactic":
mid = tactic_map.get(ref.external_id)
if mid is None:
missing.append((ref.kind, ref.external_id))
continue
rows.append(TestTemplateMitreTag(mitre_kind="tactic", tactic_id=mid))
elif ref.kind == "technique":
mid = technique_map.get(ref.external_id)
if mid is None:
missing.append((ref.kind, ref.external_id))
continue
rows.append(TestTemplateMitreTag(mitre_kind="technique", technique_id=mid))
else:
mid = subtechnique_map.get(ref.external_id)
if mid is None:
missing.append((ref.kind, ref.external_id))
continue
rows.append(TestTemplateMitreTag(mitre_kind="subtechnique", subtechnique_id=mid))
if missing:
raise UnknownMitreTag(f"unknown MITRE tags: {sorted(missing)}")
return rows
def _resolve_mitre_views(s: Session, tags: list[TestTemplateMitreTag]) -> list[MitreTagView]:
"""Batch-resolve polymorphic MITRE FKs into MitreTagViews in 3 queries
total — one per kind — regardless of how many tags or templates the
caller is rendering.
"""
tactic_ids = {t.tactic_id for t in tags if t.mitre_kind == "tactic" and t.tactic_id is not None}
technique_ids = {t.technique_id for t in tags if t.mitre_kind == "technique" and t.technique_id is not None}
sub_ids = {t.subtechnique_id for t in tags if t.mitre_kind == "subtechnique" and t.subtechnique_id is not None}
tactic_map: dict[uuid.UUID, MitreTactic] = {}
technique_map: dict[uuid.UUID, MitreTechnique] = {}
sub_map: dict[uuid.UUID, MitreSubtechnique] = {}
if tactic_ids:
tactic_map = {row.id: row for row in s.scalars(select(MitreTactic).where(MitreTactic.id.in_(tactic_ids))).all()}
if technique_ids:
technique_map = {
row.id: row
for row in s.scalars(select(MitreTechnique).where(MitreTechnique.id.in_(technique_ids))).all()
}
if sub_ids:
sub_map = {
row.id: row
for row in s.scalars(select(MitreSubtechnique).where(MitreSubtechnique.id.in_(sub_ids))).all()
}
views: list[MitreTagView] = []
for tag in tags:
if tag.mitre_kind == "tactic" and tag.tactic_id in tactic_map:
row_t = tactic_map[tag.tactic_id]
views.append(MitreTagView(kind="tactic", external_id=row_t.external_id, name=row_t.name, url=row_t.url))
elif tag.mitre_kind == "technique" and tag.technique_id in technique_map:
row_te = technique_map[tag.technique_id]
views.append(MitreTagView(kind="technique", external_id=row_te.external_id, name=row_te.name, url=row_te.url))
elif tag.mitre_kind == "subtechnique" and tag.subtechnique_id in sub_map:
row_sb = sub_map[tag.subtechnique_id]
views.append(MitreTagView(kind="subtechnique", external_id=row_sb.external_id, name=row_sb.name, url=row_sb.url))
views.sort(key=lambda v: (v.kind, v.external_id))
return views
def _to_views_batch(s: Session, templates: list[TestTemplate]) -> list[TestTemplateView]:
"""List-level batcher: one bulk MITRE resolve for all templates' tags.
For a list of K templates with ~T tags each, this issues 3 queries total
(one per MITRE kind) instead of 3K. We build (kind, uuid) → row maps
once, then assemble each template's view in memory.
"""
tactic_ids: set[uuid.UUID] = set()
technique_ids: set[uuid.UUID] = set()
sub_ids: set[uuid.UUID] = set()
for t in templates:
for tag in t.mitre_tags:
if tag.mitre_kind == "tactic" and tag.tactic_id is not None:
tactic_ids.add(tag.tactic_id)
elif tag.mitre_kind == "technique" and tag.technique_id is not None:
technique_ids.add(tag.technique_id)
elif tag.mitre_kind == "subtechnique" and tag.subtechnique_id is not None:
sub_ids.add(tag.subtechnique_id)
tactic_map: dict[uuid.UUID, MitreTactic] = (
{row.id: row for row in s.scalars(select(MitreTactic).where(MitreTactic.id.in_(tactic_ids))).all()}
if tactic_ids
else {}
)
technique_map: dict[uuid.UUID, MitreTechnique] = (
{row.id: row for row in s.scalars(select(MitreTechnique).where(MitreTechnique.id.in_(technique_ids))).all()}
if technique_ids
else {}
)
sub_map: dict[uuid.UUID, MitreSubtechnique] = (
{row.id: row for row in s.scalars(select(MitreSubtechnique).where(MitreSubtechnique.id.in_(sub_ids))).all()}
if sub_ids
else {}
)
def _views_for(tags: list[TestTemplateMitreTag]) -> list[MitreTagView]:
out: list[MitreTagView] = []
for tag in tags:
if tag.mitre_kind == "tactic" and tag.tactic_id in tactic_map:
row_t = tactic_map[tag.tactic_id]
out.append(MitreTagView(kind="tactic", external_id=row_t.external_id, name=row_t.name, url=row_t.url))
elif tag.mitre_kind == "technique" and tag.technique_id in technique_map:
row_te = technique_map[tag.technique_id]
out.append(MitreTagView(kind="technique", external_id=row_te.external_id, name=row_te.name, url=row_te.url))
elif tag.mitre_kind == "subtechnique" and tag.subtechnique_id in sub_map:
row_sb = sub_map[tag.subtechnique_id]
out.append(MitreTagView(kind="subtechnique", external_id=row_sb.external_id, name=row_sb.name, url=row_sb.url))
out.sort(key=lambda v: (v.kind, v.external_id))
return out
views: list[TestTemplateView] = []
for t in templates:
views.append(
TestTemplateView(
id=t.id,
name=t.name,
description=t.description,
objective=t.objective,
procedure_md=t.procedure_md,
prerequisites_md=t.prerequisites_md,
expected_result_red_md=t.expected_result_red_md,
expected_detection_blue_md=t.expected_detection_blue_md,
opsec_level=t.opsec_level,
tags=list(t.tags or []),
expected_iocs=list(t.expected_iocs or []),
mitre_tags=_views_for(list(t.mitre_tags)),
deleted_at=t.deleted_at,
created_at=t.created_at,
updated_at=t.updated_at,
)
)
return views
def _to_view(s: Session, t: TestTemplate) -> TestTemplateView:
tag_views = _resolve_mitre_views(s, list(t.mitre_tags))
return TestTemplateView(
id=t.id,
name=t.name,
description=t.description,
objective=t.objective,
procedure_md=t.procedure_md,
prerequisites_md=t.prerequisites_md,
expected_result_red_md=t.expected_result_red_md,
expected_detection_blue_md=t.expected_detection_blue_md,
opsec_level=t.opsec_level,
tags=list(t.tags or []),
expected_iocs=list(t.expected_iocs or []),
mitre_tags=tag_views,
deleted_at=t.deleted_at,
created_at=t.created_at,
updated_at=t.updated_at,
)
def _base_query():
return select(TestTemplate).options(selectinload(TestTemplate.mitre_tags))
def list_test_templates(
*,
q: str | None = None,
tactic: str | None = None, # external_id like "TA0006"
technique: str | None = None,
subtechnique: str | None = None,
opsec_level: str | None = None,
tag: str | None = None,
include_deleted: bool = False,
limit: int = 100,
offset: int = 0,
) -> tuple[list[TestTemplateView], int]:
with session_scope() as s:
stmt = _base_query().order_by(TestTemplate.name.asc())
count_stmt = select(func.count()).select_from(TestTemplate)
if not include_deleted:
stmt = stmt.where(TestTemplate.deleted_at.is_(None))
count_stmt = count_stmt.where(TestTemplate.deleted_at.is_(None))
if q:
like = f"%{q.lower()}%"
cond = or_(
func.lower(TestTemplate.name).like(like),
func.lower(TestTemplate.description).like(like),
)
stmt = stmt.where(cond)
count_stmt = count_stmt.where(cond)
if opsec_level:
_validate_opsec(opsec_level)
stmt = stmt.where(TestTemplate.opsec_level == opsec_level)
count_stmt = count_stmt.where(TestTemplate.opsec_level == opsec_level)
if tag:
stmt = stmt.where(TestTemplate.tags.any(tag))
count_stmt = count_stmt.where(TestTemplate.tags.any(tag))
# MITRE facets: each provided facet (tactic, technique, subtechnique) is
# AND-combined — a template tagged BOTH `TA0006` AND `T1003` matches a
# query with `?tactic=TA0006&technique=T1003`, but a template tagged
# only `TA0006` does NOT. Each facet matches strictly its own column
# (no cross-column UUID collision risk).
def _facet_subquery(column, mitre_id: uuid.UUID):
return (
select(TestTemplateMitreTag.test_template_id)
.where(column == mitre_id)
.distinct()
)
if tactic:
tac = s.scalar(select(MitreTactic).where(MitreTactic.external_id == tactic))
if tac is None:
return [], 0
sub_q = _facet_subquery(TestTemplateMitreTag.tactic_id, tac.id)
stmt = stmt.where(TestTemplate.id.in_(sub_q))
count_stmt = count_stmt.where(TestTemplate.id.in_(sub_q))
if technique:
tech = s.scalar(select(MitreTechnique).where(MitreTechnique.external_id == technique))
if tech is None:
return [], 0
sub_q = _facet_subquery(TestTemplateMitreTag.technique_id, tech.id)
stmt = stmt.where(TestTemplate.id.in_(sub_q))
count_stmt = count_stmt.where(TestTemplate.id.in_(sub_q))
if subtechnique:
sub = s.scalar(select(MitreSubtechnique).where(MitreSubtechnique.external_id == subtechnique))
if sub is None:
return [], 0
sub_q = _facet_subquery(TestTemplateMitreTag.subtechnique_id, sub.id)
stmt = stmt.where(TestTemplate.id.in_(sub_q))
count_stmt = count_stmt.where(TestTemplate.id.in_(sub_q))
total = s.scalar(count_stmt) or 0
rows = s.scalars(stmt.limit(max(1, min(limit, 500))).offset(max(0, offset))).all()
return _to_views_batch(s, list(rows)), int(total)
def get_test_template(template_id: uuid.UUID, *, include_deleted: bool = False) -> TestTemplateView:
with session_scope() as s:
t = s.get(TestTemplate, template_id)
if t is None:
raise TestTemplateNotFound()
if t.deleted_at is not None and not include_deleted:
raise TestTemplateNotFound()
return _to_view(s, t)
def create_test_template(
*,
name: str,
description: str | None = None,
objective: str | None = None,
procedure_md: str | None = None,
prerequisites_md: str | None = None,
expected_result_red_md: str | None = None,
expected_detection_blue_md: str | None = None,
opsec_level: str = "medium",
tags: list[str] | None = None,
expected_iocs: list[str] | None = None,
mitre_tags: list[MitreTagRef] | None = None,
) -> TestTemplateView:
name_norm = (name or "").strip()
if not name_norm:
raise ValueError("name is required")
_validate_opsec(opsec_level)
norm_tags = _normalize_string_list(tags)
norm_iocs = _normalize_string_list(expected_iocs)
with session_scope() as s:
t = TestTemplate(
name=name_norm,
description=_opt_str(description),
objective=_opt_str(objective),
procedure_md=procedure_md or None,
prerequisites_md=prerequisites_md or None,
expected_result_red_md=expected_result_red_md or None,
expected_detection_blue_md=expected_detection_blue_md or None,
opsec_level=opsec_level,
tags=norm_tags,
expected_iocs=norm_iocs,
)
s.add(t)
s.flush()
if mitre_tags:
rows = _resolve_mitre_refs(s, mitre_tags)
for row in rows:
row.test_template_id = t.id
s.add(row)
s.flush()
s.refresh(t)
return _to_view(s, t)
def _opt_str(value: str | None) -> str | None:
if value is None:
return None
s = value.strip()
return s or None
def update_test_template(
template_id: uuid.UUID,
*,
name: str | None = None,
description: Any = _UNSET,
objective: Any = _UNSET,
procedure_md: Any = _UNSET,
prerequisites_md: Any = _UNSET,
expected_result_red_md: Any = _UNSET,
expected_detection_blue_md: Any = _UNSET,
opsec_level: str | None = None,
tags: Any = _UNSET,
expected_iocs: Any = _UNSET,
mitre_tags: Any = _UNSET,
) -> TestTemplateView:
with session_scope() as s:
t = s.get(TestTemplate, template_id)
if t is None or t.deleted_at is not None:
raise TestTemplateNotFound()
if name is not None:
n = name.strip()
if not n:
raise ValueError("name cannot be empty")
t.name = n
if description is not _UNSET:
t.description = _opt_str(description)
if objective is not _UNSET:
t.objective = _opt_str(objective)
if procedure_md is not _UNSET:
t.procedure_md = procedure_md or None
if prerequisites_md is not _UNSET:
t.prerequisites_md = prerequisites_md or None
if expected_result_red_md is not _UNSET:
t.expected_result_red_md = expected_result_red_md or None
if expected_detection_blue_md is not _UNSET:
t.expected_detection_blue_md = expected_detection_blue_md or None
if opsec_level is not None:
_validate_opsec(opsec_level)
t.opsec_level = opsec_level
if tags is not _UNSET:
t.tags = _normalize_string_list(tags)
if expected_iocs is not _UNSET:
t.expected_iocs = _normalize_string_list(expected_iocs)
if mitre_tags is not _UNSET:
for row in list(t.mitre_tags):
s.delete(row)
s.flush()
rows = _resolve_mitre_refs(s, list(mitre_tags or []))
for row in rows:
row.test_template_id = t.id
s.add(row)
s.flush()
s.refresh(t)
return _to_view(s, t)
def soft_delete_test_template(template_id: uuid.UUID) -> None:
with session_scope() as s:
t = s.get(TestTemplate, template_id)
if t is None or t.deleted_at is not None:
raise TestTemplateNotFound()
t.deleted_at = datetime.now(tz=timezone.utc)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,781 @@
"""M6 — Mission CRUD, snapshot fidelity, membership visibility, transitions.
The fixture stack mirrors `test_templates.py`: one shared `app` per module,
fresh truncate at the start, a minimal MITRE bundle seeded for tag resolution,
plus a small catalogue of test_templates and scenario_templates created via
the admin API so the snapshot path is exercised end-to-end (not via raw ORM).
"""
from __future__ import annotations
import json
import secrets
import pytest
from sqlalchemy import text
from app.core.install_token import regenerate_install_token
from app.main import create_app
from app.services import mitre_seed as mitre_svc
def _truncate_all(engine):
with engine.begin() as conn:
# Order matches /diag/reset: missions before templates before MITRE.
conn.execute(
text(
"TRUNCATE mission_test_mitre_tags, mission_tests, "
"mission_scenarios, mission_categories, mission_members, "
"missions RESTART IDENTITY CASCADE"
)
)
conn.execute(
text(
"TRUNCATE scenario_template_tests, scenario_templates, "
"test_template_mitre_tags, test_templates "
"RESTART IDENTITY CASCADE"
)
)
conn.execute(
text(
"TRUNCATE users, refresh_tokens, invitations, invitation_groups, "
"user_groups, group_permissions, permissions, settings, groups "
"RESTART IDENTITY CASCADE"
)
)
conn.execute(
text(
"TRUNCATE mitre_technique_tactics, mitre_subtechniques, "
"mitre_techniques, mitre_tactics RESTART IDENTITY CASCADE"
)
)
_MINIMAL_BUNDLE = {
"type": "bundle",
"id": "bundle--00000000-0000-0000-0000-000000000006",
"spec_version": "2.1",
"objects": [
{
"type": "x-mitre-tactic",
"id": "x-mitre-tactic--ta0002",
"name": "Execution",
"x_mitre_shortname": "execution",
"external_references": [
{"source_name": "mitre-attack", "external_id": "TA0002"}
],
},
{
"type": "attack-pattern",
"id": "attack-pattern--t1059",
"name": "Command and Scripting Interpreter",
"kill_chain_phases": [
{"kill_chain_name": "mitre-attack", "phase_name": "execution"}
],
"external_references": [
{"source_name": "mitre-attack", "external_id": "T1059"}
],
},
{
"type": "attack-pattern",
"id": "attack-pattern--t1059-001",
"name": "PowerShell",
"x_mitre_is_subtechnique": True,
"external_references": [
{"source_name": "mitre-attack", "external_id": "T1059.001"}
],
},
{
"type": "relationship",
"id": "relationship--rel1",
"relationship_type": "subtechnique-of",
"source_ref": "attack-pattern--t1059-001",
"target_ref": "attack-pattern--t1059",
},
],
}
@pytest.fixture(scope="module")
def app(db_engine_or_skip, tmp_path_factory):
_truncate_all(db_engine_or_skip)
bundle_path = tmp_path_factory.mktemp("m6") / "stix.json"
bundle_path.write_text(json.dumps(_MINIMAL_BUNDLE))
mitre_svc.seed_mitre(source=bundle_path, expected_sha256=None)
flask_app = create_app()
flask_app.config.update(TESTING=True)
return flask_app
@pytest.fixture()
def client(app):
return app.test_client()
def _unique_email(prefix: str) -> str:
return f"{prefix}-{secrets.token_hex(4)}@metamorph.local"
def _bearer(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def _login(client, email: str, password: str) -> str:
r = client.post("/api/v1/auth/login", json={"email": email, "password": password})
assert r.status_code == 200, r.get_data(as_text=True)
return r.get_json()["access_token"]
@pytest.fixture(scope="module")
def admin(app):
token = regenerate_install_token()
email = _unique_email("admin")
password = "AdminPass1234!"
with app.test_client() as c:
r = c.post(
"/api/v1/setup",
json={"install_token": token, "email": email, "password": password},
)
assert r.status_code == 201, r.get_data(as_text=True)
return {"email": email, "password": password}
@pytest.fixture()
def admin_token(client, admin) -> str:
return _login(client, admin["email"], admin["password"])
# ---------------------------------------------------------------- catalogue --
def _mitre_kind(external_id: str) -> str:
if external_id.startswith("TA"):
return "tactic"
if "." in external_id:
return "subtechnique"
return "technique"
def _make_test_template(client, admin_token: str, *, name: str, mitre: str = "T1059"):
body = {
"name": name,
"description": "auto",
"objective": "do thing",
"procedure_md": f"# {name}\n1. run",
"expected_result_red_md": "red expectation",
"expected_detection_blue_md": "blue expectation",
"opsec_level": "medium",
"tags": ["fast"],
"expected_iocs": ["evil.exe"],
"mitre_tags": [{"kind": _mitre_kind(mitre), "external_id": mitre}],
}
r = client.post("/api/v1/test-templates", headers=_bearer(admin_token), json=body)
assert r.status_code == 201, r.get_data(as_text=True)
return r.get_json()
def _make_scenario(client, admin_token: str, *, name: str, test_ids: list[str]):
r = client.post(
"/api/v1/scenario-templates",
headers=_bearer(admin_token),
json={
"name": name,
"description": f"auto-{name}",
"test_template_ids": test_ids,
},
)
assert r.status_code == 201, r.get_data(as_text=True)
return r.get_json()
@pytest.fixture(scope="module")
def catalogue(app, admin):
"""Pre-seeded templates + scenarios so tests can reference them by id."""
with app.test_client() as c:
tok = _login(c, admin["email"], admin["password"])
t1 = _make_test_template(c, tok, name="cat-test-1", mitre="T1059")
t2 = _make_test_template(
c, tok, name="cat-test-2", mitre="T1059.001"
)
t3 = _make_test_template(c, tok, name="cat-test-3", mitre="T1059")
sc_one = _make_scenario(
c, tok, name="cat-scenario-A", test_ids=[t1["id"], t2["id"], t3["id"]]
)
sc_solo = _make_scenario(c, tok, name="cat-scenario-B", test_ids=[t1["id"]])
return {
"tests": {"t1": t1, "t2": t2, "t3": t3},
"scenarios": {"a": sc_one, "b": sc_solo},
}
# --------------------------------------------------------------------- users --
def _invite_user(client, admin_token: str, prefix: str, group_codes: list[str]) -> dict:
"""Invite a user pre-bound to a freshly-minted group with the listed perm codes."""
grp = client.post(
"/api/v1/groups",
headers=_bearer(admin_token),
json={"name": f"{prefix}-grp-{secrets.token_hex(2)}"},
).get_json()
r_set = client.put(
f"/api/v1/groups/{grp['id']}/permissions",
headers=_bearer(admin_token),
json={"codes": group_codes},
)
assert r_set.status_code == 200, r_set.get_data(as_text=True)
email = _unique_email(prefix)
password = "Pass1234!"
inv = client.post(
"/api/v1/invitations",
headers=_bearer(admin_token),
json={"email_hint": email, "group_ids": [grp["id"]]},
)
assert inv.status_code == 201, inv.get_data(as_text=True)
accept_token = inv.get_json()["token"]
r = client.post(
f"/api/v1/invitations/accept/{accept_token}",
json={"email": email, "password": password},
)
assert r.status_code == 201, r.get_data(as_text=True)
me_token = _login(client, email, password)
me = client.get("/api/v1/auth/me", headers=_bearer(me_token)).get_json()
return {"email": email, "password": password, "token": me_token, "id": me["id"]}
@pytest.fixture()
def red_user(client, admin_token):
return _invite_user(
client,
admin_token,
"red",
[
"mission.read",
"mission.create",
"mission.update",
"mission.archive",
"mission.write_red_fields",
],
)
@pytest.fixture()
def blue_user(client, admin_token):
return _invite_user(
client,
admin_token,
"blue",
["mission.read", "mission.write_blue_fields"],
)
@pytest.fixture()
def reader_user(client, admin_token):
"""A user with mission.read only — for "non-member can't see" checks."""
return _invite_user(client, admin_token, "reader", ["mission.read"])
@pytest.fixture()
def noperm_user(client, admin_token):
"""A user with no mission perms at all."""
return _invite_user(client, admin_token, "noperm", [])
# ================================================================ snapshot ==
def test_create_mission_snapshots_scenarios_and_tests(client, admin_token, catalogue):
r = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={
"name": "snapshot-fidelity",
"client_target": "Acme Corp",
"description_md": "## ROE\n- approved\n",
"scenario_template_ids": [catalogue["scenarios"]["a"]["id"]],
},
)
assert r.status_code == 201, r.get_data(as_text=True)
body = r.get_json()
assert body["status"] == "draft"
assert body["visibility_mode"] == "whitebox"
assert body["scenarios_count"] == 1
assert body["tests_count"] == 3
assert body["members_count"] == 0 # admin creator is not auto-added
sc = body["scenarios"][0]
assert sc["position"] == 0
assert sc["snapshot_name"] == "cat-scenario-A"
names_in_order = [t["snapshot_name"] for t in sc["tests"]]
assert names_in_order == ["cat-test-1", "cat-test-2", "cat-test-3"]
# MITRE denormalised into the snapshot
t1 = next(t for t in sc["tests"] if t["snapshot_name"] == "cat-test-1")
kinds = [(tag["kind"], tag["external_id"]) for tag in t1["mitre_tags"]]
assert kinds == [("technique", "T1059")]
def test_snapshot_is_frozen_after_template_edits(client, admin_token, catalogue):
create = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={
"name": "frozen-after-edits",
"scenario_template_ids": [catalogue["scenarios"]["b"]["id"]],
},
)
assert create.status_code == 201
mission_id = create.get_json()["id"]
# Mutate the source test_template: rename + change MITRE
edit = client.put(
f"/api/v1/test-templates/{catalogue['tests']['t1']['id']}",
headers=_bearer(admin_token),
json={
"name": "RENAMED-AFTER-SNAPSHOT",
"mitre_tags": [{"kind": "tactic", "external_id": "TA0002"}],
},
)
assert edit.status_code == 200
# Mission still sees the pre-edit snapshot
again = client.get(
f"/api/v1/missions/{mission_id}", headers=_bearer(admin_token)
).get_json()
sc = again["scenarios"][0]
assert sc["tests"][0]["snapshot_name"] == "cat-test-1"
assert [(t["kind"], t["external_id"]) for t in sc["tests"][0]["mitre_tags"]] == [
("technique", "T1059")
]
# Revert the rename so other tests still find the original name
client.put(
f"/api/v1/test-templates/{catalogue['tests']['t1']['id']}",
headers=_bearer(admin_token),
json={
"name": "cat-test-1",
"mitre_tags": [{"kind": "technique", "external_id": "T1059"}],
},
)
def test_create_mission_rejects_unknown_scenario(client, admin_token):
r = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={
"name": "bad-ref",
"scenario_template_ids": ["00000000-0000-0000-0000-000000000099"],
},
)
assert r.status_code == 400
assert r.get_json()["error"] == "unknown_scenario_template"
def test_create_mission_rejects_soft_deleted_scenario(client, admin_token):
# Build a scenario, soft-delete it, then try to snapshot — must 400 with
# `unknown_scenario_template` so we don't silently freeze a tombstoned
# template into a new mission.
t = _make_test_template(client, admin_token, name="sd-rejection-t")
sc = client.post(
"/api/v1/scenario-templates",
headers=_bearer(admin_token),
json={"name": "sd-rejection-sc", "test_template_ids": [t["id"]]},
).get_json()
del_r = client.delete(
f"/api/v1/scenario-templates/{sc['id']}", headers=_bearer(admin_token)
)
assert del_r.status_code == 200
r = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={
"name": "sd-rejection-mission",
"scenario_template_ids": [sc["id"]],
},
)
assert r.status_code == 400
assert r.get_json()["error"] == "unknown_scenario_template"
def test_create_mission_validates_dates(client, admin_token):
r = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={
"name": "date-flip",
"date_start": "2026-06-01",
"date_end": "2026-05-01",
},
)
assert r.status_code == 400
assert "date_end" in r.get_json().get("message", "").lower()
# =================================================== membership visibility ==
def test_non_admin_creator_auto_added(client, red_user, catalogue):
r = client.post(
"/api/v1/missions",
headers=_bearer(red_user["token"]),
json={
"name": "red-self-created",
"scenario_template_ids": [catalogue["scenarios"]["b"]["id"]],
},
)
assert r.status_code == 201, r.get_data(as_text=True)
body = r.get_json()
assert body["members_count"] == 1
assert body["members"][0]["user_id"] == red_user["id"]
assert body["members"][0]["role_hint"] == "red"
# And the red user can see it back via /missions
r2 = client.get("/api/v1/missions", headers=_bearer(red_user["token"]))
ids = [it["id"] for it in r2.get_json()["items"]]
assert body["id"] in ids
def test_non_admin_cannot_see_missions_they_are_not_members_of(
client, admin_token, reader_user, catalogue
):
# Admin creates a mission with NO members
create = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={
"name": "hidden-from-reader",
"scenario_template_ids": [catalogue["scenarios"]["b"]["id"]],
},
)
assert create.status_code == 201
mid = create.get_json()["id"]
# Reader has mission.read but is not a member → empty list + 404
r_list = client.get("/api/v1/missions", headers=_bearer(reader_user["token"]))
ids = [it["id"] for it in r_list.get_json()["items"]]
assert mid not in ids
r_get = client.get(
f"/api/v1/missions/{mid}", headers=_bearer(reader_user["token"])
)
assert r_get.status_code == 404
def test_non_member_get_returns_404_not_403(client, admin_token, reader_user):
"""Existence leak guard: non-members should see 404, not 403."""
create = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={"name": "stealth-mission"},
)
mid = create.get_json()["id"]
r = client.get(f"/api/v1/missions/{mid}", headers=_bearer(reader_user["token"]))
assert r.status_code == 404
# ===================================================== perm gating ==========
def test_create_requires_mission_create_perm(client, blue_user):
"""Blue team users (no mission.create) cannot create missions."""
r = client.post(
"/api/v1/missions",
headers=_bearer(blue_user["token"]),
json={"name": "no-perm"},
)
assert r.status_code == 403
def test_list_requires_mission_read_perm(client, noperm_user):
r = client.get("/api/v1/missions", headers=_bearer(noperm_user["token"]))
assert r.status_code == 403
def test_transition_perm_gate_runs_before_payload_parse(client, blue_user):
"""A user without `mission.update` or `mission.archive` should see 403,
not 400, even when posting a malformed body — otherwise the endpoint's
shape leaks via the validation error message."""
# blue_user only has mission.read + mission.write_blue_fields, so neither
# mission.update nor mission.archive is held.
r = client.post(
"/api/v1/missions/00000000-0000-0000-0000-000000000000/transition",
headers=_bearer(blue_user["token"]),
json={"status": "garbage-not-a-valid-shape"},
)
assert r.status_code == 403
def test_search_treats_wildcards_as_literals(client, admin_token):
"""User-typed `%` and `_` must NOT act as SQL LIKE wildcards."""
client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={"name": "no-wildcards-here"},
)
# Without escaping, `?q=%` would match every mission. With escaping, it
# only matches names that literally contain `%`.
r = client.get("/api/v1/missions?q=%25", headers=_bearer(admin_token))
assert r.status_code == 200
names = [it["name"] for it in r.get_json()["items"]]
assert "no-wildcards-here" not in names
def test_archive_requires_mission_archive_not_just_update(client, admin_token):
"""A user with mission.update but no mission.archive cannot archive."""
# blue_user only has mission.read + mission.write_blue_fields — no update either.
# We'll craft a user with update-only here.
update_only = _invite_user(client, admin_token, "u-only", ["mission.read", "mission.update"])
create = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={
"name": "to-archive",
"members": [{"user_id": update_only["id"], "role_hint": "red"}],
},
)
assert create.status_code == 201
mid = create.get_json()["id"]
# update-only can transition to in_progress (mission.update is enough)
r1 = client.post(
f"/api/v1/missions/{mid}/transition",
headers=_bearer(update_only["token"]),
json={"status": "in_progress"},
)
assert r1.status_code == 200
# … but cannot archive
r2 = client.post(
f"/api/v1/missions/{mid}/transition",
headers=_bearer(update_only["token"]),
json={"status": "archived"},
)
assert r2.status_code == 403
# ==================================================== status transitions ==
def test_valid_transition_chain(client, admin_token):
create = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={"name": "chain"},
)
mid = create.get_json()["id"]
for target in ("in_progress", "completed", "archived"):
r = client.post(
f"/api/v1/missions/{mid}/transition",
headers=_bearer(admin_token),
json={"status": target},
)
assert r.status_code == 200, (target, r.get_data(as_text=True))
assert r.get_json()["status"] == target
def test_invalid_transition_409(client, admin_token):
create = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={"name": "invalid-jump"},
)
mid = create.get_json()["id"]
# draft → completed is not allowed (must pass through in_progress)
r = client.post(
f"/api/v1/missions/{mid}/transition",
headers=_bearer(admin_token),
json={"status": "completed"},
)
assert r.status_code == 409
assert r.get_json()["error"] == "invalid_transition"
def test_unknown_target_status_400(client, admin_token):
create = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={"name": "bad-status"},
)
mid = create.get_json()["id"]
r = client.post(
f"/api/v1/missions/{mid}/transition",
headers=_bearer(admin_token),
json={"status": "delivered"},
)
assert r.status_code == 400
def test_idempotent_same_status_transition(client, admin_token):
create = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={"name": "idempotent"},
)
mid = create.get_json()["id"]
r = client.post(
f"/api/v1/missions/{mid}/transition",
headers=_bearer(admin_token),
json={"status": "draft"},
)
assert r.status_code == 200
assert r.get_json()["status"] == "draft"
# ============================================================ members =====
def test_set_members_replaces_full_set(client, admin_token, red_user, blue_user):
create = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={
"name": "members-replace",
"members": [{"user_id": red_user["id"], "role_hint": "red"}],
},
)
mid = create.get_json()["id"]
r = client.put(
f"/api/v1/missions/{mid}/members",
headers=_bearer(admin_token),
json={"members": [{"user_id": blue_user["id"], "role_hint": "blue"}]},
)
assert r.status_code == 200, r.get_data(as_text=True)
body = r.get_json()
assert body["members_count"] == 1
assert body["members"][0]["user_id"] == blue_user["id"]
# And red can no longer see it
r_red = client.get(
f"/api/v1/missions/{mid}", headers=_bearer(red_user["token"])
)
assert r_red.status_code == 404
def test_set_members_rejects_unknown_user(client, admin_token):
create = client.post(
"/api/v1/missions", headers=_bearer(admin_token), json={"name": "ghost-member"}
)
mid = create.get_json()["id"]
r = client.put(
f"/api/v1/missions/{mid}/members",
headers=_bearer(admin_token),
json={
"members": [
{
"user_id": "00000000-0000-0000-0000-000000000123",
"role_hint": "red",
}
]
},
)
assert r.status_code == 400
assert r.get_json()["error"] == "unknown_user"
def test_set_members_rejects_bad_role_hint(client, admin_token, red_user):
create = client.post(
"/api/v1/missions", headers=_bearer(admin_token), json={"name": "bad-hint"}
)
mid = create.get_json()["id"]
r = client.put(
f"/api/v1/missions/{mid}/members",
headers=_bearer(admin_token),
json={"members": [{"user_id": red_user["id"], "role_hint": "yellow"}]},
)
assert r.status_code == 400
# ==================================================== add scenarios ======
def test_add_scenarios_appends_at_end(client, admin_token, catalogue):
create = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={
"name": "appendable",
"scenario_template_ids": [catalogue["scenarios"]["b"]["id"]],
},
)
mid = create.get_json()["id"]
r = client.post(
f"/api/v1/missions/{mid}/scenarios",
headers=_bearer(admin_token),
json={"scenario_template_ids": [catalogue["scenarios"]["a"]["id"]]},
)
assert r.status_code == 200, r.get_data(as_text=True)
body = r.get_json()
assert body["scenarios_count"] == 2
positions = [sc["position"] for sc in body["scenarios"]]
assert positions == [0, 1]
# Second scenario lands at position 1
sc1 = next(sc for sc in body["scenarios"] if sc["position"] == 1)
assert sc1["snapshot_name"] == "cat-scenario-A"
assert len(sc1["tests"]) == 3
# ============================================================= delete =====
def test_soft_delete_hides_from_list(client, admin_token):
create = client.post(
"/api/v1/missions", headers=_bearer(admin_token), json={"name": "to-delete"}
)
mid = create.get_json()["id"]
r_del = client.delete(f"/api/v1/missions/{mid}", headers=_bearer(admin_token))
assert r_del.status_code == 200
r_list = client.get("/api/v1/missions", headers=_bearer(admin_token))
ids = [it["id"] for it in r_list.get_json()["items"]]
assert mid not in ids
# include_deleted=true brings it back (admin only)
r_list2 = client.get(
"/api/v1/missions?include_deleted=true", headers=_bearer(admin_token)
)
ids2 = [it["id"] for it in r_list2.get_json()["items"]]
assert mid in ids2
def test_include_deleted_forbidden_for_non_admin(client, red_user):
r = client.get(
"/api/v1/missions?include_deleted=true", headers=_bearer(red_user["token"])
)
assert r.status_code == 403
# ============================================================ update ======
def test_update_metadata_partial(client, admin_token):
create = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={
"name": "to-rename",
"client_target": "X",
"date_start": "2026-06-01",
"date_end": "2026-06-10",
},
)
mid = create.get_json()["id"]
r = client.put(
f"/api/v1/missions/{mid}",
headers=_bearer(admin_token),
json={"name": "renamed", "client_target": None},
)
assert r.status_code == 200, r.get_data(as_text=True)
body = r.get_json()
assert body["name"] == "renamed"
assert body["client_target"] is None
# date fields untouched
assert body["date_start"] == "2026-06-01"
assert body["date_end"] == "2026-06-10"
def test_update_rejects_inverted_dates(client, admin_token):
create = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={
"name": "invert",
"date_start": "2026-06-01",
"date_end": "2026-06-10",
},
)
mid = create.get_json()["id"]
r = client.put(
f"/api/v1/missions/{mid}",
headers=_bearer(admin_token),
json={"date_end": "2026-05-01"},
)
assert r.status_code == 400

View File

@@ -8,7 +8,6 @@ from __future__ import annotations
import json import json
import secrets import secrets
import uuid
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -245,12 +244,17 @@ def test_seed_persists_setting(app, fixture_bundle_path):
assert status["version"] is None # only set when source == MITRE_DEFAULT_URL assert status["version"] is None # only set when source == MITRE_DEFAULT_URL
def test_checksum_mismatch_aborts(tmp_path): def test_checksum_mismatch_aborts(tmp_path, monkeypatch):
"""A wrong sha256 triggers MitreChecksumMismatch and skips DB writes.""" """A wrong sha256 triggers MitreChecksumMismatch and skips DB writes.
We monkey-patch the allowlist to accept `file://` for the duration of the
test — file:// is rejected in production by `_ensure_host_allowed` (cf.
`test_seed_refuses_file_url`), but we need to drive `_download` past that
gate to exercise the sha256 path.
"""
path = tmp_path / "tiny.json" path = tmp_path / "tiny.json"
path.write_text(json.dumps(MINIMAL_BUNDLE)) path.write_text(json.dumps(MINIMAL_BUNDLE))
# Force the URL path so download() is invoked. We mock by passing a file:// URL. monkeypatch.setattr(mitre_svc, "_ensure_host_allowed", lambda _: None)
# Simpler: call _download() directly with a bogus hash.
bogus = "0" * 64 bogus = "0" * 64
with pytest.raises(mitre_svc.MitreChecksumMismatch): with pytest.raises(mitre_svc.MitreChecksumMismatch):
mitre_svc._download( mitre_svc._download(
@@ -387,3 +391,63 @@ def test_matrix_endpoint_requires_auth(app, fixture_bundle_path):
mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None) mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None)
with app.test_client() as c: with app.test_client() as c:
assert c.get("/api/v1/mitre/matrix").status_code == 401 assert c.get("/api/v1/mitre/matrix").status_code == 401
# === Security guards ==========================================================
def test_seed_refuses_file_url(tmp_path):
"""file:// (or any scheme outside the allowlist) is rejected — protects
against a privileged operator pivoting the in-container fetch to local
filesystem reads via the URL path."""
path = tmp_path / "bundle.json"
path.write_text(json.dumps(MINIMAL_BUNDLE))
with pytest.raises(mitre_svc.MitreSourceForbidden):
mitre_svc._download(f"file://{path}", tmp_path / "out.json")
def test_seed_refuses_disallowed_https_host(tmp_path):
"""An HTTPS URL outside MITRE_ALLOWED_HOSTS is rejected before any I/O.
Closes the SSRF surface (cloud metadata, internal mirrors)."""
with pytest.raises(mitre_svc.MitreSourceForbidden):
mitre_svc._download("https://attacker.example/bundle.json", tmp_path / "out.json")
def test_seed_refuses_custom_url_without_sha(tmp_path):
"""End-to-end refusal: even an allowlisted custom URL needs a sha or an
explicit allow_unverified=True."""
# Use the default URL with a different sha to simulate "custom" semantics
# without actually hitting the network: pass a different MITRE_DEFAULT_URL.
# The cleanest expression is to call seed_mitre with the same URL but no sha
# — but the default URL gets the default sha auto-set; we need to bypass.
with pytest.raises(mitre_svc.MitreSeedError):
mitre_svc.seed_mitre(
source="https://raw.githubusercontent.com/some-other-path/bundle.json",
expected_sha256=None,
allow_unverified=False,
)
def test_dotted_id_fallback_resolves_orphan_subtechnique(app, tmp_path):
"""When the STIX `subtechnique-of` relationship is missing, the parser
must fall back to the dotted convention (T1003.001 → T1003)."""
bundle = json.loads(json.dumps(MINIMAL_BUNDLE)) # deep copy
# Strip the relationship object so the parent_stix_id lookup fails.
bundle["objects"] = [o for o in bundle["objects"] if o.get("type") != "relationship"]
bundle_path = tmp_path / "no-rel.json"
bundle_path.write_text(json.dumps(bundle))
result = mitre_svc.seed_mitre(source=bundle_path, expected_sha256=None)
# The fallback resolves T1059.001 → T1059 via the dotted-id pattern,
# so the subtechnique is still attached (no orphan).
assert result.subtechniques_upserted == 1
assert result.subtechniques_skipped_orphan == 0
def test_seed_clears_version_when_source_is_not_default(app, fixture_bundle_path):
"""A custom source must NULL `mitre_version` so /mitre/status doesn't lie
about a stale upstream pin."""
# First seed from the default URL would set version=19.0; here we seed from
# a local file path, which should write version=None.
mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None)
assert mitre_svc.read_status()["version"] is None

View File

@@ -0,0 +1,567 @@
"""M5 — Template catalogue integration tests.
Covers `test_template` and `scenario_template` CRUD + ordering + perm gating.
Relies on a minimal MITRE seed (T1059 / TA0001 / T1059.001) so the polymorphic
tag join can be exercised end-to-end.
"""
from __future__ import annotations
import json
import secrets
import pytest
from sqlalchemy import text
from app.core.install_token import regenerate_install_token
from app.main import create_app
from app.services import mitre_seed as mitre_svc
def _truncate_all(engine):
with engine.begin() as conn:
conn.execute(
text(
"TRUNCATE users, refresh_tokens, invitations, invitation_groups, "
"user_groups, group_permissions, permissions, settings, groups, "
"scenario_template_tests, scenario_templates, "
"test_template_mitre_tags, test_templates, "
"mitre_subtechniques, mitre_technique_tactics, mitre_techniques, "
"mitre_tactics RESTART IDENTITY CASCADE"
)
)
# Same minimal bundle as in test_mitre.py — keeps tag resolution deterministic
# without re-pulling the full enterprise STIX bundle.
_MINIMAL_BUNDLE = {
"type": "bundle",
"id": "bundle--00000000-0000-0000-0000-000000000002",
"spec_version": "2.1",
"objects": [
{
"type": "x-mitre-tactic",
"id": "x-mitre-tactic--ta0001",
"name": "Initial Access",
"x_mitre_shortname": "initial-access",
"external_references": [
{"source_name": "mitre-attack", "external_id": "TA0001"}
],
},
{
"type": "x-mitre-tactic",
"id": "x-mitre-tactic--ta0002",
"name": "Execution",
"x_mitre_shortname": "execution",
"external_references": [
{"source_name": "mitre-attack", "external_id": "TA0002"}
],
},
{
"type": "attack-pattern",
"id": "attack-pattern--t1059",
"name": "Command and Scripting Interpreter",
"kill_chain_phases": [
{"kill_chain_name": "mitre-attack", "phase_name": "execution"}
],
"external_references": [
{"source_name": "mitre-attack", "external_id": "T1059"}
],
},
{
"type": "attack-pattern",
"id": "attack-pattern--t1059-001",
"name": "PowerShell",
"x_mitre_is_subtechnique": True,
"external_references": [
{"source_name": "mitre-attack", "external_id": "T1059.001"}
],
},
{
"type": "relationship",
"id": "relationship--rel1",
"relationship_type": "subtechnique-of",
"source_ref": "attack-pattern--t1059-001",
"target_ref": "attack-pattern--t1059",
},
],
}
@pytest.fixture(scope="module")
def app(db_engine_or_skip, tmp_path_factory):
_truncate_all(db_engine_or_skip)
bundle_path = tmp_path_factory.mktemp("m5") / "stix.json"
bundle_path.write_text(json.dumps(_MINIMAL_BUNDLE))
mitre_svc.seed_mitre(source=bundle_path, expected_sha256=None)
flask_app = create_app()
flask_app.config.update(TESTING=True)
return flask_app
@pytest.fixture()
def client(app):
return app.test_client()
def _unique_email(prefix: str) -> str:
return f"{prefix}-{secrets.token_hex(4)}@metamorph.local"
@pytest.fixture(scope="module")
def admin(app):
token = regenerate_install_token()
email = _unique_email("admin")
password = "AdminPass1234!"
with app.test_client() as c:
r = c.post(
"/api/v1/setup",
json={"install_token": token, "email": email, "password": password},
)
assert r.status_code == 201, r.get_data(as_text=True)
return {"email": email, "password": password}
def _login(client, email: str, password: str) -> str:
r = client.post("/api/v1/auth/login", json={"email": email, "password": password})
assert r.status_code == 200, r.get_data(as_text=True)
return r.get_json()["access_token"]
def _bearer(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
@pytest.fixture()
def admin_token(client, admin) -> str:
return _login(client, admin["email"], admin["password"])
# === Reader fixture: an invited user with only `test_template.read` =========
def _bootstrap_user_without_perms(client, admin_token: str, prefix: str) -> tuple[str, str]:
email = _unique_email(prefix)
inv = client.post(
"/api/v1/invitations",
headers=_bearer(admin_token),
json={"email_hint": email},
)
token = inv.get_json()["token"]
password = "ReaderPass1234!"
client.post(
f"/api/v1/invitations/accept/{token}",
json={"email": email, "password": password},
)
return email, _login(client, email, password)
# === test_template CRUD =====================================================
def _make_test(client, admin_token: str, **overrides):
body = {
"name": overrides.pop("name", f"Test {secrets.token_hex(2)}"),
"description": overrides.pop("description", "auto"),
"objective": "do thing",
"procedure_md": "1. step",
"expected_result_red_md": "red expectation",
"expected_detection_blue_md": "blue expectation",
"opsec_level": overrides.pop("opsec_level", "medium"),
"tags": overrides.pop("tags", ["fast"]),
"expected_iocs": ["evil.exe"],
"mitre_tags": overrides.pop("mitre_tags", [{"kind": "technique", "external_id": "T1059"}]),
**overrides,
}
r = client.post("/api/v1/test-templates", headers=_bearer(admin_token), json=body)
assert r.status_code == 201, r.get_data(as_text=True)
return r.get_json()
def test_create_test_template_with_mitre_tags(client, admin_token):
body = _make_test(
client,
admin_token,
name="PowerShell exec",
mitre_tags=[
{"kind": "tactic", "external_id": "TA0002"},
{"kind": "technique", "external_id": "T1059"},
{"kind": "subtechnique", "external_id": "T1059.001"},
],
)
assert body["opsec_level"] == "medium"
kinds = sorted((t["kind"], t["external_id"]) for t in body["mitre_tags"])
assert kinds == [
("subtechnique", "T1059.001"),
("tactic", "TA0002"),
("technique", "T1059"),
]
def test_create_test_template_rejects_unknown_mitre(client, admin_token):
r = client.post(
"/api/v1/test-templates",
headers=_bearer(admin_token),
json={
"name": "Bad",
"mitre_tags": [{"kind": "technique", "external_id": "T9999"}],
},
)
assert r.status_code == 400
assert r.get_json()["error"] == "unknown_mitre_tag"
def test_create_test_template_rejects_bad_opsec(client, admin_token):
r = client.post(
"/api/v1/test-templates",
headers=_bearer(admin_token),
json={"name": "Bad", "opsec_level": "burner"},
)
assert r.status_code == 400
def test_list_test_templates_filter_by_tactic(client, admin_token):
_make_test(
client,
admin_token,
name="filterable-1",
mitre_tags=[{"kind": "tactic", "external_id": "TA0002"}],
)
r = client.get(
"/api/v1/test-templates?tactic=TA0002",
headers=_bearer(admin_token),
)
assert r.status_code == 200
body = r.get_json()
names = [it["name"] for it in body["items"]]
assert "filterable-1" in names
def test_list_test_templates_filter_by_opsec(client, admin_token):
_make_test(client, admin_token, name="high-opsec", opsec_level="high")
r = client.get(
"/api/v1/test-templates?opsec=high",
headers=_bearer(admin_token),
)
assert r.status_code == 200
names = [it["name"] for it in r.get_json()["items"]]
assert "high-opsec" in names
assert all(it["opsec_level"] == "high" for it in r.get_json()["items"])
def test_list_test_templates_filter_by_tag(client, admin_token):
_make_test(client, admin_token, name="tagged-fast", tags=["fast", "phish"])
r = client.get(
"/api/v1/test-templates?tag=phish",
headers=_bearer(admin_token),
)
assert r.status_code == 200
names = [it["name"] for it in r.get_json()["items"]]
assert "tagged-fast" in names
def test_list_test_templates_search_q(client, admin_token):
_make_test(client, admin_token, name="unique-token-azertyuiop")
r = client.get(
"/api/v1/test-templates?q=AZERTYUIOP", # case-insensitive
headers=_bearer(admin_token),
)
assert r.status_code == 200
names = [it["name"] for it in r.get_json()["items"]]
assert "unique-token-azertyuiop" in names
def test_update_test_template_replaces_mitre_tags(client, admin_token):
body = _make_test(
client,
admin_token,
name="to-update",
mitre_tags=[{"kind": "tactic", "external_id": "TA0001"}],
)
r = client.put(
f"/api/v1/test-templates/{body['id']}",
headers=_bearer(admin_token),
json={"mitre_tags": [{"kind": "technique", "external_id": "T1059"}]},
)
assert r.status_code == 200, r.get_data(as_text=True)
updated = r.get_json()
kinds = [(t["kind"], t["external_id"]) for t in updated["mitre_tags"]]
assert kinds == [("technique", "T1059")]
def test_update_test_template_partial_keeps_unset_fields(client, admin_token):
body = _make_test(
client,
admin_token,
name="partial-update",
opsec_level="low",
tags=["a", "b"],
)
r = client.put(
f"/api/v1/test-templates/{body['id']}",
headers=_bearer(admin_token),
json={"name": "renamed"},
)
assert r.status_code == 200
updated = r.get_json()
assert updated["name"] == "renamed"
assert updated["opsec_level"] == "low" # untouched
assert set(updated["tags"]) == {"a", "b"} # untouched
def test_soft_delete_then_list_hides_by_default(client, admin_token):
body = _make_test(client, admin_token, name="to-be-deleted")
r = client.delete(
f"/api/v1/test-templates/{body['id']}", headers=_bearer(admin_token)
)
assert r.status_code == 200
r2 = client.get("/api/v1/test-templates", headers=_bearer(admin_token))
names = [it["name"] for it in r2.get_json()["items"]]
assert "to-be-deleted" not in names
# And reappears with include_deleted=true
r3 = client.get(
"/api/v1/test-templates?include_deleted=true",
headers=_bearer(admin_token),
)
names3 = [it["name"] for it in r3.get_json()["items"]]
assert "to-be-deleted" in names3
def test_read_perm_required(client, admin_token):
"""A user without `test_template.read` gets 403."""
_, eve_token = _bootstrap_user_without_perms(client, admin_token, "eve-noperm")
r = client.get("/api/v1/test-templates", headers=_bearer(eve_token))
assert r.status_code == 403
def test_write_perm_required(client, admin_token):
"""A user with only `test_template.read` cannot create.
Bootstrap path: create a dedicated group via the admin API, bind only the
`test_template.read` perm, then invite a user pre-assigned to that group.
"""
# 1. Create the read-only group + bind the single perm.
grp = client.post(
"/api/v1/groups",
headers=_bearer(admin_token),
json={"name": f"tpl-reader-{secrets.token_hex(2)}"},
).get_json()
r_set = client.put(
f"/api/v1/groups/{grp['id']}/permissions",
headers=_bearer(admin_token),
json={"codes": ["test_template.read"]},
)
assert r_set.status_code == 200, r_set.get_data(as_text=True)
# 2. Invite a user already attached to that group.
email = _unique_email("alice-readonly")
password = "ReaderPass1234!"
inv = client.post(
"/api/v1/invitations",
headers=_bearer(admin_token),
json={"email_hint": email, "group_ids": [grp["id"]]},
).get_json()
client.post(
f"/api/v1/invitations/accept/{inv['token']}",
json={"email": email, "password": password},
)
token = _login(client, email, password)
r = client.get("/api/v1/test-templates", headers=_bearer(token))
assert r.status_code == 200, r.get_data(as_text=True)
r2 = client.post(
"/api/v1/test-templates", headers=_bearer(token), json={"name": "X"}
)
assert r2.status_code == 403
# === scenario_template CRUD =================================================
def test_create_scenario_with_ordered_tests(client, admin_token):
a = _make_test(client, admin_token, name="scn-a")
b = _make_test(client, admin_token, name="scn-b")
c = _make_test(client, admin_token, name="scn-c")
r = client.post(
"/api/v1/scenario-templates",
headers=_bearer(admin_token),
json={
"name": "phishing-flow",
"description": "click → exec → persist",
"test_template_ids": [a["id"], b["id"], c["id"]],
},
)
assert r.status_code == 201, r.get_data(as_text=True)
body = r.get_json()
assert body["tests_count"] == 3
assert [t["position"] for t in body["tests"]] == [0, 1, 2]
assert [t["test_template_name"] for t in body["tests"]] == ["scn-a", "scn-b", "scn-c"]
def test_reorder_scenario_tests(client, admin_token):
a = _make_test(client, admin_token, name="reord-a")
b = _make_test(client, admin_token, name="reord-b")
c = _make_test(client, admin_token, name="reord-c")
created = client.post(
"/api/v1/scenario-templates",
headers=_bearer(admin_token),
json={
"name": "reorder-me",
"test_template_ids": [a["id"], b["id"], c["id"]],
},
).get_json()
# Reverse order.
r = client.put(
f"/api/v1/scenario-templates/{created['id']}/tests",
headers=_bearer(admin_token),
json={"test_template_ids": [c["id"], b["id"], a["id"]]},
)
assert r.status_code == 200
after = r.get_json()
assert [t["test_template_name"] for t in after["tests"]] == ["reord-c", "reord-b", "reord-a"]
# Re-reading via GET yields the same order — confirms persistence.
fresh = client.get(
f"/api/v1/scenario-templates/{created['id']}", headers=_bearer(admin_token)
).get_json()
assert [t["test_template_name"] for t in fresh["tests"]] == ["reord-c", "reord-b", "reord-a"]
def test_scenario_rejects_unknown_test_id(client, admin_token):
r = client.post(
"/api/v1/scenario-templates",
headers=_bearer(admin_token),
json={
"name": "bad",
"test_template_ids": ["00000000-0000-0000-0000-000000000000"],
},
)
assert r.status_code == 400
assert r.get_json()["error"] == "unknown_test_template"
def test_scenario_rejects_soft_deleted_test_on_create(client, admin_token):
a = _make_test(client, admin_token, name="will-be-deleted")
client.delete(f"/api/v1/test-templates/{a['id']}", headers=_bearer(admin_token))
r = client.post(
"/api/v1/scenario-templates",
headers=_bearer(admin_token),
json={"name": "linked", "test_template_ids": [a["id"]]},
)
assert r.status_code == 400
assert r.get_json()["error"] == "unknown_test_template"
def test_scenario_surfaces_soft_deleted_test_after_link(client, admin_token):
"""Once linked, a test can be soft-deleted without breaking the scenario —
the join row stays and the API flags the test as deleted."""
a = _make_test(client, admin_token, name="linked-then-deleted")
sc = client.post(
"/api/v1/scenario-templates",
headers=_bearer(admin_token),
json={"name": "survives", "test_template_ids": [a["id"]]},
).get_json()
client.delete(f"/api/v1/test-templates/{a['id']}", headers=_bearer(admin_token))
fresh = client.get(
f"/api/v1/scenario-templates/{sc['id']}", headers=_bearer(admin_token)
).get_json()
assert fresh["tests"][0]["test_template_deleted"] is True
def test_scenario_soft_delete(client, admin_token):
sc = client.post(
"/api/v1/scenario-templates",
headers=_bearer(admin_token),
json={"name": "doomed-scn"},
).get_json()
r = client.delete(
f"/api/v1/scenario-templates/{sc['id']}", headers=_bearer(admin_token)
)
assert r.status_code == 200
names = [
it["name"]
for it in client.get(
"/api/v1/scenario-templates", headers=_bearer(admin_token)
).get_json()["items"]
]
assert "doomed-scn" not in names
def test_scenario_perm_required(client, admin_token):
_, eve_token = _bootstrap_user_without_perms(client, admin_token, "scn-eve")
r = client.get("/api/v1/scenario-templates", headers=_bearer(eve_token))
assert r.status_code == 403
# === Post-review fixes ======================================================
def test_list_filter_combines_facets_with_and_semantics(client, admin_token):
"""A template tagged only `TA0002` is NOT in `?tactic=TA0002&technique=T1059`.
Pre-fix the OR-combined query would return it. AND-combined semantics
(one IN subquery per facet) restrict the set to templates matching ALL
requested facets.
"""
a = _make_test(
client,
admin_token,
name="and-tactic-only",
mitre_tags=[{"kind": "tactic", "external_id": "TA0002"}],
)
b = _make_test(
client,
admin_token,
name="and-both-tags",
mitre_tags=[
{"kind": "tactic", "external_id": "TA0002"},
{"kind": "technique", "external_id": "T1059"},
],
)
r = client.get(
"/api/v1/test-templates?tactic=TA0002&technique=T1059",
headers=_bearer(admin_token),
)
assert r.status_code == 200
names = [it["name"] for it in r.get_json()["items"]]
assert "and-both-tags" in names
assert "and-tactic-only" not in names
_ = a, b # silence unused vars from linter
def test_create_test_template_rejects_extra_fields(client, admin_token):
"""`model_config = {"extra": "forbid"}` — unknown fields must 400."""
r = client.post(
"/api/v1/test-templates",
headers=_bearer(admin_token),
json={"name": "extra-test", "rogue_field": "smuggled"},
)
assert r.status_code == 400
def test_update_test_template_explicit_empty_mitre_clears(client, admin_token):
"""`PUT { mitre_tags: [] }` is an explicit clear, not a no-op."""
body = _make_test(
client,
admin_token,
name="clear-tags",
mitre_tags=[{"kind": "technique", "external_id": "T1059"}],
)
assert len(body["mitre_tags"]) == 1
r = client.put(
f"/api/v1/test-templates/{body['id']}",
headers=_bearer(admin_token),
json={"mitre_tags": []},
)
assert r.status_code == 200
assert r.get_json()["mitre_tags"] == []
def test_tag_item_length_capped_at_64(client, admin_token):
"""Individual `tags` items must be ≤ 64 chars at the wire layer."""
long_tag = "x" * 65
r = client.post(
"/api/v1/test-templates",
headers=_bearer(admin_token),
json={"name": "long-tag", "tags": [long_tag]},
)
assert r.status_code == 400

View File

@@ -9,7 +9,9 @@ import { expect, test, type APIRequestContext, type Page } from '@playwright/tes
* + the picker UI. * + the picker UI.
*/ */
const ADMIN_EMAIL = `admin-${Math.floor(Math.random() * 1e6)}@metamorph.local`; // crypto.randomUUID() guarantees uniqueness across parallel test runs; the
// Math.random() previous pattern could collide one-in-a-million in CI.
const ADMIN_EMAIL = `admin-${crypto.randomUUID().slice(0, 8)}@metamorph.local`;
const ADMIN_PASSWORD = 'AdminPass1234!'; const ADMIN_PASSWORD = 'AdminPass1234!';
async function resetAndMintToken(request: APIRequestContext): Promise<string> { async function resetAndMintToken(request: APIRequestContext): Promise<string> {
@@ -55,9 +57,13 @@ test.describe('M4 — MITRE ATT&CK reference', () => {
}); });
expect(sync.status(), `mitre sync failed: ${await sync.text()}`).toBe(200); expect(sync.status(), `mitre sync failed: ${await sync.text()}`).toBe(200);
const result = await sync.json(); const result = await sync.json();
expect(result.tactics_upserted).toBeGreaterThanOrEqual(14); // Pinned exactly to MITRE Enterprise v19.0 — bump alongside MITRE_VERSION
expect(result.techniques_upserted).toBeGreaterThanOrEqual(180); // in `app/services/mitre_seed.py` when the pin changes. Exact counts catch
expect(result.subtechniques_upserted).toBeGreaterThanOrEqual(400); // parser regressions that would silently include revoked/deprecated rows.
expect(result.tactics_upserted).toBe(15);
expect(result.techniques_upserted).toBe(222);
expect(result.subtechniques_upserted).toBe(475);
expect(result.subtechniques_skipped_orphan).toBe(0);
}); });
test('GET /mitre/tactics returns 14+ Enterprise tactics', async ({ request }) => { test('GET /mitre/tactics returns 14+ Enterprise tactics', async ({ request }) => {
@@ -146,7 +152,7 @@ test.describe('M4 — MITRE ATT&CK reference', () => {
test('Non-admin cannot trigger /mitre/sync', async ({ page, request }) => { test('Non-admin cannot trigger /mitre/sync', async ({ page, request }) => {
// Invite a no-perm user via the admin. // Invite a no-perm user via the admin.
const adminAccess = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD); const adminAccess = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
const eveEmail = `eve-${Math.floor(Math.random() * 1e6)}@metamorph.local`; const eveEmail = `eve-${crypto.randomUUID().slice(0, 8)}@metamorph.local`;
const inv = await request.post('/api/v1/invitations', { const inv = await request.post('/api/v1/invitations', {
headers: { Authorization: `Bearer ${adminAccess}` }, headers: { Authorization: `Bearer ${adminAccess}` },
data: { email_hint: eveEmail }, data: { email_hint: eveEmail },

View File

@@ -0,0 +1,253 @@
import { expect, test, type APIRequestContext, type Page } from '@playwright/test';
/**
* M5 — Test + Scenario template catalogue.
*
* Verifies CRUD on /test-templates and /scenario-templates plus the admin SPA
* pages. We do NOT seed the full MITRE bundle here — M4 already covers that
* suite. This spec only needs ONE technique resolvable from a STIX-like
* shape (we ride on the same `/diag/reset` then re-seed MITRE so tag refs
* resolve).
*/
const ADMIN_EMAIL = `admin-${crypto.randomUUID().slice(0, 8)}@metamorph.local`;
const ADMIN_PASSWORD = 'AdminPass1234!';
async function resetAndMintToken(request: APIRequestContext): Promise<string> {
const r = await request.post('/api/v1/diag/reset');
expect(r.status()).toBe(200);
return (await r.json()).install_token as string;
}
async function loginAndGetAccess(
request: APIRequestContext,
email: string,
password: string,
): Promise<string> {
const r = await request.post('/api/v1/auth/login', { data: { email, password } });
expect(r.status()).toBe(200);
return (await r.json()).access_token as string;
}
async function loginViaSpa(page: Page, email: string, password: string) {
await page.goto('/login');
await page.getByLabel(/email/i).fill(email);
await page.getByLabel(/password/i).fill(password);
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page.getByTestId('me-email')).toHaveText(email);
}
test.describe.configure({ mode: 'serial' });
test.describe('M5 — Template catalogue', () => {
test.beforeAll(async ({ request }) => {
const installToken = await resetAndMintToken(request);
const setup = await request.post('/api/v1/setup', {
data: { install_token: installToken, email: ADMIN_EMAIL, password: ADMIN_PASSWORD },
});
expect(setup.status()).toBe(201);
// MITRE re-sync — picker + tag refs rely on the canonical bundle.
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
const sync = await request.post('/api/v1/mitre/sync', {
headers: { Authorization: `Bearer ${access}` },
});
expect(sync.status()).toBe(200);
});
test.afterAll(async ({ request }) => {
// Restore the stable admin (cf. memory feedback_metamorph_test_admin):
// any wipe should leave admin@metamorph.local / AdminPass1234! usable.
const installToken = await resetAndMintToken(request);
await request.post('/api/v1/setup', {
data: {
install_token: installToken,
email: 'admin@metamorph.local',
password: 'AdminPass1234!',
},
});
// Re-seed MITRE so subsequent manual sessions don't see an empty matrix.
const access = await loginAndGetAccess(request, 'admin@metamorph.local', 'AdminPass1234!');
await request.post('/api/v1/mitre/sync', {
headers: { Authorization: `Bearer ${access}` },
});
});
// === API smoke ============================================================
test('CRUD test-templates via API', async ({ request }) => {
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
const auth = { Authorization: `Bearer ${access}` };
// Create
const r1 = await request.post('/api/v1/test-templates', {
headers: auth,
data: {
name: 'phish-link',
description: 'send a phishing email with tracked link',
objective: 'land a click',
procedure_md: '1. craft mail\n2. send\n3. await click',
opsec_level: 'low',
tags: ['phish', 'initial-access'],
expected_iocs: ['phish@example.com'],
mitre_tags: [
{ kind: 'tactic', external_id: 'TA0001' },
{ kind: 'technique', external_id: 'T1566' },
],
},
});
expect(r1.status(), await r1.text()).toBe(201);
const created = await r1.json();
expect(created.name).toBe('phish-link');
expect(created.mitre_tags.length).toBe(2);
expect(created.tags).toContain('phish');
// Update — partial: change opsec only
const r2 = await request.put(`/api/v1/test-templates/${created.id}`, {
headers: auth,
data: { opsec_level: 'high' },
});
expect(r2.status()).toBe(200);
const updated = await r2.json();
expect(updated.opsec_level).toBe('high');
expect(updated.name).toBe('phish-link'); // untouched
// List + filter by tactic
const r3 = await request.get('/api/v1/test-templates?tactic=TA0001', {
headers: auth,
});
expect(r3.status()).toBe(200);
const list = await r3.json();
expect(list.items.map((it: { name: string }) => it.name)).toContain('phish-link');
// Reject unknown MITRE
const r4 = await request.post('/api/v1/test-templates', {
headers: auth,
data: {
name: 'bad',
mitre_tags: [{ kind: 'technique', external_id: 'T9999' }],
},
});
expect(r4.status()).toBe(400);
expect((await r4.json()).error).toBe('unknown_mitre_tag');
// Soft-delete
const r5 = await request.delete(`/api/v1/test-templates/${created.id}`, {
headers: auth,
});
expect(r5.status()).toBe(200);
const r6 = await request.get('/api/v1/test-templates', { headers: auth });
expect(
(await r6.json()).items.map((it: { name: string }) => it.name),
).not.toContain('phish-link');
});
test('Scenario template: create + reorder + soft-delete', async ({ request }) => {
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
const auth = { Authorization: `Bearer ${access}` };
async function mkTest(name: string): Promise<string> {
const r = await request.post('/api/v1/test-templates', {
headers: auth,
data: { name },
});
expect(r.status()).toBe(201);
return (await r.json()).id as string;
}
const a = await mkTest('scn-step-a');
const b = await mkTest('scn-step-b');
const c = await mkTest('scn-step-c');
// Create with [a, b, c]
const r1 = await request.post('/api/v1/scenario-templates', {
headers: auth,
data: { name: 'ordered-scenario', test_template_ids: [a, b, c] },
});
expect(r1.status()).toBe(201);
const sc = await r1.json();
expect(sc.tests.map((t: { test_template_name: string }) => t.test_template_name)).toEqual([
'scn-step-a',
'scn-step-b',
'scn-step-c',
]);
// Reorder → [c, a, b]
const r2 = await request.put(`/api/v1/scenario-templates/${sc.id}/tests`, {
headers: auth,
data: { test_template_ids: [c, a, b] },
});
expect(r2.status()).toBe(200);
const after = await r2.json();
expect(after.tests.map((t: { test_template_name: string }) => t.test_template_name)).toEqual([
'scn-step-c',
'scn-step-a',
'scn-step-b',
]);
// Soft-delete the scenario.
const r3 = await request.delete(`/api/v1/scenario-templates/${sc.id}`, { headers: auth });
expect(r3.status()).toBe(200);
const list = await (await request.get('/api/v1/scenario-templates', { headers: auth })).json();
expect(list.items.map((it: { name: string }) => it.name)).not.toContain('ordered-scenario');
});
// === SPA smoke ============================================================
test('SPA — admin sees the test catalogue and can filter', async ({ page, request }) => {
// Seed two tests up front via the API — exercise the SPA list + filter
// pipeline without fighting the heavy create-modal (covered by API tests).
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
const auth = { Authorization: `Bearer ${access}` };
await request.post('/api/v1/test-templates', {
headers: auth,
data: { name: 'spa-list-fast', opsec_level: 'low', tags: ['fast'] },
});
await request.post('/api/v1/test-templates', {
headers: auth,
data: { name: 'spa-list-slow', opsec_level: 'high' },
});
await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD);
await page.goto('/admin/tests');
await expect(page.getByText('spa-list-fast')).toBeVisible();
await expect(page.getByText('spa-list-slow')).toBeVisible();
await page.getByTestId('filter-opsec').selectOption('high');
await expect(page.getByText('spa-list-slow')).toBeVisible();
await expect(page.getByText('spa-list-fast')).toBeHidden();
});
test('SPA — scenario list shows ordered tests with their position', async ({ page, request }) => {
// Seed a 3-test scenario via the API; the SPA must render the order as
// saved. Pointer-event drag is flaky in CI, and the API-level reorder
// test already covers the persistence pipeline.
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
const auth = { Authorization: `Bearer ${access}` };
const ids: string[] = [];
for (const name of ['drag-1', 'drag-2', 'drag-3']) {
const r = await request.post('/api/v1/test-templates', {
headers: auth,
data: { name },
});
ids.push((await r.json()).id);
}
const scResp = await request.post('/api/v1/scenario-templates', {
headers: auth,
data: {
name: 'spa-rendered-scenario',
test_template_ids: [ids[2], ids[0], ids[1]],
},
});
const scId = (await scResp.json()).id;
await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD);
await page.goto('/admin/scenarios');
const card = page.locator(`[data-testid="scenario-row-${scId}"]`);
await expect(card).toBeVisible();
await expect(card.getByText('1. drag-3')).toBeVisible();
await expect(card.getByText('2. drag-1')).toBeVisible();
await expect(card.getByText('3. drag-2')).toBeVisible();
});
});

View File

@@ -0,0 +1,405 @@
import { expect, test, type APIRequestContext, type Page } from '@playwright/test';
/**
* M6 — Mission CRUD, snapshot fidelity, membership visibility, transitions.
*
* The suite covers:
* - Snapshot independence (mutating a template after mission creation must NOT
* propagate into the mission's snapshot).
* - Membership visibility (non-admin viewers see only their own missions).
* - Status transition state machine (draft → in_progress → completed → archived).
* - SPA: list + 3-step create wizard + detail page tabs.
*
* Template + MITRE seed are pulled in `beforeAll`; the `afterAll` hook restores
* the stable admin (memory rule `feedback-metamorph-test-admin`) and re-seeds
* MITRE so subsequent manual sessions don't see an empty matrix.
*/
const ADMIN_EMAIL = `m6-admin-${crypto.randomUUID().slice(0, 8)}@metamorph.local`;
const ADMIN_PASSWORD = 'AdminPass1234!';
async function resetAndMintToken(request: APIRequestContext): Promise<string> {
const r = await request.post('/api/v1/diag/reset');
expect(r.status()).toBe(200);
return (await r.json()).install_token as string;
}
async function loginAndGetAccess(
request: APIRequestContext,
email: string,
password: string,
): Promise<string> {
const r = await request.post('/api/v1/auth/login', { data: { email, password } });
expect(r.status()).toBe(200);
return (await r.json()).access_token as string;
}
async function loginViaSpa(page: Page, email: string, password: string) {
await page.goto('/login');
await page.getByLabel(/email/i).fill(email);
await page.getByLabel(/password/i).fill(password);
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page.getByTestId('me-email')).toHaveText(email);
}
test.describe.configure({ mode: 'serial' });
test.describe('M6 — Missions', () => {
test.beforeAll(async ({ request }) => {
const installToken = await resetAndMintToken(request);
const setup = await request.post('/api/v1/setup', {
data: { install_token: installToken, email: ADMIN_EMAIL, password: ADMIN_PASSWORD },
});
expect(setup.status()).toBe(201);
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
const sync = await request.post('/api/v1/mitre/sync', {
headers: { Authorization: `Bearer ${access}` },
});
expect(sync.status()).toBe(200);
});
test.afterAll(async ({ request }) => {
const installToken = await resetAndMintToken(request);
await request.post('/api/v1/setup', {
data: {
install_token: installToken,
email: 'admin@metamorph.local',
password: 'AdminPass1234!',
},
});
const access = await loginAndGetAccess(request, 'admin@metamorph.local', 'AdminPass1234!');
await request.post('/api/v1/mitre/sync', {
headers: { Authorization: `Bearer ${access}` },
});
});
// ---------- helpers ----------------------------------------------------
async function adminAuth(request: APIRequestContext): Promise<Record<string, string>> {
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
return { Authorization: `Bearer ${access}` };
}
async function makeTest(
request: APIRequestContext,
auth: Record<string, string>,
name: string,
mitre = 'T1059',
): Promise<string> {
const r = await request.post('/api/v1/test-templates', {
headers: auth,
data: {
name,
mitre_tags: [{ kind: 'technique', external_id: mitre }],
},
});
expect(r.status(), await r.text()).toBe(201);
return (await r.json()).id as string;
}
async function makeScenario(
request: APIRequestContext,
auth: Record<string, string>,
name: string,
testIds: string[],
): Promise<string> {
const r = await request.post('/api/v1/scenario-templates', {
headers: auth,
data: { name, test_template_ids: testIds },
});
expect(r.status(), await r.text()).toBe(201);
return (await r.json()).id as string;
}
// ---------- API: snapshot fidelity ------------------------------------
test('Snapshot freezes scenario + test fields at creation time', async ({ request }) => {
const auth = await adminAuth(request);
const tid = await makeTest(request, auth, 'snap-t1');
const sid = await makeScenario(request, auth, 'snap-scenario', [tid]);
const create = await request.post('/api/v1/missions', {
headers: auth,
data: {
name: 'snap-mission',
client_target: 'Acme',
scenario_template_ids: [sid],
},
});
expect(create.status(), await create.text()).toBe(201);
const mission = await create.json();
expect(mission.scenarios_count).toBe(1);
expect(mission.tests_count).toBe(1);
expect(mission.scenarios[0].tests[0].snapshot_name).toBe('snap-t1');
// Mutate the source template AFTER snapshot
const edit = await request.put(`/api/v1/test-templates/${tid}`, {
headers: auth,
data: {
name: 'RENAMED-LATER',
mitre_tags: [{ kind: 'tactic', external_id: 'TA0002' }],
},
});
expect(edit.status()).toBe(200);
// Mission still sees the pre-edit snapshot
const refetch = await request.get(`/api/v1/missions/${mission.id}`, { headers: auth });
expect(refetch.status()).toBe(200);
const snapshot = await refetch.json();
expect(snapshot.scenarios[0].tests[0].snapshot_name).toBe('snap-t1');
expect(
snapshot.scenarios[0].tests[0].mitre_tags.map(
(t: { external_id: string }) => t.external_id,
),
).toEqual(['T1059']);
});
// ---------- API: membership visibility --------------------------------
test('Non-admin members see only missions they belong to', async ({ request }) => {
const auth = await adminAuth(request);
// Create a group with mission.* perms and invite a "red" user.
const grp = await request
.post('/api/v1/groups', { headers: auth, data: { name: 'm6-red-grp' } })
.then((r) => r.json());
const setPerms = await request.put(`/api/v1/groups/${grp.id}/permissions`, {
headers: auth,
data: {
codes: ['mission.read', 'mission.create', 'mission.update', 'mission.archive'],
},
});
expect(setPerms.status()).toBe(200);
const redEmail = `m6-red-${crypto.randomUUID().slice(0, 8)}@metamorph.local`;
const redPwd = 'RedPass1234!';
const inv = await request
.post('/api/v1/invitations', {
headers: auth,
data: { email_hint: redEmail, group_ids: [grp.id] },
})
.then((r) => r.json());
const accept = await request.post(`/api/v1/invitations/accept/${inv.token}`, {
data: { email: redEmail, password: redPwd },
});
expect(accept.status()).toBe(201);
const redAccess = await loginAndGetAccess(request, redEmail, redPwd);
const redAuth = { Authorization: `Bearer ${redAccess}` };
// Admin creates a mission with NO members → red should not see it.
const hidden = await request
.post('/api/v1/missions', {
headers: auth,
data: { name: 'm6-admin-hidden' },
})
.then((r) => r.json());
const redList = await request.get('/api/v1/missions', { headers: redAuth });
expect(redList.status()).toBe(200);
const visible = (await redList.json()).items.map((it: { name: string }) => it.name);
expect(visible).not.toContain('m6-admin-hidden');
const redGetHidden = await request.get(`/api/v1/missions/${hidden.id}`, {
headers: redAuth,
});
expect(redGetHidden.status()).toBe(404);
// Red creates their own mission — auto-added as member → visible to them.
const ownResp = await request.post('/api/v1/missions', {
headers: redAuth,
data: { name: 'm6-red-own' },
});
expect(ownResp.status(), await ownResp.text()).toBe(201);
const own = await ownResp.json();
expect(own.members.map((m: { user_id: string }) => m.user_id)).toContain(
own.members[0].user_id,
);
const redListAfter = await request.get('/api/v1/missions', { headers: redAuth });
const namesAfter = (await redListAfter.json()).items.map(
(it: { name: string }) => it.name,
);
expect(namesAfter).toContain('m6-red-own');
});
// ---------- API: transitions ------------------------------------------
test('Status transition chain and rejection of invalid jumps', async ({ request }) => {
const auth = await adminAuth(request);
const m = await request
.post('/api/v1/missions', {
headers: auth,
data: { name: 'm6-status-chain' },
})
.then((r) => r.json());
for (const target of ['in_progress', 'completed', 'archived']) {
const r = await request.post(`/api/v1/missions/${m.id}/transition`, {
headers: auth,
data: { status: target },
});
expect(r.status(), await r.text()).toBe(200);
expect((await r.json()).status).toBe(target);
}
// Re-create + try an invalid jump draft → completed (must be 409)
const m2 = await request
.post('/api/v1/missions', {
headers: auth,
data: { name: 'm6-status-jump' },
})
.then((r) => r.json());
const bad = await request.post(`/api/v1/missions/${m2.id}/transition`, {
headers: auth,
data: { status: 'completed' },
});
expect(bad.status()).toBe(409);
expect((await bad.json()).error).toBe('invalid_transition');
});
// ---------- SPA -------------------------------------------------------
test('SPA — admin creates a mission via the 3-step wizard', async ({ page, request }) => {
const auth = await adminAuth(request);
const ids: string[] = [];
for (const name of ['spa-wizard-t1', 'spa-wizard-t2', 'spa-wizard-t3']) {
ids.push(await makeTest(request, auth, name));
}
const sid = await makeScenario(request, auth, 'spa-wizard-scenario', ids);
await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD);
await page.goto('/missions');
await page.getByTestId('missions-new-link').click();
await expect(page).toHaveURL(/\/missions\/new$/);
// Step 1 — Metadata
await page.getByTestId('meta-name').fill('spa-wizard-mission');
await page.getByTestId('meta-client').fill('Acme via SPA');
await page.getByTestId('missions-create-next').click();
// Step 2 — Scenarios
await page.getByTestId(`scenario-toggle-${sid}`).click();
await page.getByTestId('missions-create-next').click();
// Step 3 — Members (admin doesn't need to add themselves; submit straight away)
await page.getByTestId('missions-create-submit').click();
// Should land on the detail page
await expect(page).toHaveURL(/\/missions\/[0-9a-f-]+$/);
await expect(page.getByTestId('mission-transition-in_progress')).toBeVisible();
await expect(page.getByTestId('mission-tab-tests')).toBeVisible();
// Tests tab renders 3 snapshotted tests
await expect(page.getByText('spa-wizard-t1')).toBeVisible();
await expect(page.getByText('spa-wizard-t2')).toBeVisible();
await expect(page.getByText('spa-wizard-t3')).toBeVisible();
});
test('SPA — detail page edits metadata, appends scenarios, edits members', async ({
page,
request,
}) => {
const auth = await adminAuth(request);
// Pre-seed: one mission with one initial scenario; a second scenario to
// append; and a second user we can assign as a member from the SPA.
const initialTestId = await makeTest(request, auth, 'spa-edit-initial-t');
const initialScenarioId = await makeScenario(
request,
auth,
'spa-edit-initial-scenario',
[initialTestId],
);
const extraTestId = await makeTest(request, auth, 'spa-edit-appended-t');
const extraScenarioId = await makeScenario(
request,
auth,
'spa-edit-appended-scenario',
[extraTestId],
);
const mission = await request
.post('/api/v1/missions', {
headers: auth,
data: {
name: 'spa-edit-target',
client_target: 'Initial Co.',
scenario_template_ids: [initialScenarioId],
},
})
.then((r) => r.json());
// A second user the admin can add as a member via the modal.
const teammateEmail = `spa-edit-mate-${crypto.randomUUID().slice(0, 8)}@metamorph.local`;
const inv = await request
.post('/api/v1/invitations', {
headers: auth,
data: { email_hint: teammateEmail },
})
.then((r) => r.json());
await request.post(`/api/v1/invitations/accept/${inv.token}`, {
data: { email: teammateEmail, password: 'MatePass1234!' },
});
await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD);
await page.goto(`/missions/${mission.id}`);
await expect(page.getByText('Initial Co.')).toBeVisible();
// --- Edit metadata --------------------------------------------------
await page.getByTestId('mission-edit-meta').click();
const metaModal = page.getByTestId('mission-edit-meta-modal');
await expect(metaModal).toBeVisible();
await metaModal.getByTestId('meta-edit-client').fill('Renamed Co.');
await metaModal.getByTestId('meta-edit-save').click();
await expect(metaModal).toBeHidden();
await expect(page.getByText('Renamed Co.')).toBeVisible();
// --- Append a scenario ---------------------------------------------
await page.getByTestId('mission-add-scenarios').click();
const addModal = page.getByTestId('mission-add-scenarios-modal');
await expect(addModal).toBeVisible();
await addModal.getByTestId(`add-scenario-toggle-${extraScenarioId}`).click();
await addModal.getByTestId('add-scenarios-save').click();
await expect(addModal).toBeHidden();
// Both scenarios now visible in the Tests tab
await expect(page.getByText('spa-edit-initial-scenario')).toBeVisible();
await expect(page.getByText('spa-edit-appended-scenario')).toBeVisible();
await expect(page.getByText('spa-edit-appended-t')).toBeVisible();
// --- Edit members ---------------------------------------------------
await page.getByTestId('mission-tab-members').click();
await page.getByTestId('mission-edit-members').click();
const memModal = page.getByTestId('mission-edit-members-modal');
await expect(memModal).toBeVisible();
// The roster row test-ids encode the new user's id; we don't know it here
// but the email is unique, so locate the row by email text and toggle red.
const teammateRow = memModal.getByText(teammateEmail).locator('..').locator('..');
await teammateRow.getByRole('button', { name: /red/i }).click();
await memModal.getByTestId('edit-members-save').click();
await expect(memModal).toBeHidden();
await expect(page.getByText(teammateEmail)).toBeVisible();
});
test('SPA — list page filters by status', async ({ page, request }) => {
const auth = await adminAuth(request);
// Seed two missions with distinct statuses.
const m1 = await request
.post('/api/v1/missions', { headers: auth, data: { name: 'filter-draft' } })
.then((r) => r.json());
const m2 = await request
.post('/api/v1/missions', { headers: auth, data: { name: 'filter-active' } })
.then((r) => r.json());
await request.post(`/api/v1/missions/${m2.id}/transition`, {
headers: auth,
data: { status: 'in_progress' },
});
await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD);
await page.goto('/missions');
await expect(page.getByText('filter-draft')).toBeVisible();
await expect(page.getByText('filter-active')).toBeVisible();
await page.getByTestId('missions-filter-status').selectOption('in_progress');
await expect(page.getByText('filter-active')).toBeVisible();
await expect(page.getByText('filter-draft')).toBeHidden();
// Sanity: m1 / m2 ids should match what the list-card test-id encodes.
await expect(page.getByTestId(`mission-card-${m2.id}`)).toBeVisible();
await expect(page.getByTestId(`mission-card-${m1.id}`)).toBeHidden();
});
});

View File

@@ -0,0 +1,436 @@
import { expect, test, type APIRequestContext } from '@playwright/test';
/**
* M7 — Red/blue execution on a mission test.
*
* Scope (cf. tasks/spec.md §M7):
* - Field-level perm gating (write_red_fields vs write_blue_fields).
* - State machine transitions and side-effect on `executed_at`.
* - Evidence upload: 24 MB ok, 26 MB rejected, SHA256 verified.
* - Activity polling endpoint surfaces the last actor.
* - SPA: the per-test page exposes both zones, accepts a small file via the
* dropzone, and shows the "modified by X" badge after a write.
*
* `afterAll` restores `admin@metamorph.local` / `AdminPass1234!` and re-syncs
* MITRE so subsequent manual sessions are not staring at an empty stack.
*/
const ADMIN_EMAIL = `m7-admin-${crypto.randomUUID().slice(0, 8)}@metamorph.local`;
const ADMIN_PASSWORD = 'AdminPass1234!';
async function resetAndMintToken(request: APIRequestContext): Promise<string> {
const r = await request.post('/api/v1/diag/reset');
expect(r.status()).toBe(200);
return (await r.json()).install_token as string;
}
async function login(
request: APIRequestContext,
email: string,
password: string,
): Promise<string> {
const r = await request.post('/api/v1/auth/login', {
data: { email, password },
});
expect(r.status()).toBe(200);
return (await r.json()).access_token as string;
}
async function inviteUser(
request: APIRequestContext,
adminAuth: Record<string, string>,
prefix: string,
groupCodes: string[],
): Promise<{ email: string; password: string; token: string; id: string }> {
const grp = await request.post('/api/v1/groups', {
headers: adminAuth,
data: { name: `${prefix}-${crypto.randomUUID().slice(0, 4)}` },
});
expect(grp.status()).toBe(201);
const grpId = (await grp.json()).id as string;
const setPerms = await request.put(`/api/v1/groups/${grpId}/permissions`, {
headers: adminAuth,
data: { codes: groupCodes },
});
expect(setPerms.status()).toBe(200);
const email = `${prefix}-${crypto.randomUUID().slice(0, 6)}@metamorph.local`;
const inv = await request.post('/api/v1/invitations', {
headers: adminAuth,
data: { email_hint: email, group_ids: [grpId] },
});
expect(inv.status()).toBe(201);
const inviteToken = (await inv.json()).token as string;
const password = 'Pass1234!';
const accept = await request.post(
`/api/v1/invitations/accept/${inviteToken}`,
{ data: { email, password } },
);
expect(accept.status()).toBe(201);
const token = await login(request, email, password);
const me = await request.get('/api/v1/auth/me', {
headers: { Authorization: `Bearer ${token}` },
});
expect(me.status()).toBe(200);
return { email, password, token, id: (await me.json()).id as string };
}
test.describe.configure({ mode: 'serial' });
test.describe('M7 — Test execution', () => {
let templateId = '';
let scenarioId = '';
test.beforeAll(async ({ request }) => {
const installToken = await resetAndMintToken(request);
const setup = await request.post('/api/v1/setup', {
data: {
install_token: installToken,
email: ADMIN_EMAIL,
password: ADMIN_PASSWORD,
},
});
expect(setup.status()).toBe(201);
const access = await login(request, ADMIN_EMAIL, ADMIN_PASSWORD);
const sync = await request.post('/api/v1/mitre/sync', {
headers: { Authorization: `Bearer ${access}` },
});
expect(sync.status()).toBe(200);
const auth = { Authorization: `Bearer ${access}` };
const t = await request.post('/api/v1/test-templates', {
headers: auth,
data: {
name: 'm7-test',
description: 'auto',
objective: 'do thing',
procedure_md: '# steps',
expected_result_red_md: 'red expects',
expected_detection_blue_md: 'blue expects',
opsec_level: 'medium',
tags: [],
expected_iocs: [],
mitre_tags: [{ kind: 'technique', external_id: 'T1059' }],
},
});
expect(t.status()).toBe(201);
templateId = (await t.json()).id as string;
const sc = await request.post('/api/v1/scenario-templates', {
headers: auth,
data: {
name: 'm7-scenario',
description: 'auto',
test_template_ids: [templateId],
},
});
expect(sc.status()).toBe(201);
scenarioId = (await sc.json()).id as string;
});
test.afterAll(async ({ request }) => {
const installToken = await resetAndMintToken(request);
await request.post('/api/v1/setup', {
data: {
install_token: installToken,
email: 'admin@metamorph.local',
password: 'AdminPass1234!',
},
});
const access = await login(
request,
'admin@metamorph.local',
'AdminPass1234!',
);
await request.post('/api/v1/mitre/sync', {
headers: { Authorization: `Bearer ${access}` },
});
});
// -----------------------------------------------------------------------
// API — field-level perm gating + state machine
// -----------------------------------------------------------------------
test('red-only user cannot write blue fields; blue-only user cannot write red', async ({
request,
}) => {
const adminAccess = await login(request, ADMIN_EMAIL, ADMIN_PASSWORD);
const adminAuth = { Authorization: `Bearer ${adminAccess}` };
const red = await inviteUser(request, adminAuth, 'red', [
'mission.read',
'mission.create',
'mission.write_red_fields',
'detection_level.read',
]);
const blue = await inviteUser(request, adminAuth, 'blue', [
'mission.read',
'mission.write_blue_fields',
'detection_level.read',
]);
const mission = await request.post('/api/v1/missions', {
headers: { Authorization: `Bearer ${red.token}` },
data: {
name: 'm7-fields',
scenario_template_ids: [scenarioId],
members: [
{ user_id: red.id, role_hint: 'red' },
{ user_id: blue.id, role_hint: 'blue' },
],
},
});
expect(mission.status()).toBe(201);
const m = await mission.json();
const testId = m.scenarios[0].tests[0].id as string;
const redCannotBlue = await request.put(
`/api/v1/missions/${m.id}/tests/${testId}`,
{
headers: { Authorization: `Bearer ${red.token}` },
data: { blue_comment_md: 'should be blocked' },
},
);
expect(redCannotBlue.status()).toBe(403);
const blueCannotRed = await request.put(
`/api/v1/missions/${m.id}/tests/${testId}`,
{
headers: { Authorization: `Bearer ${blue.token}` },
data: { red_command: 'should be blocked' },
},
);
expect(blueCannotRed.status()).toBe(403);
});
test('mark-executed stamps executed_at and gates reviewed_by_blue to blue side', async ({
request,
}) => {
const adminAccess = await login(request, ADMIN_EMAIL, ADMIN_PASSWORD);
const adminAuth = { Authorization: `Bearer ${adminAccess}` };
const red = await inviteUser(request, adminAuth, 'red', [
'mission.read',
'mission.create',
'mission.write_red_fields',
]);
const blue = await inviteUser(request, adminAuth, 'blue', [
'mission.read',
'mission.write_blue_fields',
]);
const mission = await request.post('/api/v1/missions', {
headers: { Authorization: `Bearer ${red.token}` },
data: {
name: 'm7-state',
scenario_template_ids: [scenarioId],
members: [
{ user_id: red.id, role_hint: 'red' },
{ user_id: blue.id, role_hint: 'blue' },
],
},
});
const m = await mission.json();
const testId = m.scenarios[0].tests[0].id as string;
const execute = await request.post(
`/api/v1/missions/${m.id}/tests/${testId}/transition`,
{
headers: { Authorization: `Bearer ${red.token}` },
data: { target_state: 'executed' },
},
);
expect(execute.status()).toBe(200);
const executedBody = await execute.json();
expect(executedBody.state).toBe('executed');
expect(executedBody.executed_at).not.toBeNull();
// Red cannot review_by_blue.
const redReview = await request.post(
`/api/v1/missions/${m.id}/tests/${testId}/transition`,
{
headers: { Authorization: `Bearer ${red.token}` },
data: { target_state: 'reviewed_by_blue' },
},
);
expect(redReview.status()).toBe(403);
const blueReview = await request.post(
`/api/v1/missions/${m.id}/tests/${testId}/transition`,
{
headers: { Authorization: `Bearer ${blue.token}` },
data: { target_state: 'reviewed_by_blue' },
},
);
expect(blueReview.status()).toBe(200);
expect((await blueReview.json()).state).toBe('reviewed_by_blue');
});
// -----------------------------------------------------------------------
// API — evidence upload
// -----------------------------------------------------------------------
test('evidence upload — 24 MB accepted, 26 MB rejected, SHA256 verified', async ({
request,
}) => {
const adminAccess = await login(request, ADMIN_EMAIL, ADMIN_PASSWORD);
const adminAuth = { Authorization: `Bearer ${adminAccess}` };
const blue = await inviteUser(request, adminAuth, 'blue', [
'mission.read',
'mission.write_blue_fields',
]);
const mission = await request.post('/api/v1/missions', {
headers: adminAuth,
data: {
name: 'm7-evidence',
scenario_template_ids: [scenarioId],
members: [{ user_id: blue.id, role_hint: 'blue' }],
},
});
expect(mission.status()).toBe(201);
const m = await mission.json();
const testId = m.scenarios[0].tests[0].id as string;
const headerOk = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const tail24 = Buffer.alloc(24 * 1024 * 1024 - headerOk.length, 0x41);
const file24 = Buffer.concat([headerOk, tail24]);
const expected = await crypto.subtle.digest('SHA-256', file24);
const expectedHex = Array.from(new Uint8Array(expected))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
const ok = await request.post(
`/api/v1/missions/${m.id}/tests/${testId}/evidence`,
{
headers: { Authorization: `Bearer ${blue.token}` },
multipart: {
file: {
name: 'lab.evtx',
mimeType: 'application/octet-stream',
buffer: file24,
},
},
},
);
expect(ok.status()).toBe(201);
const body = await ok.json();
expect(body.size_bytes).toBe(file24.length);
expect(body.sha256).toBe(expectedHex);
const file26 = Buffer.concat([
headerOk,
Buffer.alloc(26 * 1024 * 1024 - headerOk.length, 0x41),
]);
const tooBig = await request.post(
`/api/v1/missions/${m.id}/tests/${testId}/evidence`,
{
headers: { Authorization: `Bearer ${blue.token}` },
multipart: {
file: {
name: 'huge.evtx',
mimeType: 'application/octet-stream',
buffer: file26,
},
},
},
);
expect(tooBig.status()).toBe(400);
expect((await tooBig.json()).error).toBe('too_large');
const evictGet = await request.get(
`/api/v1/evidence/${body.id}?download=true`,
{ headers: { Authorization: `Bearer ${blue.token}` } },
);
expect(evictGet.status()).toBe(200);
const dlBytes = await evictGet.body();
expect(dlBytes.length).toBe(file24.length);
});
// -----------------------------------------------------------------------
// SPA — per-test page edits & uploads
// -----------------------------------------------------------------------
test('SPA — per-test page: red comment save bumps activity badge', async ({
page,
request,
}) => {
const adminAccess = await login(request, ADMIN_EMAIL, ADMIN_PASSWORD);
const adminAuth = { Authorization: `Bearer ${adminAccess}` };
const red = await inviteUser(request, adminAuth, 'red', [
'mission.read',
'mission.create',
'mission.update',
'mission.write_red_fields',
'detection_level.read',
]);
const mission = await request.post('/api/v1/missions', {
headers: { Authorization: `Bearer ${red.token}` },
data: {
name: 'm7-spa',
scenario_template_ids: [scenarioId],
members: [{ user_id: red.id, role_hint: 'red' }],
},
});
const m = await mission.json();
const testId = m.scenarios[0].tests[0].id as string;
// Log the SPA in as the red user.
await page.goto('/login');
await page.getByLabel(/email/i).fill(red.email);
await page.getByLabel(/password/i).fill(red.password);
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page.getByTestId('me-email')).toHaveText(red.email);
await page.goto(`/missions/${m.id}/tests/${testId}`);
await expect(page.getByTestId('mission-test-page')).toBeVisible();
await expect(page.getByTestId('state-pill')).toContainText(/Not started/);
// Fill the red command + comment, then save. Post-amendement 2026-05-15
// bis: writing red fields implicitly promotes the state — no transition
// button click required.
await page.getByTestId('red-command').fill('whoami /priv');
await page.getByTestId('red-comment').fill('Verified locally');
await page.getByTestId('red-save').click();
await expect(page.getByTestId('state-pill')).toContainText(/Awaiting review/);
// The "last touched" line should now mention the red user.
await expect(page.locator('text=/Last touched/')).toBeVisible();
});
test('SPA — non-member sees 404 message instead of mission content', async ({
page,
request,
}) => {
const adminAccess = await login(request, ADMIN_EMAIL, ADMIN_PASSWORD);
const adminAuth = { Authorization: `Bearer ${adminAccess}` };
const owner = await inviteUser(request, adminAuth, 'own', [
'mission.read',
'mission.create',
'mission.write_red_fields',
]);
const outsider = await inviteUser(request, adminAuth, 'out', [
'mission.read',
]);
const mission = await request.post('/api/v1/missions', {
headers: { Authorization: `Bearer ${owner.token}` },
data: {
name: 'm7-private',
scenario_template_ids: [scenarioId],
},
});
const m = await mission.json();
const testId = m.scenarios[0].tests[0].id as string;
await page.goto('/login');
await page.getByLabel(/email/i).fill(outsider.email);
await page.getByLabel(/password/i).fill(outsider.password);
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page.getByTestId('me-email')).toHaveText(outsider.email);
await page.goto(`/missions/${m.id}/tests/${testId}`);
await expect(
page.locator('text=/Mission test not found/'),
).toBeVisible();
});
});

View File

@@ -13,6 +13,9 @@
"format:check": "prettier --check \"src/**/*.{ts,tsx,css,json,html}\"" "format:check": "prettier --check \"src/**/*.{ts,tsx,css,json,html}\""
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/ibm-plex-sans": "^5.0.20", "@fontsource/ibm-plex-sans": "^5.0.20",
"@fontsource/jetbrains-mono": "^5.0.20", "@fontsource/jetbrains-mono": "^5.0.20",
"@tanstack/react-query": "^5.51.0", "@tanstack/react-query": "^5.51.0",

View File

@@ -6,10 +6,16 @@ import { RequireAdmin } from '@/components/RequireAdmin';
import { RequireAuth } from '@/components/RequireAuth'; import { RequireAuth } from '@/components/RequireAuth';
import { AdminGroupsPage } from '@/pages/AdminGroupsPage'; import { AdminGroupsPage } from '@/pages/AdminGroupsPage';
import { AdminInvitationsPage } from '@/pages/AdminInvitationsPage'; import { AdminInvitationsPage } from '@/pages/AdminInvitationsPage';
import { AdminScenariosPage } from '@/pages/AdminScenariosPage';
import { AdminTestsPage } from '@/pages/AdminTestsPage';
import { AdminUsersPage } from '@/pages/AdminUsersPage'; import { AdminUsersPage } from '@/pages/AdminUsersPage';
import { HomePage } from '@/pages/HomePage'; import { HomePage } from '@/pages/HomePage';
import { MitrePage } from '@/pages/MitrePage'; import { MitrePage } from '@/pages/MitrePage';
import { LoginPage } from '@/pages/LoginPage'; import { LoginPage } from '@/pages/LoginPage';
import { MissionDetailPage } from '@/pages/MissionDetailPage';
import { MissionTestPage } from '@/pages/MissionTestPage';
import { MissionsCreatePage } from '@/pages/MissionsCreatePage';
import { MissionsListPage } from '@/pages/MissionsListPage';
import { ProfilePage } from '@/pages/ProfilePage'; import { ProfilePage } from '@/pages/ProfilePage';
import { RegisterPage } from '@/pages/RegisterPage'; import { RegisterPage } from '@/pages/RegisterPage';
import { SetupPage } from '@/pages/SetupPage'; import { SetupPage } from '@/pages/SetupPage';
@@ -58,6 +64,38 @@ function App() {
</RequireAuth> </RequireAuth>
} }
/> />
<Route
path="/missions"
element={
<RequireAuth>
<MissionsListPage />
</RequireAuth>
}
/>
<Route
path="/missions/new"
element={
<RequireAuth>
<MissionsCreatePage />
</RequireAuth>
}
/>
<Route
path="/missions/:id"
element={
<RequireAuth>
<MissionDetailPage />
</RequireAuth>
}
/>
<Route
path="/missions/:id/tests/:testId"
element={
<RequireAuth>
<MissionTestPage />
</RequireAuth>
}
/>
<Route <Route
path="/admin/users" path="/admin/users"
element={ element={
@@ -82,6 +120,22 @@ function App() {
</RequireAdmin> </RequireAdmin>
} }
/> />
<Route
path="/admin/tests"
element={
<RequireAdmin>
<AdminTestsPage />
</RequireAdmin>
}
/>
<Route
path="/admin/scenarios"
element={
<RequireAdmin>
<AdminScenariosPage />
</RequireAdmin>
}
/>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Route> </Route>
</Routes> </Routes>

View File

@@ -37,11 +37,16 @@ export function Layout() {
{navItem('/', 'Home')} {navItem('/', 'Home')}
{navItem('/profile', 'Profile')} {navItem('/profile', 'Profile')}
{navItem('/mitre', 'MITRE')} {navItem('/mitre', 'MITRE')}
{(state.user.is_admin ||
state.user.permissions.includes('mission.read')) &&
navItem('/missions', 'Missions')}
{state.user.is_admin && ( {state.user.is_admin && (
<> <>
{navItem('/admin/users', 'Users')} {navItem('/admin/users', 'Users')}
{navItem('/admin/groups', 'Groups')} {navItem('/admin/groups', 'Groups')}
{navItem('/admin/invitations', 'Invitations')} {navItem('/admin/invitations', 'Invitations')}
{navItem('/admin/tests', 'Tests')}
{navItem('/admin/scenarios', 'Scenarios')}
</> </>
)} )}
<span className="font-mono text-2xs text-text-dim ml-2" data-testid="me-email"> <span className="font-mono text-2xs text-text-dim ml-2" data-testid="me-email">
@@ -69,7 +74,7 @@ export function Layout() {
<Outlet /> <Outlet />
<footer className="mt-[60px] py-8 border-t border-border text-center font-mono text-xs text-text-dim"> <footer className="mt-[60px] py-8 border-t border-border text-center font-mono text-xs text-text-dim">
metamorph · M0 bootstrap · M1 db schema · M2 auth · M3 rbac · M4 mitre · design system from tasks/design.md metamorph · M0 bootstrap · M1 db schema · M2 auth · M3 rbac · M4 mitre · M5 templates · M6 missions · design system from tasks/design.md
</footer> </footer>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,45 @@
import { useId, type TextareaHTMLAttributes } from 'react';
import { cn } from '@/lib/cn';
interface MarkdownFieldProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'value' | 'onChange'> {
label: string;
value: string;
onChange: (next: string) => void;
rows?: number;
hint?: string;
}
/**
* Markdown-content textarea. We deliberately keep it textarea-only (no fancy
* WYSIWYG editor) — markdown lives well in plain text and the saved blob is
* rendered to HTML at display time (M6/M7 mission pages). The label exposes
* "markdown" so the user knows the field accepts MD syntax.
*/
export function MarkdownField({ label, value, onChange, rows = 6, hint, id, className, ...rest }: MarkdownFieldProps) {
const fallbackId = useId();
const inputId = id ?? fallbackId;
return (
<div className="block">
<label
htmlFor={inputId}
className="block font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim"
>
{label} <span className="text-text-dim/60">· markdown</span>
</label>
<textarea
id={inputId}
rows={rows}
value={value}
onChange={(e) => onChange(e.target.value)}
className={cn(
'mt-1 w-full rounded-md border border-border bg-bg-card px-3 py-2 font-mono text-xs text-text-bright placeholder:text-text-dim',
'focus:border-cyan focus:outline-none',
className,
)}
{...rest}
/>
{hint && <p className="mt-1 font-mono text-2xs text-text-dim">{hint}</p>}
</div>
);
}

View File

@@ -83,7 +83,7 @@ export function MitreTagPicker({ value, onChange, className }: MitreTagPickerPro
} }
return ( return (
<div className={cn('rounded-lg border border-border bg-bg-card p-4', className)} data-testid="mitre-tag-picker"> <div className={cn('rounded-lg border border-border bg-bg-card p-4 min-w-0', className)} data-testid="mitre-tag-picker">
{/* Selection chips */} {/* Selection chips */}
{value.length > 0 && ( {value.length > 0 && (
<div className="mb-3 flex flex-wrap items-center gap-1" data-testid="mitre-selected"> <div className="mb-3 flex flex-wrap items-center gap-1" data-testid="mitre-selected">
@@ -132,9 +132,15 @@ export function MitreTagPicker({ value, onChange, className }: MitreTagPickerPro
aria-label="MITRE ATT&CK matrix" aria-label="MITRE ATT&CK matrix"
/* `minmax(7rem, 1fr)` ensures every cell is wide enough for the /* `minmax(7rem, 1fr)` ensures every cell is wide enough for the
* longest single word in MITRE names (no mid-word breaks), and * longest single word in MITRE names (no mid-word breaks), and
* stretches to fill the container otherwise. Horizontal scroll only * stretches to fill the container otherwise. The wrapper scrolls
* kicks in on narrow viewports below ~1680px. */ * horizontally — placing `overflow-x-auto` on the grid itself fails
className="grid gap-px bg-border rounded overflow-x-auto" * because the grid's intrinsic min-width (15 × 7rem) prevents it
* from shrinking below its parent, so the grid spills out instead
* of scrolling. */
className="overflow-x-auto rounded min-w-0 w-full"
>
<div
className="grid gap-px bg-border"
style={{ style={{
gridTemplateColumns: `repeat(${matrix.data.tactics.length}, minmax(7rem, 1fr))`, gridTemplateColumns: `repeat(${matrix.data.tactics.length}, minmax(7rem, 1fr))`,
}} }}
@@ -276,6 +282,7 @@ export function MitreTagPicker({ value, onChange, className }: MitreTagPickerPro
); );
})} })}
</div> </div>
</div>
)} )}
<p className="mt-3 font-sans text-[11px] text-text-dim"> <p className="mt-3 font-sans text-[11px] text-text-dim">

View File

@@ -4,6 +4,20 @@ import { Button } from '@/components/ui/Button';
import { SectionHeader } from '@/components/ui/SectionHeader'; import { SectionHeader } from '@/components/ui/SectionHeader';
import { type Accent } from '@/lib/cn'; import { type Accent } from '@/lib/cn';
type ModalSize = 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | '7xl';
const SIZE_CLASS: Record<ModalSize, string> = {
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
'3xl': 'max-w-3xl',
'4xl': 'max-w-4xl',
'5xl': 'max-w-5xl',
'6xl': 'max-w-6xl',
'7xl': 'max-w-7xl',
};
interface ModalProps { interface ModalProps {
open: boolean; open: boolean;
title: string; title: string;
@@ -12,14 +26,27 @@ interface ModalProps {
children: ReactNode; children: ReactNode;
/** Optional name to give the dialog role for screen readers / Playwright. */ /** Optional name to give the dialog role for screen readers / Playwright. */
testid?: string; testid?: string;
/** Max-width preset. Defaults to `2xl` to keep historical behavior. */
size?: ModalSize;
} }
/** /**
* Centered modal with a backdrop. Closes on Escape and on backdrop click. * Centered modal with a backdrop. Closes on Escape and on backdrop click.
* The accessible name comes from the SectionHeader's `highlight`, so the dialog * The accessible name comes from the SectionHeader's `highlight`, so the dialog
* can be located via `getByRole('dialog', { name: ... })`. * can be located via `getByRole('dialog', { name: ... })`.
*
* The dialog caps its height at the viewport and scrolls its body internally,
* so tall content (MITRE matrix, long forms) never escapes the viewport.
*/ */
export function Modal({ open, title, accent = 'cyan', onClose, children, testid }: ModalProps) { export function Modal({
open,
title,
accent = 'cyan',
onClose,
children,
testid,
size = '2xl',
}: ModalProps) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
@@ -47,15 +74,15 @@ export function Modal({ open, title, accent = 'cyan', onClose, children, testid
aria-modal="true" aria-modal="true"
aria-label={title} aria-label={title}
data-testid={testid} data-testid={testid}
className="w-full max-w-2xl rounded-lg border border-border bg-bg-base p-6 shadow-2xl" className={`flex w-full ${SIZE_CLASS[size]} max-h-[calc(100vh-2rem)] flex-col rounded-lg border border-border bg-bg-base shadow-2xl`}
> >
<div className="flex items-start justify-between gap-4"> <div className="flex shrink-0 items-start justify-between gap-4 border-b border-border px-6 pt-6 pb-2">
<SectionHeader prefix="Edit" highlight={title} accent={accent} className="mt-0 mb-4" /> <SectionHeader prefix="Edit" highlight={title} accent={accent} className="mt-0 mb-4" />
<Button variant="ghost" onClick={onClose} aria-label="Close dialog"> <Button variant="ghost" onClick={onClose} aria-label="Close dialog">
</Button> </Button>
</div> </div>
{children} <div className="min-w-0 flex-1 overflow-y-auto px-6 py-4">{children}</div>
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,349 @@
/**
* Mission types + query-key factory.
*
* A mission is a *snapshot* of one or more scenario templates: the backend
* copies template fields into mission_* tables at creation time, and template
* edits after that point do not propagate. Types here mirror the server-side
* dataclasses in `app/services/missions.py`.
*/
export type MissionStatus = 'draft' | 'in_progress' | 'completed' | 'archived';
export type MissionRoleHint = 'red' | 'blue';
export type MissionTestState =
| 'pending'
| 'executed'
| 'reviewed_by_blue'
| 'skipped'
| 'blocked';
export type MissionVisibilityMode = 'whitebox' | 'titles_only' | 'executed_only';
export type MissionMitreKind = 'tactic' | 'technique' | 'subtechnique';
export type MissionOpsecLevel = 'low' | 'medium' | 'high';
export interface MissionMember {
user_id: string;
user_email: string;
user_display_name: string | null;
role_hint: MissionRoleHint;
}
export interface MissionMitreTag {
kind: MissionMitreKind;
external_id: string;
name: string;
url: string | null;
}
export interface MissionTest {
id: string;
position: number;
snapshot_name: string;
snapshot_description: string | null;
snapshot_objective: string | null;
snapshot_procedure_md: string | null;
snapshot_prerequisites_md: string | null;
snapshot_expected_red_md: string | null;
snapshot_expected_blue_md: string | null;
snapshot_opsec_level: MissionOpsecLevel;
snapshot_tags: string[];
snapshot_expected_iocs: string[];
state: MissionTestState;
executed_at: string | null;
executed_at_overridden: boolean;
mitre_tags: MissionMitreTag[];
source_test_template_id: string | null;
// Annotation fields surfaced server-side post-M7 so the scenario table
// renders in a single GET (cf. CHANGELOG 2026-05-15 amendment).
red_command: string | null;
red_output: string | null;
red_comment_md: string | null;
blue_comment_md: string | null;
detection_level_id: string | null;
detection_level_key: string | null;
blue_log_source: string | null;
blue_siem_logs: string | null;
blue_incident_at: string | null;
blue_incident_number: string | null;
blue_incident_recipient_email: string | null;
last_actor_id: string | null;
last_actor_email: string | null;
last_actor_display_name: string | null;
updated_at: string;
}
export interface MissionScenario {
id: string;
position: number;
snapshot_name: string;
snapshot_description: string | null;
tests: MissionTest[];
source_scenario_template_id: string | null;
}
export interface MissionListItem {
id: string;
name: string;
client_target: string | null;
date_start: string | null;
date_end: string | null;
status: MissionStatus;
description_md: string | null;
visibility_mode: MissionVisibilityMode;
scenarios_count: number;
tests_count: number;
members_count: number;
deleted_at: string | null;
created_at: string;
updated_at: string;
}
export interface Mission extends MissionListItem {
scenarios: MissionScenario[];
members: MissionMember[];
}
export interface MissionListResponse {
items: MissionListItem[];
total: number;
limit: number;
offset: number;
}
export interface MissionFilters {
q?: string;
status?: MissionStatus | '';
client?: string;
}
export interface MemberPayload {
user_id: string;
role_hint: MissionRoleHint;
}
export interface CreateMissionPayload {
name: string;
client_target?: string | null;
date_start?: string | null;
date_end?: string | null;
description_md?: string | null;
scenario_template_ids?: string[];
members?: MemberPayload[];
}
export interface UpdateMissionPayload {
name?: string;
client_target?: string | null;
date_start?: string | null;
date_end?: string | null;
description_md?: string | null;
}
export interface AddScenariosPayload {
scenario_template_ids: string[];
}
export interface SetMembersPayload {
members: MemberPayload[];
}
export interface TransitionPayload {
status: MissionStatus;
}
export const missionKeys = {
/** Prefix-only key — pass this to `invalidateQueries` to refresh every
* filtered variant. Matching is prefix-based: `['missions','list',{q:'x'}]`
* also gets invalidated.
*/
listPrefix: () => ['missions', 'list'] as const,
list: (filters?: MissionFilters) => ['missions', 'list', filters ?? {}] as const,
detail: (id: string) => ['missions', 'detail', id] as const,
};
export function buildMissionQueryString(filters: MissionFilters | undefined): string {
if (!filters) return '';
const params = new URLSearchParams();
if (filters.q) params.set('q', filters.q);
if (filters.status) params.set('status', filters.status);
if (filters.client) params.set('client', filters.client);
const s = params.toString();
return s ? `?${s}` : '';
}
export const MISSION_STATUS_ACCENT: Record<MissionStatus, 'cyan' | 'orange' | 'green' | 'teal'> = {
draft: 'cyan',
in_progress: 'orange',
completed: 'green',
archived: 'teal',
};
export const MISSION_STATUS_LABEL: Record<MissionStatus, string> = {
draft: 'Draft',
in_progress: 'In Progress',
completed: 'Completed',
archived: 'Archived',
};
// =========================================================================== //
// M7 — per-test execution
// =========================================================================== //
export interface DetectionLevel {
id: string;
key: string;
label_fr: string;
label_en: string;
color_token: string;
position: number;
is_default: boolean;
is_system: boolean;
}
export interface DetectionLevelList {
items: DetectionLevel[];
}
export interface MissionTestEvidence {
id: string;
mission_test_id: string;
sha256: string;
mime: string;
size_bytes: number;
original_filename: string;
uploaded_by_user_id: string | null;
uploaded_by_email: string | null;
uploaded_by_display_name: string | null;
uploaded_at: string;
created_at: string;
}
export interface MissionTestDetail {
id: string;
mission_id: string;
scenario_id: string;
position: number;
snapshot_name: string;
snapshot_description: string | null;
snapshot_objective: string | null;
snapshot_procedure_md: string | null;
snapshot_prerequisites_md: string | null;
snapshot_expected_red_md: string | null;
snapshot_expected_blue_md: string | null;
snapshot_opsec_level: MissionOpsecLevel;
snapshot_tags: string[];
snapshot_expected_iocs: string[];
state: MissionTestState;
executed_at: string | null;
executed_at_overridden: boolean;
red_command: string | null;
red_output: string | null;
red_comment_md: string | null;
blue_comment_md: string | null;
detection_level_id: string | null;
detection_level_key: string | null;
blue_log_source: string | null;
blue_siem_logs: string | null;
blue_incident_at: string | null;
blue_incident_number: string | null;
blue_incident_recipient_email: string | null;
last_actor_id: string | null;
last_actor_email: string | null;
last_actor_display_name: string | null;
updated_at: string;
mitre_tags: MissionMitreTag[];
evidence: MissionTestEvidence[];
}
export interface UpdateMissionTestPayload {
red_command?: string | null;
red_output?: string | null;
red_comment_md?: string | null;
blue_comment_md?: string | null;
detection_level_id?: string | null;
executed_at?: string | null;
executed_at_overridden?: boolean;
blue_log_source?: string | null;
blue_siem_logs?: string | null;
blue_incident_at?: string | null;
blue_incident_number?: string | null;
blue_incident_recipient_email?: string | null;
}
export interface TestTransitionPayload {
target_state: MissionTestState;
}
export interface ActivityEntry {
test_id: string;
scenario_id: string;
state: MissionTestState;
updated_at: string;
last_actor_id: string | null;
last_actor_email: string | null;
last_actor_display_name: string | null;
}
export interface ActivityResponse {
items: ActivityEntry[];
server_time: string;
}
export const missionTestKeys = {
detail: (missionId: string, testId: string) =>
['missions', 'detail', missionId, 'tests', testId] as const,
activity: (missionId: string) => ['missions', missionId, 'activity'] as const,
detectionLevels: () => ['detection-levels'] as const,
};
/**
* Evidence upload constraints. Mirror of the backend whitelist in
* `app/services/evidence.py` — kept in sync by hand because exposing the
* full whitelist via an endpoint would be a one-trip-too-many on every
* page mount. If the backend list changes, update this array too.
*/
export const EVIDENCE_ALLOWED_EXTENSIONS = [
'.png',
'.jpg',
'.jpeg',
'.pdf',
'.txt',
'.log',
'.json',
'.csv',
'.evtx',
'.zip',
] as const;
export const EVIDENCE_MAX_BYTES = 25 * 1024 * 1024;
// Post-amendement 2026-05-15 bis: the explicit workflow is gone from the
// UI — the state column survives in the DB but the labels describe the
// implicit lifecycle (driven by which side has written data).
export const MISSION_TEST_STATE_LABEL: Record<MissionTestState, string> = {
pending: 'Not started',
executed: 'Awaiting review',
reviewed_by_blue: 'Reviewed',
skipped: 'Skipped',
blocked: 'Blocked',
};
export const MISSION_TEST_STATE_ACCENT: Record<
MissionTestState,
'teal' | 'orange' | 'green' | 'rose' | 'red'
> = {
pending: 'teal',
executed: 'orange',
reviewed_by_blue: 'green',
skipped: 'rose',
blocked: 'red',
};
// Front-end mirror of the backend state-machine matrix so the UI only renders
// the transitions the server will accept. Keep this in sync with
// `app/services/mission_tests.py::_VALID_TRANSITIONS`.
export const VALID_TEST_TRANSITIONS: Record<MissionTestState, MissionTestState[]> = {
pending: ['executed', 'skipped', 'blocked'],
executed: ['reviewed_by_blue', 'pending'],
reviewed_by_blue: ['executed'],
skipped: ['pending'],
blocked: ['pending'],
};

View File

@@ -51,6 +51,10 @@ export interface MitreTag {
name: string; name: string;
} }
// Query keys. `status` + `matrix` drive the M4 picker; the per-list factories
// (`tactics`/`techniques`/`subtechniques`) are unused today but the M5
// template forms will consume them for the standalone REST endpoints when
// users edit a single test's tags inline.
export const mitreKeys = { export const mitreKeys = {
status: ['mitre', 'status'] as const, status: ['mitre', 'status'] as const,
matrix: ['mitre', 'matrix'] as const, matrix: ['mitre', 'matrix'] as const,
@@ -85,3 +89,17 @@ export interface MatrixTactic {
export interface MitreMatrix { export interface MitreMatrix {
tactics: MatrixTactic[]; tactics: MatrixTactic[];
} }
/** Mirror of backend `SyncResultOut` (`api/mitre.py`). */
export interface MitreSyncResult {
tactics_upserted: number;
techniques_upserted: number;
subtechniques_upserted: number;
subtechniques_skipped_orphan: number;
technique_tactic_links: number;
version: string | null;
source: string;
started_at: string;
finished_at: string;
duration_ms: number;
}

View File

@@ -0,0 +1,136 @@
/**
* Shared types + query-key factory for the M5 template catalogue.
*
* Two resources: `test_templates` (atomic test units) and `scenario_templates`
* (ordered lists of tests). Both back the admin pages and feed the M6 mission
* wizard.
*/
import type { MitreTagKind } from './mitre';
export type OpsecLevel = 'low' | 'medium' | 'high';
export interface MitreTagOut {
kind: MitreTagKind;
external_id: string;
name: string;
url: string | null;
}
export interface MitreTagInWire {
kind: MitreTagKind;
external_id: string;
}
export interface TestTemplate {
id: string;
name: string;
description: string | null;
objective: string | null;
procedure_md: string | null;
prerequisites_md: string | null;
expected_result_red_md: string | null;
expected_detection_blue_md: string | null;
opsec_level: OpsecLevel;
tags: string[];
expected_iocs: string[];
mitre_tags: MitreTagOut[];
deleted_at: string | null;
created_at: string;
updated_at: string;
}
export interface TestTemplateListResponse {
items: TestTemplate[];
total: number;
limit: number;
offset: number;
}
export interface CreateTestTemplatePayload {
name: string;
description?: string | null;
objective?: string | null;
procedure_md?: string | null;
prerequisites_md?: string | null;
expected_result_red_md?: string | null;
expected_detection_blue_md?: string | null;
opsec_level?: OpsecLevel;
tags?: string[];
expected_iocs?: string[];
mitre_tags?: MitreTagInWire[];
}
export type UpdateTestTemplatePayload = Partial<CreateTestTemplatePayload>;
export interface ScenarioTest {
position: number;
test_template_id: string;
test_template_name: string;
test_template_deleted: boolean;
}
export interface ScenarioTemplate {
id: string;
name: string;
description: string | null;
tests: ScenarioTest[];
tests_count: number;
deleted_at: string | null;
created_at: string;
updated_at: string;
}
export interface ScenarioTemplateListResponse {
items: ScenarioTemplate[];
total: number;
limit: number;
offset: number;
}
export interface CreateScenarioPayload {
name: string;
description?: string | null;
test_template_ids?: string[];
}
export interface UpdateScenarioPayload {
name?: string;
description?: string | null;
}
export interface SetScenarioTestsPayload {
test_template_ids: string[];
}
export interface TestTemplateFilters {
q?: string;
tactic?: string;
technique?: string;
subtechnique?: string;
opsec?: OpsecLevel | '';
tag?: string;
}
export const templateKeys = {
// Test templates
tests: (filters?: TestTemplateFilters) =>
['templates', 'tests', filters ?? {}] as const,
test: (id: string) => ['templates', 'tests', id] as const,
// Scenario templates
scenarios: (q?: string) => ['templates', 'scenarios', q ?? ''] as const,
scenario: (id: string) => ['templates', 'scenarios', id] as const,
};
export function buildTestQueryString(filters: TestTemplateFilters | undefined): string {
if (!filters) return '';
const params = new URLSearchParams();
if (filters.q) params.set('q', filters.q);
if (filters.tactic) params.set('tactic', filters.tactic);
if (filters.technique) params.set('technique', filters.technique);
if (filters.subtechnique) params.set('subtechnique', filters.subtechnique);
if (filters.opsec) params.set('opsec', filters.opsec);
if (filters.tag) params.set('tag', filters.tag);
const s = params.toString();
return s ? `?${s}` : '';
}

View File

@@ -0,0 +1,443 @@
import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { Alert } from '@/components/ui/Alert';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Modal } from '@/components/ui/Modal';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { Tag } from '@/components/ui/Tag';
import { TextField } from '@/components/ui/TextField';
import {
ApiError,
apiDelete,
apiGet,
apiPatch,
apiPost,
apiPut,
} from '@/lib/api';
import {
templateKeys,
type CreateScenarioPayload,
type ScenarioTemplate,
type ScenarioTemplateListResponse,
type TestTemplate,
type TestTemplateListResponse,
} from '@/lib/templates';
interface FormState {
name: string;
description: string;
test_ids: string[];
}
function blankForm(): FormState {
return { name: '', description: '', test_ids: [] };
}
function toForm(sc: ScenarioTemplate): FormState {
return {
name: sc.name,
description: sc.description ?? '',
test_ids: sc.tests.map((t) => t.test_template_id),
};
}
function useScenarios(q: string) {
return useQuery({
queryKey: templateKeys.scenarios(q),
queryFn: () =>
apiGet<ScenarioTemplateListResponse>(
`/scenario-templates${q ? `?q=${encodeURIComponent(q)}` : ''}`,
),
});
}
function useTestCatalogue() {
return useQuery({
queryKey: templateKeys.tests({}),
queryFn: () => apiGet<TestTemplateListResponse>('/test-templates?limit=500'),
});
}
interface SortableTestRowProps {
id: string;
index: number;
name: string;
onRemove: () => void;
}
function SortableTestRow({ id, index, name, onRemove }: SortableTestRowProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<li
ref={setNodeRef}
style={style}
className="flex items-center gap-2 rounded-md border border-border bg-bg-card px-3 py-2"
data-testid={`scenario-test-row-${id}`}
>
<button
type="button"
{...attributes}
{...listeners}
className="cursor-grab font-mono text-text-dim hover:text-text-bright active:cursor-grabbing"
aria-label={`Drag ${name}`}
data-testid={`drag-handle-${id}`}
>
</button>
<span className="font-mono text-2xs text-text-dim w-6">{String(index + 1).padStart(2, '0')}</span>
<span className="font-mono text-xs text-text-bright flex-1">{name}</span>
<Button variant="ghost" accent="rose" onClick={onRemove} aria-label={`Remove ${name}`}>
</Button>
</li>
);
}
export function AdminScenariosPage() {
const qc = useQueryClient();
const [q, setQ] = useState('');
const [creating, setCreating] = useState(false);
const [editing, setEditing] = useState<ScenarioTemplate | null>(null);
const [form, setForm] = useState<FormState>(blankForm());
const [error, setError] = useState<string | null>(null);
const scenarios = useScenarios(q);
const catalogue = useTestCatalogue();
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const create = useMutation({
mutationFn: (payload: CreateScenarioPayload) =>
apiPost<ScenarioTemplate>('/scenario-templates', payload),
onSuccess: async () => {
setCreating(false);
setForm(blankForm());
setError(null);
await qc.invalidateQueries({ queryKey: ['templates', 'scenarios'] });
},
onError: (e) => setError(humanError(e)),
});
// updateMeta and setTests both invalidate on success so a partial failure
// (metadata saved, reorder rejected) still leaves the cache consistent
// with whichever step landed.
const updateMeta = useMutation({
mutationFn: ({ id, name, description }: { id: string; name: string; description: string | null }) =>
apiPatch<ScenarioTemplate>(`/scenario-templates/${id}`, { name, description }),
onSettled: () => qc.invalidateQueries({ queryKey: ['templates', 'scenarios'] }),
});
const setTests = useMutation({
mutationFn: ({ id, test_template_ids }: { id: string; test_template_ids: string[] }) =>
apiPut<ScenarioTemplate>(`/scenario-templates/${id}/tests`, { test_template_ids }),
onSettled: () => qc.invalidateQueries({ queryKey: ['templates', 'scenarios'] }),
});
const remove = useMutation({
mutationFn: (id: string) => apiDelete<{ ok: boolean }>(`/scenario-templates/${id}`),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ['templates', 'scenarios'] });
},
});
function openCreate() {
setForm(blankForm());
setError(null);
setCreating(true);
}
function openEdit(sc: ScenarioTemplate) {
setForm(toForm(sc));
setError(null);
setEditing(sc);
}
function onDragEnd(e: DragEndEvent) {
const { active, over } = e;
if (!over || active.id === over.id) return;
setForm((f) => {
const from = f.test_ids.indexOf(String(active.id));
const to = f.test_ids.indexOf(String(over.id));
if (from < 0 || to < 0) return f;
return { ...f, test_ids: arrayMove(f.test_ids, from, to) };
});
}
async function submit() {
setError(null);
if (!form.name.trim()) {
setError('Name is required.');
return;
}
try {
if (editing) {
// Two-step: metadata first, then ordered tests.
await updateMeta.mutateAsync({
id: editing.id,
name: form.name.trim(),
description: form.description || null,
});
await setTests.mutateAsync({ id: editing.id, test_template_ids: form.test_ids });
} else {
await create.mutateAsync({
name: form.name.trim(),
description: form.description || null,
test_template_ids: form.test_ids,
});
}
await qc.invalidateQueries({ queryKey: ['templates', 'scenarios'] });
setEditing(null);
setCreating(false);
} catch (e) {
setError(humanError(e));
}
}
const isModalOpen = creating || editing !== null;
const testNameById = useMemo(() => {
const map = new Map<string, string>();
catalogue.data?.items.forEach((t) => map.set(t.id, t.name));
return map;
}, [catalogue.data]);
// Tests offered for inclusion. The same test_template MAY appear multiple
// times in a scenario (chained operations are a real purple-team pattern,
// cf. `scenario_template_tests` UNIQUE on `(scenario_id, position)`, not
// on `test_template_id`). So we do NOT exclude already-picked items —
// only soft-deleted ones, which the backend would reject.
const availableTests = useMemo<TestTemplate[]>(() => {
if (!catalogue.data) return [];
return catalogue.data.items.filter((t) => !t.deleted_at);
}, [catalogue.data]);
return (
<>
<SectionHeader
prefix="Admin"
highlight="Scenarios"
accent="purple"
description="Ordered playbooks composed from the test catalogue. Drag rows to reorder; the order is the execution sequence."
/>
<div className="mb-6 flex flex-wrap items-end gap-3">
<TextField
label="Search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="name or description"
data-testid="scenarios-search"
/>
<Button accent="purple" onClick={openCreate} data-testid="create-scenario" className="ml-auto">
+ New scenario
</Button>
</div>
{scenarios.isError && <Alert accent="red">Failed to load scenarios.</Alert>}
<div className="grid gap-3" data-testid="scenarios-list">
{scenarios.isLoading && <p className="font-mono text-xs text-text-dim">Loading</p>}
{scenarios.data?.items.map((sc) => (
<Card
key={sc.id}
accent="purple"
title={sc.name}
sub={sc.description ?? '—'}
data-testid={`scenario-row-${sc.id}`}
>
<div className="flex flex-wrap items-center gap-2">
<Tag accent="purple">{sc.tests_count} test{sc.tests_count === 1 ? '' : 's'}</Tag>
{sc.tests.slice(0, 4).map((t) => (
<Tag key={`${sc.id}:${t.position}`} accent={t.test_template_deleted ? 'rose' : 'cyan'}>
{t.position + 1}. {t.test_template_name}
</Tag>
))}
{sc.tests.length > 4 && (
<Tag accent="yellow">+{sc.tests.length - 4} more</Tag>
)}
<div className="ml-auto flex gap-2">
<Button accent="purple" onClick={() => openEdit(sc)} data-testid={`edit-scenario-${sc.id}`}>
Edit
</Button>
<Button
accent="rose"
variant="ghost"
onClick={() => {
if (window.confirm(`Soft-delete "${sc.name}"?`)) remove.mutate(sc.id);
}}
data-testid={`delete-scenario-${sc.id}`}
>
Delete
</Button>
</div>
</div>
</Card>
))}
{scenarios.data && scenarios.data.items.length === 0 && !scenarios.isLoading && (
<p className="font-mono text-2xs text-text-dim">No scenarios yet.</p>
)}
</div>
<Modal
open={isModalOpen}
title={editing ? `Scenario · ${editing.name}` : 'New scenario template'}
accent="purple"
size="3xl"
onClose={() => {
setCreating(false);
setEditing(null);
}}
testid="scenario-template-modal"
>
{error && <Alert accent="red" className="mb-3">{error}</Alert>}
<div className="grid gap-3">
<TextField
label="Name"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
data-testid="form-scenario-name"
/>
<TextField
label="Description"
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
data-testid="form-scenario-description"
/>
<div>
<p className="font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim mb-2">
Tests in order ({form.test_ids.length})
</p>
{form.test_ids.length === 0 ? (
<p className="font-mono text-2xs text-text-dim mb-2">No test picked yet.</p>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext items={form.test_ids} strategy={verticalListSortingStrategy}>
<ol className="grid gap-2 mb-3" data-testid="scenario-tests-ordered">
{form.test_ids.map((id, idx) => (
<SortableTestRow
key={id}
id={id}
index={idx}
name={testNameById.get(id) ?? '<missing>'}
onRemove={() =>
setForm((f) => ({ ...f, test_ids: f.test_ids.filter((t) => t !== id) }))
}
/>
))}
</ol>
</SortableContext>
</DndContext>
)}
<CataloguePicker
tests={availableTests}
onAdd={(id) => setForm((f) => ({ ...f, test_ids: [...f.test_ids, id] }))}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
variant="ghost"
onClick={() => {
setCreating(false);
setEditing(null);
}}
>
Cancel
</Button>
<Button
accent="purple"
onClick={submit}
disabled={create.isPending || updateMeta.isPending || setTests.isPending}
data-testid="form-scenario-submit"
>
{editing ? 'Save' : 'Create'}
</Button>
</div>
</div>
</Modal>
</>
);
}
interface CataloguePickerProps {
tests: TestTemplate[];
onAdd: (id: string) => void;
}
function CataloguePicker({ tests, onAdd }: CataloguePickerProps) {
const [q, setQ] = useState('');
const filtered = useMemo(() => {
const norm = q.trim().toLowerCase();
if (!norm) return tests.slice(0, 50);
return tests.filter((t) => t.name.toLowerCase().includes(norm)).slice(0, 50);
}, [tests, q]);
return (
<div className="rounded-md border border-border bg-bg-card p-3">
<p className="font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim mb-2">
Add a test from the catalogue
</p>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search catalogue…"
className="mb-2 w-full rounded-md border border-border bg-bg-base px-3 py-2 font-mono text-xs text-text-bright placeholder:text-text-dim focus:border-cyan focus:outline-none"
data-testid="scenario-catalogue-search"
/>
<ul className="max-h-48 overflow-auto grid gap-1" data-testid="scenario-catalogue-list">
{filtered.map((t) => (
<li key={t.id} className="flex items-center gap-2">
<button
type="button"
onClick={() => onAdd(t.id)}
className="flex-1 text-left rounded-sm px-2 py-1 font-mono text-xs text-text-bright hover:bg-bg-base"
data-testid={`catalogue-add-${t.id}`}
>
+ {t.name}
</button>
</li>
))}
{filtered.length === 0 && (
<li className="font-mono text-2xs text-text-dim">No matching test in the catalogue.</li>
)}
</ul>
</div>
);
}
function humanError(e: unknown): string {
if (e instanceof ApiError) {
const p = e.payload as { error?: string; message?: string } | null;
return p?.message ?? p?.error ?? `HTTP ${e.status}`;
}
return e instanceof Error ? e.message : 'Unexpected error';
}

View File

@@ -0,0 +1,404 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { MarkdownField } from '@/components/MarkdownField';
import { MitreTagPicker } from '@/components/MitreTagPicker';
import { Alert } from '@/components/ui/Alert';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Modal } from '@/components/ui/Modal';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { Tag } from '@/components/ui/Tag';
import { TextField } from '@/components/ui/TextField';
import {
ApiError,
apiDelete,
apiGet,
apiPost,
apiPut,
} from '@/lib/api';
import { type MitreTag, type MitreTagKind } from '@/lib/mitre';
import {
buildTestQueryString,
templateKeys,
type CreateTestTemplatePayload,
type OpsecLevel,
type TestTemplate,
type TestTemplateFilters,
type TestTemplateListResponse,
} from '@/lib/templates';
const OPSEC_LEVELS: OpsecLevel[] = ['low', 'medium', 'high'];
interface FormState {
name: string;
description: string;
objective: string;
procedure_md: string;
prerequisites_md: string;
expected_result_red_md: string;
expected_detection_blue_md: string;
opsec_level: OpsecLevel;
tags: string;
expected_iocs: string;
mitre_tags: MitreTag[];
}
function blankForm(): FormState {
return {
name: '',
description: '',
objective: '',
procedure_md: '',
prerequisites_md: '',
expected_result_red_md: '',
expected_detection_blue_md: '',
opsec_level: 'medium',
tags: '',
expected_iocs: '',
mitre_tags: [],
};
}
function toForm(t: TestTemplate): FormState {
return {
name: t.name,
description: t.description ?? '',
objective: t.objective ?? '',
procedure_md: t.procedure_md ?? '',
prerequisites_md: t.prerequisites_md ?? '',
expected_result_red_md: t.expected_result_red_md ?? '',
expected_detection_blue_md: t.expected_detection_blue_md ?? '',
opsec_level: t.opsec_level,
tags: t.tags.join(', '),
expected_iocs: t.expected_iocs.join(', '),
mitre_tags: t.mitre_tags.map((tag) => ({
kind: tag.kind as MitreTagKind,
id: tag.external_id,
external_id: tag.external_id,
name: tag.name,
})),
};
}
function csvToList(s: string): string[] {
return s.split(',').map((x) => x.trim()).filter(Boolean);
}
function toPayload(form: FormState): CreateTestTemplatePayload {
return {
name: form.name.trim(),
description: form.description || null,
objective: form.objective || null,
procedure_md: form.procedure_md || null,
prerequisites_md: form.prerequisites_md || null,
expected_result_red_md: form.expected_result_red_md || null,
expected_detection_blue_md: form.expected_detection_blue_md || null,
opsec_level: form.opsec_level,
tags: csvToList(form.tags),
expected_iocs: csvToList(form.expected_iocs),
mitre_tags: form.mitre_tags.map((t) => ({ kind: t.kind, external_id: t.external_id })),
};
}
function useTestTemplates(filters: TestTemplateFilters) {
return useQuery({
queryKey: templateKeys.tests(filters),
queryFn: () =>
apiGet<TestTemplateListResponse>(`/test-templates${buildTestQueryString(filters)}`),
});
}
export function AdminTestsPage() {
const qc = useQueryClient();
const [filters, setFilters] = useState<TestTemplateFilters>({});
const [editing, setEditing] = useState<TestTemplate | null>(null);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState<FormState>(blankForm());
const [error, setError] = useState<string | null>(null);
const tests = useTestTemplates(filters);
const create = useMutation({
mutationFn: (payload: CreateTestTemplatePayload) =>
apiPost<TestTemplate>('/test-templates', payload),
onSuccess: async () => {
setCreating(false);
setForm(blankForm());
setError(null);
await qc.invalidateQueries({ queryKey: ['templates', 'tests'] });
},
onError: (e) => setError(humanError(e)),
});
const update = useMutation({
mutationFn: ({ id, payload }: { id: string; payload: CreateTestTemplatePayload }) =>
apiPut<TestTemplate>(`/test-templates/${id}`, payload),
onSuccess: async () => {
setEditing(null);
setError(null);
await qc.invalidateQueries({ queryKey: ['templates', 'tests'] });
},
onError: (e) => setError(humanError(e)),
});
const remove = useMutation({
mutationFn: (id: string) => apiDelete<{ ok: boolean }>(`/test-templates/${id}`),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ['templates', 'tests'] });
},
});
function openCreate() {
setForm(blankForm());
setError(null);
setCreating(true);
}
function openEdit(t: TestTemplate) {
setForm(toForm(t));
setError(null);
setEditing(t);
}
function submit() {
const payload = toPayload(form);
if (!payload.name) {
setError('Name is required.');
return;
}
if (editing) update.mutate({ id: editing.id, payload });
else create.mutate(payload);
}
const isModalOpen = creating || editing !== null;
return (
<>
<SectionHeader
prefix="Admin"
highlight="Tests"
accent="orange"
description="Reusable test units. Each test belongs to a scenario at instantiation time, but the catalogue lives independently."
/>
<div className="mb-6 flex flex-wrap items-end gap-3" data-testid="tests-filters">
<TextField
label="Search"
placeholder="name or description"
value={filters.q ?? ''}
onChange={(e) => setFilters((f) => ({ ...f, q: e.target.value || undefined }))}
data-testid="filter-q"
/>
<TextField
label="Tactic external_id"
placeholder="TA0006"
value={filters.tactic ?? ''}
onChange={(e) => setFilters((f) => ({ ...f, tactic: e.target.value || undefined }))}
data-testid="filter-tactic"
/>
<label className="block">
<span className="block font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim">
OPSEC
</span>
<select
value={filters.opsec ?? ''}
onChange={(e) =>
setFilters((f) => ({ ...f, opsec: (e.target.value as OpsecLevel | '') || undefined }))
}
className="mt-1 rounded-md border border-border bg-bg-card px-3 py-2 font-mono text-xs text-text-bright"
data-testid="filter-opsec"
>
<option value=""> all </option>
{OPSEC_LEVELS.map((lv) => (
<option key={lv} value={lv}>{lv}</option>
))}
</select>
</label>
<TextField
label="Free tag"
placeholder="phish"
value={filters.tag ?? ''}
onChange={(e) => setFilters((f) => ({ ...f, tag: e.target.value || undefined }))}
data-testid="filter-tag"
/>
<Button accent="orange" onClick={openCreate} data-testid="create-test" className="ml-auto">
+ New test
</Button>
</div>
{tests.isError && <Alert accent="red">Failed to load tests.</Alert>}
<div className="grid gap-3" data-testid="tests-list">
{tests.isLoading && <p className="font-mono text-xs text-text-dim">Loading</p>}
{tests.data?.items.map((t) => (
<Card
key={t.id}
accent="orange"
title={t.name}
sub={t.description ?? '—'}
data-testid={`test-row-${t.id}`}
>
<div className="flex flex-wrap items-center gap-2">
<Tag accent={t.opsec_level === 'high' ? 'red' : t.opsec_level === 'low' ? 'green' : 'yellow'}>
opsec: {t.opsec_level}
</Tag>
{t.mitre_tags.map((tag) => (
<Tag
key={`${tag.kind}:${tag.external_id}`}
accent={tag.kind === 'tactic' ? 'cyan' : tag.kind === 'technique' ? 'orange' : 'purple'}
>
{tag.external_id}
</Tag>
))}
{t.tags.map((tg) => (
<Tag key={tg} accent="cyan">#{tg}</Tag>
))}
<div className="ml-auto flex gap-2">
<Button accent="orange" onClick={() => openEdit(t)} data-testid={`edit-test-${t.id}`}>
Edit
</Button>
<Button
accent="rose"
variant="ghost"
onClick={() => {
if (window.confirm(`Soft-delete "${t.name}"?`)) remove.mutate(t.id);
}}
data-testid={`delete-test-${t.id}`}
>
Delete
</Button>
</div>
</div>
</Card>
))}
{tests.data && tests.data.items.length === 0 && !tests.isLoading && (
<p className="font-mono text-2xs text-text-dim">No tests match the current filters.</p>
)}
</div>
<Modal
open={isModalOpen}
title={editing ? `Test · ${editing.name}` : 'New test template'}
accent="orange"
size="7xl"
onClose={() => {
setCreating(false);
setEditing(null);
}}
testid="test-template-modal"
>
{error && <Alert accent="red" className="mb-3">{error}</Alert>}
<div className="flex flex-col gap-3 min-w-0">
<TextField
label="Name"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
data-testid="form-name"
/>
<TextField
label="Description"
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
data-testid="form-description"
/>
<TextField
label="Objective (1-liner)"
value={form.objective}
onChange={(e) => setForm((f) => ({ ...f, objective: e.target.value }))}
/>
<MarkdownField
label="Procedure"
value={form.procedure_md}
onChange={(v) => setForm((f) => ({ ...f, procedure_md: v }))}
data-testid="form-procedure"
hint="Step-by-step playbook. Markdown supported."
/>
<MarkdownField
label="Prerequisites"
value={form.prerequisites_md}
onChange={(v) => setForm((f) => ({ ...f, prerequisites_md: v }))}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<MarkdownField
label="Red — expected result"
value={form.expected_result_red_md}
onChange={(v) => setForm((f) => ({ ...f, expected_result_red_md: v }))}
rows={4}
/>
<MarkdownField
label="Blue — expected detection"
value={form.expected_detection_blue_md}
onChange={(v) => setForm((f) => ({ ...f, expected_detection_blue_md: v }))}
rows={4}
/>
</div>
<label className="block">
<span className="block font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim">
OPSEC level
</span>
<select
value={form.opsec_level}
onChange={(e) => setForm((f) => ({ ...f, opsec_level: e.target.value as OpsecLevel }))}
className="mt-1 rounded-md border border-border bg-bg-card px-3 py-2 font-mono text-xs text-text-bright"
data-testid="form-opsec"
>
{OPSEC_LEVELS.map((lv) => (
<option key={lv} value={lv}>{lv}</option>
))}
</select>
</label>
<TextField
label="Free tags (comma-separated)"
value={form.tags}
onChange={(e) => setForm((f) => ({ ...f, tags: e.target.value }))}
data-testid="form-tags"
hint="e.g. phish, persistence, quick-win"
/>
<TextField
label="Expected IOCs (comma-separated)"
value={form.expected_iocs}
onChange={(e) => setForm((f) => ({ ...f, expected_iocs: e.target.value }))}
hint="Indicators the blue team should look for"
/>
<div>
<p className="font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim mb-1">
MITRE ATT&amp;CK tags
</p>
<MitreTagPicker
value={form.mitre_tags}
onChange={(next) => setForm((f) => ({ ...f, mitre_tags: next }))}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
variant="ghost"
onClick={() => {
setCreating(false);
setEditing(null);
}}
>
Cancel
</Button>
<Button
accent="orange"
onClick={submit}
disabled={create.isPending || update.isPending}
data-testid="form-submit"
>
{editing ? 'Save' : 'Create'}
</Button>
</div>
</div>
</Modal>
</>
);
}
function humanError(e: unknown): string {
if (e instanceof ApiError) {
const p = e.payload as { error?: string; message?: string } | null;
return p?.message ?? p?.error ?? `HTTP ${e.status}`;
}
return e instanceof Error ? e.message : 'Unexpected error';
}

View File

@@ -61,7 +61,7 @@ export function HomePage() {
<span className="text-purple">Purple Team Platform</span> <span className="text-purple">Purple Team Platform</span>
</h1> </h1>
<p className="font-mono text-sm font-light text-text-dim mt-2"> <p className="font-mono text-sm font-light text-text-dim mt-2">
Collaborative red &amp; blue test orchestration M4 milestone (MITRE ATT&amp;CK) Collaborative red &amp; blue test orchestration M7 milestone (Red &amp; blue execution)
</p> </p>
</header> </header>
<SectionHeader <SectionHeader
@@ -141,9 +141,9 @@ export function HomePage() {
<Card accent="purple" title="Roadmap" sub="14 milestones"> <Card accent="purple" title="Roadmap" sub="14 milestones">
<p> <p>
M0 + M1 + M2 + M3 + M4 done. Next:{' '} M0 + M1 + M2 + M3 + M4 + M5 + M6 + M7 done. Next:{' '}
<code className="accent-fill-cyan px-2 py-[2px] rounded-sm font-mono text-4xs"> <code className="accent-fill-cyan px-2 py-[2px] rounded-sm font-mono text-4xs">
M5 Test &amp; scenario templates M8 Custom detection-level taxonomy
</code> </code>
. .
</p> </p>

View File

@@ -0,0 +1,808 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { MarkdownField } from '@/components/MarkdownField';
import { Alert } from '@/components/ui/Alert';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Modal } from '@/components/ui/Modal';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { Tag } from '@/components/ui/Tag';
import { TextField } from '@/components/ui/TextField';
import { ApiError, apiDelete, apiGet, apiPost, apiPut } from '@/lib/api';
import { useAuth } from '@/lib/auth';
import {
MISSION_STATUS_ACCENT,
MISSION_STATUS_LABEL,
missionKeys,
missionTestKeys,
type AddScenariosPayload,
type DetectionLevelList,
type MemberPayload,
type Mission,
type MissionRoleHint,
type MissionStatus,
type SetMembersPayload,
type TransitionPayload,
type UpdateMissionPayload,
} from '@/lib/missions';
import { MissionScenarioTable } from '@/pages/MissionScenarioTable';
// --------------------------------------------------------------------------- //
// Tests tab — full-bleed scenario tables //
// --------------------------------------------------------------------------- //
interface FullBleedTestsProps {
mission: Mission;
canEdit: boolean;
canWriteRed: boolean;
canWriteBlue: boolean;
onAddScenarios: () => void;
}
function FullBleedTests({
mission,
canEdit,
canWriteRed,
canWriteBlue,
onAddScenarios,
}: FullBleedTestsProps) {
// A single row across the whole mission is in edit mode at a time so the
// user never juggles two unsaved drafts (spec §F6 amendement 2026-05-15).
const [editingTestId, setEditingTestId] = useState<string | null>(null);
const detectionLevels = useQuery({
queryKey: missionTestKeys.detectionLevels(),
queryFn: () => apiGet<DetectionLevelList>('/detection-levels'),
staleTime: 60_000,
});
return (
<>
<div className="flex items-baseline justify-between flex-wrap gap-2 mb-3">
<p className="font-mono text-2xs text-text-dim">
Double-click a row to edit · server is the perm arbiter ·
snapshots stay frozen at append time.
</p>
{canEdit && (
<Button
accent="cyan"
onClick={onAddScenarios}
data-testid="mission-add-scenarios"
>
+ Add scenarios
</Button>
)}
</div>
{mission.scenarios.length === 0 ? (
<p className="font-mono text-xs text-text-dim">
No scenarios snapshotted yet.
{canEdit && ' Click "Add scenarios" to append one.'}
</p>
) : (
// Full-bleed escape from the layout's max-w-page (same recipe as
// the MITRE picker). Lets the 7-column table breathe on wide
// screens without forcing a horizontal scroll.
<div
className="px-[60px]"
style={{
marginLeft: 'calc(50% - 50vw)',
marginRight: 'calc(50% - 50vw)',
width: '100vw',
}}
data-testid="mission-scenarios"
>
<div className="flex flex-col gap-4">
{mission.scenarios.map((sc) => (
<MissionScenarioTable
key={sc.id}
missionId={mission.id}
scenario={sc}
detectionLevels={detectionLevels.data?.items ?? []}
canWriteRed={canWriteRed}
canWriteBlue={canWriteBlue}
editingTestId={editingTestId}
onEditRequest={setEditingTestId}
/>
))}
</div>
</div>
)}
</>
);
}
import type {
ScenarioTemplate,
ScenarioTemplateListResponse,
} from '@/lib/templates';
import { templateKeys } from '@/lib/templates';
const TABS = ['tests', 'members', 'synthesis', 'export'] as const;
type Tab = (typeof TABS)[number];
const ALLOWED_TRANSITIONS: Record<MissionStatus, MissionStatus[]> = {
draft: ['in_progress', 'archived'],
in_progress: ['completed', 'archived'],
completed: ['archived'],
archived: [],
};
const TRANSITION_BUTTON_ACCENT: Record<MissionStatus, 'cyan' | 'orange' | 'green' | 'teal'> = {
draft: 'cyan',
in_progress: 'orange',
completed: 'green',
archived: 'teal',
};
interface RosterUser {
id: string;
email: string;
display_name: string | null;
}
interface RosterResponse {
items: RosterUser[];
}
interface MemberSelection {
user_id: string;
role_hint: MissionRoleHint;
}
function useMission(id: string) {
return useQuery({
queryKey: missionKeys.detail(id),
queryFn: () => apiGet<Mission>(`/missions/${id}`),
enabled: !!id,
});
}
function useScenarioCatalogue(enabled: boolean) {
return useQuery({
queryKey: templateKeys.scenarios(''),
queryFn: () =>
apiGet<ScenarioTemplateListResponse>('/scenario-templates?limit=500'),
enabled,
});
}
function useRoster(enabled: boolean) {
return useQuery({
queryKey: ['users', 'roster'],
queryFn: () => apiGet<RosterResponse>('/users/roster'),
enabled,
});
}
function formatDateRange(start: string | null, end: string | null): string {
if (!start && !end) return 'No dates set';
if (start && end) return `${start}${end}`;
return start ?? end ?? '';
}
// --------------------------------------------------------------------------- //
// Metadata edit modal //
// --------------------------------------------------------------------------- //
interface MetaEditModalProps {
mission: Mission;
open: boolean;
onClose: () => void;
}
function MetaEditModal({ mission, open, onClose }: MetaEditModalProps) {
const qc = useQueryClient();
const [name, setName] = useState(mission.name);
const [client, setClient] = useState(mission.client_target ?? '');
const [dateStart, setDateStart] = useState(mission.date_start ?? '');
const [dateEnd, setDateEnd] = useState(mission.date_end ?? '');
const [description, setDescription] = useState(mission.description_md ?? '');
// Reset form whenever the modal opens with a (potentially newer) mission.
useEffect(() => {
if (!open) return;
setName(mission.name);
setClient(mission.client_target ?? '');
setDateStart(mission.date_start ?? '');
setDateEnd(mission.date_end ?? '');
setDescription(mission.description_md ?? '');
}, [open, mission]);
const update = useMutation({
mutationFn: (body: UpdateMissionPayload) =>
apiPut<Mission>(`/missions/${mission.id}`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: missionKeys.detail(mission.id) });
qc.invalidateQueries({ queryKey: missionKeys.listPrefix() });
onClose();
},
});
const apiErr = update.error instanceof ApiError ? update.error : null;
const nameInvalid = name.trim().length === 0;
const datesInvalid = dateStart && dateEnd && dateEnd < dateStart;
function submit() {
update.mutate({
name: name.trim(),
client_target: client.trim() || null,
date_start: dateStart || null,
date_end: dateEnd || null,
description_md: description.trim() || null,
});
}
return (
<Modal
open={open}
onClose={onClose}
title={mission.name}
accent="cyan"
size="3xl"
testid="mission-edit-meta-modal"
>
<div className="flex flex-col gap-3 min-w-0">
{apiErr && <Alert accent="red">{apiErr.message}</Alert>}
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<TextField
label="Name"
required
value={name}
onChange={(e) => setName(e.target.value)}
data-testid="meta-edit-name"
/>
<TextField
label="Client / target"
value={client}
onChange={(e) => setClient(e.target.value)}
data-testid="meta-edit-client"
/>
<TextField
label="Start date"
type="date"
value={dateStart}
onChange={(e) => setDateStart(e.target.value)}
data-testid="meta-edit-date-start"
/>
<TextField
label="End date"
type="date"
value={dateEnd}
onChange={(e) => setDateEnd(e.target.value)}
data-testid="meta-edit-date-end"
/>
</div>
<MarkdownField
label="ROE / Description"
value={description}
onChange={setDescription}
data-testid="meta-edit-description"
/>
{datesInvalid && (
<p className="font-mono text-2xs text-red" data-testid="meta-edit-date-error">
End date must be on or after start date.
</p>
)}
<div className="flex items-center justify-end gap-2 pt-2">
<Button accent="teal" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
accent="green"
onClick={submit}
disabled={nameInvalid || !!datesInvalid || update.isPending}
data-testid="meta-edit-save"
>
{update.isPending ? 'Saving…' : 'Save'}
</Button>
</div>
</div>
</Modal>
);
}
// --------------------------------------------------------------------------- //
// Add-scenarios modal //
// --------------------------------------------------------------------------- //
interface AddScenariosModalProps {
mission: Mission;
open: boolean;
onClose: () => void;
}
function AddScenariosModal({ mission, open, onClose }: AddScenariosModalProps) {
const qc = useQueryClient();
const [selected, setSelected] = useState<string[]>([]);
const catalogue = useScenarioCatalogue(open);
useEffect(() => {
if (open) setSelected([]);
}, [open]);
const add = useMutation({
mutationFn: (body: AddScenariosPayload) =>
apiPost<Mission>(`/missions/${mission.id}/scenarios`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: missionKeys.detail(mission.id) });
qc.invalidateQueries({ queryKey: missionKeys.listPrefix() });
onClose();
},
});
const apiErr = add.error instanceof ApiError ? add.error : null;
function toggle(id: string) {
setSelected((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
}
function submit() {
add.mutate({ scenario_template_ids: selected });
}
const totalTestsToAdd = useMemo(() => {
if (!catalogue.data) return 0;
const by_id = new Map<string, ScenarioTemplate>(
catalogue.data.items.map((sc) => [sc.id, sc] as const),
);
return selected.reduce((acc, id) => acc + (by_id.get(id)?.tests_count ?? 0), 0);
}, [selected, catalogue.data]);
return (
<Modal
open={open}
onClose={onClose}
title={`Add scenarios to ${mission.name}`}
accent="cyan"
size="3xl"
testid="mission-add-scenarios-modal"
>
<div className="flex flex-col gap-3 min-w-0">
{apiErr && <Alert accent="red">{apiErr.message}</Alert>}
{catalogue.isError && <Alert accent="red">Failed to load scenarios.</Alert>}
{catalogue.isLoading && (
<p className="font-mono text-xs text-text-dim">Loading</p>
)}
<p className="font-mono text-2xs text-text-dim">
{selected.length} scenario{selected.length === 1 ? '' : 's'} ·{' '}
{totalTestsToAdd} test{totalTestsToAdd === 1 ? '' : 's'} will be appended
after the current {mission.scenarios_count}.
</p>
<ul
className="grid grid-cols-1 gap-2 md:grid-cols-2"
data-testid="add-scenarios-picker"
>
{catalogue.data?.items.map((sc) => {
const isSelected = selected.includes(sc.id);
return (
<li key={sc.id}>
<button
type="button"
className={`w-full rounded-md border ${
isSelected ? 'border-cyan text-cyan' : 'border-border text-text'
} bg-bg-card p-3 text-left font-mono text-xs hover:border-cyan`}
onClick={() => toggle(sc.id)}
data-testid={`add-scenario-toggle-${sc.id}`}
aria-pressed={isSelected}
>
<div className="flex items-center justify-between">
<span className="text-text-bright">{sc.name}</span>
<Tag accent="purple">{sc.tests_count} tests</Tag>
</div>
{sc.description && (
<p className="mt-1 text-text-dim">{sc.description}</p>
)}
</button>
</li>
);
})}
</ul>
{catalogue.data && catalogue.data.items.length === 0 && (
<p className="font-mono text-xs text-text-dim">
No scenarios in the catalogue yet.
</p>
)}
<div className="flex items-center justify-end gap-2 pt-2">
<Button accent="teal" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
accent="green"
onClick={submit}
disabled={selected.length === 0 || add.isPending}
data-testid="add-scenarios-save"
>
{add.isPending ? 'Adding…' : `Add ${selected.length} scenario${selected.length === 1 ? '' : 's'}`}
</Button>
</div>
</div>
</Modal>
);
}
// --------------------------------------------------------------------------- //
// Edit-members modal //
// --------------------------------------------------------------------------- //
interface EditMembersModalProps {
mission: Mission;
open: boolean;
onClose: () => void;
}
function EditMembersModal({ mission, open, onClose }: EditMembersModalProps) {
const qc = useQueryClient();
const roster = useRoster(open);
const [members, setMembers] = useState<MemberSelection[]>([]);
useEffect(() => {
if (!open) return;
setMembers(
mission.members.map((m) => ({
user_id: m.user_id,
role_hint: m.role_hint,
})),
);
}, [open, mission]);
const save = useMutation({
mutationFn: (body: SetMembersPayload) =>
apiPut<Mission>(`/missions/${mission.id}/members`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: missionKeys.detail(mission.id) });
qc.invalidateQueries({ queryKey: missionKeys.listPrefix() });
onClose();
},
});
const apiErr = save.error instanceof ApiError ? save.error : null;
function setRole(user_id: string, role_hint: MissionRoleHint) {
setMembers((prev) =>
prev.some((m) => m.user_id === user_id)
? prev.map((m) => (m.user_id === user_id ? { ...m, role_hint } : m))
: [...prev, { user_id, role_hint }],
);
}
function remove(user_id: string) {
setMembers((prev) => prev.filter((m) => m.user_id !== user_id));
}
function submit() {
const payload: SetMembersPayload = {
members: members.map(
(m): MemberPayload => ({ user_id: m.user_id, role_hint: m.role_hint }),
),
};
save.mutate(payload);
}
return (
<Modal
open={open}
onClose={onClose}
title={`Members of ${mission.name}`}
accent="cyan"
size="3xl"
testid="mission-edit-members-modal"
>
<div className="flex flex-col gap-3 min-w-0">
{apiErr && <Alert accent="red">{apiErr.message}</Alert>}
{roster.isError && <Alert accent="red">Failed to load roster.</Alert>}
{roster.isLoading && (
<p className="font-mono text-xs text-text-dim">Loading users</p>
)}
<ul className="flex flex-col gap-2" data-testid="edit-members-picker">
{roster.data?.items.map((u) => {
const selected = members.find((m) => m.user_id === u.id);
return (
<li
key={u.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-border bg-bg-card p-3"
data-testid={`edit-member-row-${u.id}`}
>
<div>
<p className="font-mono text-xs text-text-bright">
{u.display_name ?? u.email}
</p>
{u.display_name && (
<p className="font-mono text-2xs text-text-dim">{u.email}</p>
)}
</div>
<div className="flex items-center gap-2">
<Button
accent="red"
variant={selected?.role_hint === 'red' ? 'solid' : 'outline'}
onClick={() => setRole(u.id, 'red')}
data-testid={`edit-member-${u.id}-red`}
>
Red
</Button>
<Button
accent="cyan"
variant={selected?.role_hint === 'blue' ? 'solid' : 'outline'}
onClick={() => setRole(u.id, 'blue')}
data-testid={`edit-member-${u.id}-blue`}
>
Blue
</Button>
{selected && (
<Button
accent="rose"
variant="ghost"
onClick={() => remove(u.id)}
data-testid={`edit-member-${u.id}-clear`}
>
</Button>
)}
</div>
</li>
);
})}
</ul>
<div className="flex items-center justify-end gap-2 pt-2">
<Button accent="teal" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
accent="green"
onClick={submit}
disabled={save.isPending}
data-testid="edit-members-save"
>
{save.isPending ? 'Saving…' : 'Save members'}
</Button>
</div>
</div>
</Modal>
);
}
// --------------------------------------------------------------------------- //
// Main page //
// --------------------------------------------------------------------------- //
export function MissionDetailPage() {
const params = useParams();
const missionId = params.id ?? '';
const navigate = useNavigate();
const qc = useQueryClient();
const { state } = useAuth();
const canEdit =
state.user?.is_admin ||
state.user?.permissions.includes('mission.update') ||
false;
const [tab, setTab] = useState<Tab>('tests');
const [editMeta, setEditMeta] = useState(false);
const [addScenarios, setAddScenarios] = useState(false);
const [editMembers, setEditMembers] = useState(false);
const detail = useMission(missionId);
const transition = useMutation({
mutationFn: (body: TransitionPayload) =>
apiPost<Mission>(`/missions/${missionId}/transition`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: missionKeys.detail(missionId) });
qc.invalidateQueries({ queryKey: missionKeys.listPrefix() });
},
});
const remove = useMutation({
mutationFn: () => apiDelete<{ ok: true }>(`/missions/${missionId}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: missionKeys.listPrefix() });
navigate('/missions');
},
});
const apiErr = detail.error instanceof ApiError ? detail.error : null;
const m = detail.data;
if (apiErr) {
return (
<section>
<SectionHeader prefix="Mission" highlight="Not found" accent="rose" />
<Alert accent="rose">{apiErr.message}</Alert>
</section>
);
}
if (!m) {
return <p className="font-mono text-xs text-text-dim">Loading mission</p>;
}
const accent = MISSION_STATUS_ACCENT[m.status];
const allowedNext = ALLOWED_TRANSITIONS[m.status];
return (
<section data-testid={`mission-detail-${m.id}`}>
<div className="flex items-baseline justify-between flex-wrap gap-3">
<SectionHeader prefix="Mission" highlight={m.name} accent={accent} />
<div className="flex items-center gap-2">
<Tag accent={accent}>{MISSION_STATUS_LABEL[m.status]}</Tag>
{canEdit && (
<Button
accent="cyan"
variant="outline"
onClick={() => setEditMeta(true)}
data-testid="mission-edit-meta"
>
Edit
</Button>
)}
{allowedNext.map((target) => (
<Button
key={target}
accent={TRANSITION_BUTTON_ACCENT[target]}
onClick={() => transition.mutate({ status: target })}
data-testid={`mission-transition-${target}`}
disabled={transition.isPending}
>
{MISSION_STATUS_LABEL[target]}
</Button>
))}
<Button
accent="rose"
variant="ghost"
onClick={() => {
if (
window.confirm(
`Soft-delete mission "${m.name}"? An admin can restore from the trash.`,
)
) {
remove.mutate();
}
}}
data-testid="mission-delete"
disabled={remove.isPending}
>
Delete
</Button>
</div>
</div>
<Card className="mb-4">
<dl className="grid grid-cols-2 gap-3 md:grid-cols-4 font-mono text-2xs">
<div>
<dt className="text-text-dim uppercase tracking-wider2">Client</dt>
<dd className="text-text-bright">{m.client_target ?? '—'}</dd>
</div>
<div>
<dt className="text-text-dim uppercase tracking-wider2">Dates</dt>
<dd className="text-text-bright">
{formatDateRange(m.date_start, m.date_end)}
</dd>
</div>
<div>
<dt className="text-text-dim uppercase tracking-wider2">Scenarios</dt>
<dd className="text-text-bright">{m.scenarios_count}</dd>
</div>
<div>
<dt className="text-text-dim uppercase tracking-wider2">Tests</dt>
<dd className="text-text-bright">{m.tests_count}</dd>
</div>
</dl>
{m.description_md && (
<pre className="mt-3 whitespace-pre-wrap font-mono text-xs text-text">{m.description_md}</pre>
)}
</Card>
<nav className="flex gap-1 border-b border-border mb-4" aria-label="Mission tabs">
{TABS.map((t) => (
<button
key={t}
type="button"
onClick={() => setTab(t)}
data-testid={`mission-tab-${t}`}
className={`px-3 py-2 font-mono text-2xs uppercase tracking-wider2 ${
tab === t
? 'text-cyan border-b-2 border-cyan -mb-px'
: 'text-text-dim hover:text-text-bright'
}`}
>
{t}
</button>
))}
</nav>
{tab === 'tests' && (
<FullBleedTests
mission={m}
canEdit={canEdit}
canWriteRed={
state.user?.is_admin === true ||
state.user?.permissions.includes('mission.write_red_fields') === true
}
canWriteBlue={
state.user?.is_admin === true ||
state.user?.permissions.includes('mission.write_blue_fields') === true
}
onAddScenarios={() => setAddScenarios(true)}
/>
)}
{tab === 'members' && (
<Card>
<div className="flex items-baseline justify-between flex-wrap gap-2 mb-3">
<p className="font-mono text-2xs text-text-dim">
Members see this mission and (for reds) can author red-side fields
on its tests in M7+.
</p>
{canEdit && (
<Button
accent="cyan"
onClick={() => setEditMembers(true)}
data-testid="mission-edit-members"
>
Edit members
</Button>
)}
</div>
{m.members.length === 0 ? (
<p className="font-mono text-xs text-text-dim">
No members assigned.
{canEdit && ' Click "Edit members" to add some.'}
</p>
) : (
<ul className="flex flex-col gap-2" data-testid="mission-members">
{m.members.map((mb) => (
<li
key={mb.user_id}
className="flex items-center justify-between rounded-md border border-border bg-bg-card p-3"
data-testid={`mission-member-${mb.user_id}`}
>
<div>
<p className="font-mono text-xs text-text-bright">
{mb.user_display_name ?? mb.user_email}
</p>
{mb.user_display_name && (
<p className="font-mono text-2xs text-text-dim">
{mb.user_email}
</p>
)}
</div>
<Tag accent={mb.role_hint === 'red' ? 'red' : 'cyan'}>
{mb.role_hint}
</Tag>
</li>
))}
</ul>
)}
</Card>
)}
{tab === 'synthesis' && (
<Card>
<p className="font-mono text-xs text-text-dim">
Reveal.js slide synthesis lands in M10.
</p>
</Card>
)}
{tab === 'export' && (
<Card>
<p className="font-mono text-xs text-text-dim">
JSON / CSV exports land in M11.
</p>
</Card>
)}
<MetaEditModal mission={m} open={editMeta} onClose={() => setEditMeta(false)} />
<AddScenariosModal
mission={m}
open={addScenarios}
onClose={() => setAddScenarios(false)}
/>
<EditMembersModal
mission={m}
open={editMembers}
onClose={() => setEditMembers(false)}
/>
</section>
);
}

View File

@@ -0,0 +1,597 @@
/**
* Full-bleed scenario table with inline edit (spec §F6, amendement 2026-05-15).
*
* One table per scenario. Double-click a row to enter edit mode — a *single*
* row in edit mode at a time, Esc cancels, "Save" commits, double-clicking
* another row while dirty prompts to save first.
*
* Cells in edit mode are gated by the viewer's red/blue perms:
* - `Exécution` (executed_at + red_command) → mission.write_red_fields
* - everything else → mission.write_blue_fields
*
* `detection_level` lives inside the `Commentaires` cell as a select +
* pill, not as its own column (spec §F6).
*/
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState, type KeyboardEvent } from 'react';
import { Link } from 'react-router-dom';
import { Button } from '@/components/ui/Button';
import { Tag } from '@/components/ui/Tag';
import { ApiError, apiPut } from '@/lib/api';
import {
MISSION_TEST_STATE_ACCENT,
MISSION_TEST_STATE_LABEL,
missionKeys,
missionTestKeys,
type DetectionLevel,
type MissionScenario,
type MissionTest,
type MissionTestDetail,
type UpdateMissionTestPayload,
} from '@/lib/missions';
interface MissionScenarioTableProps {
missionId: string;
scenario: MissionScenario;
detectionLevels: DetectionLevel[];
canWriteRed: boolean;
canWriteBlue: boolean;
/** Currently-editing test id across the whole mission — managed by the
* parent so opening edit on a row in scenario B closes any active edit
* in scenario A. */
editingTestId: string | null;
onEditRequest: (testId: string | null) => void;
}
// ----- helpers -------------------------------------------------------------
function isoToInput(iso: string | null): string {
// Strip the time portion verbatim. No TZ shift (cf. M7 lessons).
if (!iso) return '';
return iso.slice(0, 16);
}
function inputToIso(local: string): string | null {
if (!local) return null;
return `${local}:00Z`;
}
function emptyToNull(s: string): string | null {
const t = s.trim();
return t === '' ? null : s;
}
// ----- editable row state --------------------------------------------------
interface RowDraft {
// Red fields
executed_at: string; // local YYYY-MM-DDTHH:MM
red_command: string;
// Blue fields
blue_log_source: string;
blue_comment_md: string;
detection_level_id: string;
blue_siem_logs: string;
blue_incident_at: string;
blue_incident_number: string;
blue_incident_recipient_email: string;
}
function makeDraft(t: MissionTest): RowDraft {
return {
executed_at: isoToInput(t.executed_at),
red_command: t.red_command ?? '',
blue_log_source: t.blue_log_source ?? '',
blue_comment_md: t.blue_comment_md ?? '',
detection_level_id: t.detection_level_id ?? '',
blue_siem_logs: t.blue_siem_logs ?? '',
blue_incident_at: isoToInput(t.blue_incident_at),
blue_incident_number: t.blue_incident_number ?? '',
blue_incident_recipient_email: t.blue_incident_recipient_email ?? '',
};
}
function draftDiff(t: MissionTest, d: RowDraft): UpdateMissionTestPayload | null {
const body: UpdateMissionTestPayload = {};
const before = makeDraft(t);
let dirty = false;
if (d.executed_at !== before.executed_at) {
body.executed_at = inputToIso(d.executed_at);
body.executed_at_overridden = d.executed_at !== '';
dirty = true;
}
if (d.red_command !== before.red_command) {
body.red_command = emptyToNull(d.red_command);
dirty = true;
}
if (d.blue_log_source !== before.blue_log_source) {
body.blue_log_source = emptyToNull(d.blue_log_source);
dirty = true;
}
if (d.blue_comment_md !== before.blue_comment_md) {
body.blue_comment_md = emptyToNull(d.blue_comment_md);
dirty = true;
}
if (d.detection_level_id !== before.detection_level_id) {
body.detection_level_id = d.detection_level_id || null;
dirty = true;
}
if (d.blue_siem_logs !== before.blue_siem_logs) {
body.blue_siem_logs = d.blue_siem_logs === '' ? null : d.blue_siem_logs;
dirty = true;
}
if (d.blue_incident_at !== before.blue_incident_at) {
body.blue_incident_at = inputToIso(d.blue_incident_at);
dirty = true;
}
if (d.blue_incident_number !== before.blue_incident_number) {
body.blue_incident_number = emptyToNull(d.blue_incident_number);
dirty = true;
}
if (d.blue_incident_recipient_email !== before.blue_incident_recipient_email) {
body.blue_incident_recipient_email = emptyToNull(
d.blue_incident_recipient_email,
);
dirty = true;
}
return dirty ? body : null;
}
// ----- read-mode cell helpers ---------------------------------------------
function truncate(s: string | null, n: number): string {
if (!s) return '—';
return s.length <= n ? s : s.slice(0, n - 1) + '…';
}
function ExecutionCell({ test }: { test: MissionTest }) {
return (
<div className="flex flex-col gap-1">
<code className="font-mono text-3xs text-text-bright">
{test.executed_at ?? '—'}
</code>
{test.red_command && (
<code className="font-mono text-3xs text-text-dim whitespace-pre-wrap break-all">
{truncate(test.red_command, 140)}
</code>
)}
</div>
);
}
function CommentairesCell({
test,
detectionLevels,
}: {
test: MissionTest;
detectionLevels: DetectionLevel[];
}) {
// Commentaires is blue-team content only — the workflow state lives in
// the Test column (cf. user feedback 2026-05-15 ter).
const lvl = detectionLevels.find((l) => l.id === test.detection_level_id);
return (
<div className="flex flex-col gap-1">
{lvl && (
<div>
<Tag accent={(lvl.color_token as 'red') ?? 'cyan'}>{lvl.label_en}</Tag>
</div>
)}
{test.blue_comment_md && (
<p className="font-mono text-3xs text-text whitespace-pre-wrap">
{truncate(test.blue_comment_md, 220)}
</p>
)}
</div>
);
}
function CyberIncidentCell({ test }: { test: MissionTest }) {
if (
!test.blue_incident_at &&
!test.blue_incident_number &&
!test.blue_incident_recipient_email
) {
return <span className="font-mono text-3xs text-text-dim"></span>;
}
return (
<div className="flex flex-col gap-0.5 font-mono text-3xs">
{test.blue_incident_at && (
<code className="text-text-bright">{test.blue_incident_at}</code>
)}
{test.blue_incident_number && (
<span className="text-text">{test.blue_incident_number}</span>
)}
{test.blue_incident_recipient_email && (
<span className="text-text-dim">{test.blue_incident_recipient_email}</span>
)}
</div>
);
}
// ----- table component -----------------------------------------------------
export function MissionScenarioTable({
missionId,
scenario,
detectionLevels,
canWriteRed,
canWriteBlue,
editingTestId,
onEditRequest,
}: MissionScenarioTableProps) {
const qc = useQueryClient();
const editingTest = scenario.tests.find((t) => t.id === editingTestId);
const [draft, setDraft] = useState<RowDraft | null>(
editingTest ? makeDraft(editingTest) : null,
);
const [apiErr, setApiErr] = useState<string | null>(null);
// Sync the draft whenever the parent flips us into edit mode for a row
// (or out of it). We never re-derive from `scenario.tests` mid-edit so a
// polling refetch doesn't blow the user's typing.
useEffect(() => {
if (editingTest) {
setDraft(makeDraft(editingTest));
} else {
setDraft(null);
}
setApiErr(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editingTestId]);
const save = useMutation({
mutationFn: async ({
testId,
body,
}: {
testId: string;
body: UpdateMissionTestPayload;
}) =>
apiPut<MissionTestDetail>(
`/missions/${missionId}/tests/${testId}`,
body,
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: missionKeys.detail(missionId) });
if (editingTestId) {
qc.invalidateQueries({
queryKey: missionTestKeys.detail(missionId, editingTestId),
});
}
onEditRequest(null);
},
onError: (e: unknown) => {
if (e instanceof ApiError) {
const payload = e.payload as { message?: string } | undefined;
setApiErr(
payload?.message
? `${e.message}${payload.message}`
: e.message,
);
} else {
setApiErr(String(e));
}
},
});
function tryEnterEdit(testId: string) {
if (!canWriteRed && !canWriteBlue) return;
if (editingTestId && editingTestId !== testId && draft && editingTest) {
const diff = draftDiff(editingTest, draft);
if (diff) {
const ok = window.confirm(
'You have unsaved changes on another row. Discard them?',
);
if (!ok) return;
}
}
onEditRequest(testId);
}
function cancel() {
if (draft && editingTest) {
const diff = draftDiff(editingTest, draft);
if (diff) {
const ok = window.confirm('Discard unsaved changes?');
if (!ok) return;
}
}
onEditRequest(null);
}
function commit() {
if (!editingTest || !draft) return;
const diff = draftDiff(editingTest, draft);
if (!diff) {
onEditRequest(null);
return;
}
save.mutate({ testId: editingTest.id, body: diff });
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
cancel();
}
}
// Helpers to set a single draft field without rewriting the whole object.
function set<K extends keyof RowDraft>(key: K, value: RowDraft[K]) {
setDraft((d) => (d ? { ...d, [key]: value } : d));
}
const colHeaderClass =
'text-left py-2 px-2 font-mono text-3xs uppercase tracking-wider2 text-text-dim border-b border-border bg-bg-card';
return (
<div
className="rounded-md border border-border bg-bg-card"
data-testid={`scenario-table-${scenario.id}`}
>
<div className="flex items-baseline gap-2 px-3 py-2 border-b border-border">
<Tag accent="cyan">#{scenario.position + 1}</Tag>
<h3 className="font-mono text-sm text-text-bright">
{scenario.snapshot_name}
</h3>
{scenario.snapshot_description && (
<span className="font-mono text-2xs text-text-dim">
· {scenario.snapshot_description}
</span>
)}
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse font-mono text-2xs">
<thead>
<tr>
<th className={`${colHeaderClass} w-[7rem]`}>Test</th>
<th className={`${colHeaderClass} w-[14rem]`}>Procédure</th>
<th className={`${colHeaderClass} w-[12rem]`}>Exécution</th>
<th className={`${colHeaderClass} w-[10rem]`}>Source de log</th>
<th className={`${colHeaderClass} w-[16rem]`}>Commentaires</th>
<th className={`${colHeaderClass} w-[20rem]`}>Logs SIEM</th>
<th className={`${colHeaderClass} w-[12rem]`}>Cyber Incident</th>
<th className={`${colHeaderClass} w-[8rem]`}>Actions</th>
</tr>
</thead>
<tbody>
{scenario.tests.map((t) => {
const isEditing = editingTestId === t.id;
if (!isEditing) {
return (
<tr
key={t.id}
className="border-t border-border/40 align-top hover:bg-bg-base/40 cursor-pointer"
onDoubleClick={() => tryEnterEdit(t.id)}
data-testid={`row-${t.id}`}
>
<td className="py-2 px-2 text-text-bright">
<div className="flex flex-col gap-1">
<div>
<Tag accent={MISSION_TEST_STATE_ACCENT[t.state]}>
{MISSION_TEST_STATE_LABEL[t.state]}
</Tag>
</div>
<span>{t.snapshot_name}</span>
<div className="flex flex-wrap gap-1">
{t.mitre_tags.slice(0, 3).map((tag) => (
<Tag
accent="cyan"
key={`${tag.kind}-${tag.external_id}`}
>
{tag.external_id}
</Tag>
))}
</div>
</div>
</td>
<td className="py-2 px-2 text-text whitespace-pre-wrap">
{truncate(
t.snapshot_objective ?? t.snapshot_description ?? '',
180,
)}
</td>
<td className="py-2 px-2 align-top">
<ExecutionCell test={t} />
</td>
<td className="py-2 px-2 text-text">
{t.blue_log_source ?? '—'}
</td>
<td className="py-2 px-2 align-top">
<CommentairesCell
test={t}
detectionLevels={detectionLevels}
/>
</td>
<td className="py-2 px-2 text-text whitespace-pre-wrap break-words">
{truncate(t.blue_siem_logs, 240)}
</td>
<td className="py-2 px-2 align-top">
<CyberIncidentCell test={t} />
</td>
<td className="py-2 px-2 text-text-dim">
<Link
to={`/missions/${missionId}/tests/${t.id}`}
className="text-cyan hover:underline"
data-testid={`open-test-${t.id}`}
>
open
</Link>
</td>
</tr>
);
}
if (!draft) return null;
return (
<tr
key={t.id}
className="border-t border-border/40 align-top bg-bg-base/30"
data-testid={`row-edit-${t.id}`}
onKeyDown={onKey}
>
<td className="py-2 px-2 text-text-bright">
<div className="flex flex-col gap-1">
<span>{t.snapshot_name}</span>
<div className="flex flex-wrap gap-1">
{t.mitre_tags.slice(0, 3).map((tag) => (
<Tag
accent="cyan"
key={`${tag.kind}-${tag.external_id}`}
>
{tag.external_id}
</Tag>
))}
</div>
</div>
</td>
<td className="py-2 px-2 text-text whitespace-pre-wrap">
{t.snapshot_objective ?? t.snapshot_description ?? ''}
</td>
<td className="py-2 px-2">
<div className="flex flex-col gap-1">
<input
type="datetime-local"
value={draft.executed_at}
onChange={(e) => set('executed_at', e.target.value)}
disabled={!canWriteRed}
className="w-full rounded border border-border bg-bg-card px-1 py-1 font-mono text-3xs text-text-bright"
data-testid={`cell-executed-at-${t.id}`}
/>
<textarea
value={draft.red_command}
onChange={(e) => set('red_command', e.target.value)}
disabled={!canWriteRed}
rows={3}
placeholder="command"
className="w-full rounded border border-border bg-bg-card px-1 py-1 font-mono text-3xs text-text"
data-testid={`cell-red-command-${t.id}`}
/>
</div>
</td>
<td className="py-2 px-2">
<input
type="text"
value={draft.blue_log_source}
onChange={(e) => set('blue_log_source', e.target.value)}
disabled={!canWriteBlue}
placeholder="EDR / Firewall / NDR …"
className="w-full rounded border border-border bg-bg-card px-1 py-1 font-mono text-3xs text-text"
data-testid={`cell-log-source-${t.id}`}
/>
</td>
<td className="py-2 px-2">
<div className="flex flex-col gap-1">
<select
value={draft.detection_level_id}
onChange={(e) =>
set('detection_level_id', e.target.value)
}
disabled={!canWriteBlue}
className="w-full rounded border border-border bg-bg-card px-1 py-1 font-mono text-3xs text-text"
data-testid={`cell-detection-level-${t.id}`}
>
<option value=""> detection </option>
{detectionLevels.map((l) => (
<option key={l.id} value={l.id}>
{l.label_en} ({l.key})
</option>
))}
</select>
<textarea
value={draft.blue_comment_md}
onChange={(e) => set('blue_comment_md', e.target.value)}
disabled={!canWriteBlue}
rows={3}
placeholder="blueteam comment (markdown)"
className="w-full rounded border border-border bg-bg-card px-1 py-1 font-mono text-3xs text-text"
data-testid={`cell-blue-comment-${t.id}`}
/>
</div>
</td>
<td className="py-2 px-2">
<textarea
value={draft.blue_siem_logs}
onChange={(e) => set('blue_siem_logs', e.target.value)}
disabled={!canWriteBlue}
rows={6}
placeholder="raw SIEM log lines"
className="w-full rounded border border-border bg-bg-card px-1 py-1 font-mono text-3xs text-text"
data-testid={`cell-siem-logs-${t.id}`}
/>
</td>
<td className="py-2 px-2">
<div className="flex flex-col gap-1">
<input
type="datetime-local"
value={draft.blue_incident_at}
onChange={(e) => set('blue_incident_at', e.target.value)}
disabled={!canWriteBlue}
className="w-full rounded border border-border bg-bg-card px-1 py-1 font-mono text-3xs text-text"
data-testid={`cell-incident-at-${t.id}`}
/>
<input
type="text"
value={draft.blue_incident_number}
onChange={(e) =>
set('blue_incident_number', e.target.value)
}
disabled={!canWriteBlue}
placeholder="INC-2026-…"
className="w-full rounded border border-border bg-bg-card px-1 py-1 font-mono text-3xs text-text"
data-testid={`cell-incident-number-${t.id}`}
/>
<input
type="email"
value={draft.blue_incident_recipient_email}
onChange={(e) =>
set('blue_incident_recipient_email', e.target.value)
}
disabled={!canWriteBlue}
placeholder="soc@…"
className="w-full rounded border border-border bg-bg-card px-1 py-1 font-mono text-3xs text-text"
data-testid={`cell-incident-email-${t.id}`}
/>
</div>
</td>
<td className="py-2 px-2 align-top">
<div className="flex flex-col gap-1">
<Button
accent="green"
onClick={commit}
disabled={save.isPending}
data-testid={`cell-save-${t.id}`}
>
{save.isPending ? '…' : 'Save'}
</Button>
<Button
accent="teal"
variant="ghost"
onClick={cancel}
data-testid={`cell-cancel-${t.id}`}
>
Cancel
</Button>
{apiErr && (
<p className="font-mono text-3xs text-red whitespace-pre-wrap">
{apiErr}
</p>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<p className="px-3 py-1 font-mono text-3xs text-text-dim border-t border-border">
Double-click a row to edit · Esc to cancel · "open ↗" for the full
per-test page (evidence upload, full procedure)
</p>
</div>
);
}

View File

@@ -0,0 +1,745 @@
/**
* Per-test execution page (M7).
*
* Two zones, mirror of the spec §M7:
* - Red zone (red border) — command, output, markdown comment, mark-executed.
* - Blue zone (cyan border) — detection level, markdown comment, evidence dropzone.
*
* State transitions are driven from a small button row in the header. The
* "modified by X Ns ago" indicator polls `/missions/{id}/activity?since=…`
* every 15s while the page is mounted (and the document is visible).
*
* Field-level permissions:
* - Admins always see and write everything.
* - `mission.write_red_fields` enables the red-side form.
* - `mission.write_blue_fields` enables the blue-side form + uploads.
* The server is the ultimate arbiter (PUT/POST will 403 if a side is forbidden);
* the UI just disables inputs the user cannot write to reduce confusion.
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { MarkdownField } from '@/components/MarkdownField';
import { Alert } from '@/components/ui/Alert';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { Tag } from '@/components/ui/Tag';
import { TextField } from '@/components/ui/TextField';
import { ApiError, apiDelete, apiFetch, apiGet, apiPut } from '@/lib/api';
import { useAuth } from '@/lib/auth';
import {
EVIDENCE_ALLOWED_EXTENSIONS,
EVIDENCE_MAX_BYTES,
MISSION_TEST_STATE_ACCENT,
MISSION_TEST_STATE_LABEL,
missionKeys,
missionTestKeys,
type ActivityResponse,
type DetectionLevel,
type DetectionLevelList,
type MissionTestDetail,
type MissionTestEvidence,
type UpdateMissionTestPayload,
} from '@/lib/missions';
const POLL_INTERVAL_MS = 15_000;
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
}
function formatRelative(iso: string | null | undefined): string {
if (!iso) return 'never';
const then = new Date(iso).getTime();
const now = Date.now();
const seconds = Math.max(0, Math.floor((now - then) / 1000));
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
/**
* Datetime helpers — zero timezone interpretation. The user types a
* wall-clock value, the server stores it verbatim, and we display it back
* unchanged. We never go through `new Date(...).toISOString()` because that
* would shift the value by the browser's local offset on every render.
*/
function isoToInputValue(iso: string | null | undefined): string {
// `2026-05-15T10:30:00+00:00` → `2026-05-15T10:30`.
if (!iso) return '';
return iso.slice(0, 16);
}
function inputValueToIso(local: string): string | null {
// `2026-05-15T10:30` → `2026-05-15T10:30:00Z`. The `Z` makes the ISO
// unambiguous for the backend without applying any local-time shift.
if (!local) return null;
return `${local}:00Z`;
}
function useMissionTest(missionId: string, testId: string) {
return useQuery({
queryKey: missionTestKeys.detail(missionId, testId),
queryFn: () =>
apiGet<MissionTestDetail>(`/missions/${missionId}/tests/${testId}`),
enabled: !!missionId && !!testId,
});
}
function useDetectionLevels(enabled: boolean) {
return useQuery({
queryKey: missionTestKeys.detectionLevels(),
queryFn: () => apiGet<DetectionLevelList>('/detection-levels'),
enabled,
staleTime: 60_000,
});
}
// --------------------------------------------------------------------------- //
// Activity indicator //
// --------------------------------------------------------------------------- //
function useActivityWatcher(
missionId: string,
testId: string,
onTouched: () => void,
) {
const lastServerTimeRef = useRef<string | null>(null);
const onTouchedRef = useRef(onTouched);
onTouchedRef.current = onTouched;
useEffect(() => {
if (!missionId) return;
let cancelled = false;
async function poll() {
try {
const since = lastServerTimeRef.current;
const url =
`/missions/${missionId}/activity` +
(since ? `?since=${encodeURIComponent(since)}` : '');
const res = await apiGet<ActivityResponse>(url);
if (cancelled) return;
lastServerTimeRef.current = res.server_time;
// We only care about activity on the same test (the badge is local).
if (res.items.some((it) => it.test_id === testId)) {
onTouchedRef.current();
}
} catch {
// Network blips are non-fatal — the badge just doesn't refresh.
}
}
// Prime the timestamp without firing onTouched.
void poll();
const handle = window.setInterval(() => {
if (document.visibilityState === 'visible') void poll();
}, POLL_INTERVAL_MS);
return () => {
cancelled = true;
window.clearInterval(handle);
};
}, [missionId, testId]);
}
// --------------------------------------------------------------------------- //
// Red zone //
// --------------------------------------------------------------------------- //
interface RedZoneProps {
test: MissionTestDetail;
missionId: string;
canWriteRed: boolean;
}
function RedZone({ test, missionId, canWriteRed }: RedZoneProps) {
const qc = useQueryClient();
const [command, setCommand] = useState(test.red_command ?? '');
const [output, setOutput] = useState(test.red_output ?? '');
const [comment, setComment] = useState(test.red_comment_md ?? '');
// `executedAtInput` is the raw `YYYY-MM-DDTHH:MM` string the datetime-local
// input speaks — stored verbatim, displayed verbatim, no TZ shift on either
// side. Conversion only happens at submit (append `:00Z`).
const [executedAtInput, setExecutedAtInput] = useState(
isoToInputValue(test.executed_at),
);
const executedAtServer = isoToInputValue(test.executed_at);
const dirty =
command !== (test.red_command ?? '') ||
output !== (test.red_output ?? '') ||
comment !== (test.red_comment_md ?? '') ||
executedAtInput !== executedAtServer;
// Sync local state only when the *identity* of the test changes (route
// change). Polling refetches return a new object reference for the same
// test_id; resetting on those would wipe whatever the user is mid-typing.
useEffect(() => {
setCommand(test.red_command ?? '');
setOutput(test.red_output ?? '');
setComment(test.red_comment_md ?? '');
setExecutedAtInput(isoToInputValue(test.executed_at));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [test.id]);
const save = useMutation({
mutationFn: (body: UpdateMissionTestPayload) =>
apiPut<MissionTestDetail>(
`/missions/${missionId}/tests/${test.id}`,
body,
),
onSuccess: (next) => {
qc.setQueryData(missionTestKeys.detail(missionId, test.id), next);
qc.invalidateQueries({ queryKey: missionKeys.detail(missionId) });
},
});
function submit() {
const body: UpdateMissionTestPayload = {
red_command: command.trim() || null,
red_output: output.length === 0 ? null : output,
red_comment_md: comment.trim() || null,
};
// Any change to the executed_at field is implicitly a manual override —
// the operator wouldn't have touched it otherwise. The override flag is
// a backend-side bookkeeping detail and never surfaced in the UI.
if (executedAtInput !== executedAtServer) {
body.executed_at = inputValueToIso(executedAtInput);
body.executed_at_overridden = executedAtInput !== '';
}
save.mutate(body);
}
const apiErr = save.error instanceof ApiError ? save.error : null;
// Post-amendement 2026-05-15: the backend auto-promotes the state from
// pending/skipped/blocked when a non-null executed_at is stamped, so the
// UI no longer needs to disable the input on those states. Red perm is
// still required.
const canEditExecutedAt = canWriteRed;
return (
<Card accent="red" className="flex flex-col gap-3" data-testid="red-zone">
<SectionHeader prefix="Red" highlight="Execution" accent="red" />
{apiErr && <Alert accent="red">{apiErr.message}</Alert>}
{/* The execution timestamp anchors the blue team's log correlation,
so it leads the form (cf. user feedback 2026-05-15). What the user
types is what gets stored — no timezone interpretation. */}
<div data-testid="red-executed-block">
<label className="block font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim">
Executed at
</label>
<input
type="datetime-local"
value={executedAtInput}
onChange={(e) => setExecutedAtInput(e.target.value)}
disabled={!canEditExecutedAt}
className="mt-1 w-full rounded-md border border-border bg-bg-card px-3 py-2 font-mono text-xs text-text-bright"
data-testid="red-executed-at"
/>
</div>
<TextField
label="Command"
value={command}
onChange={(e) => setCommand(e.target.value)}
disabled={!canWriteRed}
className="font-mono"
data-testid="red-command"
placeholder="powershell -enc ..."
/>
<label className="flex flex-col gap-1">
<span className="font-mono text-2xs uppercase tracking-wider2 text-text-dim">
Output
</span>
<textarea
value={output}
onChange={(e) => setOutput(e.target.value)}
disabled={!canWriteRed}
rows={8}
className="w-full rounded-md border border-border bg-bg-card p-3 font-mono text-xs text-text"
data-testid="red-output"
placeholder="stdout / stderr capture"
/>
</label>
<MarkdownField
label="Comment"
value={comment}
onChange={setComment}
disabled={!canWriteRed}
data-testid="red-comment"
/>
<div className="flex justify-end">
<Button
accent="red"
onClick={submit}
disabled={!canWriteRed || !dirty || save.isPending}
data-testid="red-save"
>
{save.isPending ? 'Saving…' : 'Save red fields'}
</Button>
</div>
</Card>
);
}
// --------------------------------------------------------------------------- //
// Blue zone //
// --------------------------------------------------------------------------- //
interface BlueZoneProps {
test: MissionTestDetail;
missionId: string;
canWriteBlue: boolean;
detectionLevels: DetectionLevel[];
}
function BlueZone({ test, missionId, canWriteBlue, detectionLevels }: BlueZoneProps) {
const qc = useQueryClient();
const [comment, setComment] = useState(test.blue_comment_md ?? '');
const [levelId, setLevelId] = useState(test.detection_level_id ?? '');
const fileInputRef = useRef<HTMLInputElement>(null);
const [dropError, setDropError] = useState<string | null>(null);
const dirty =
comment !== (test.blue_comment_md ?? '') ||
levelId !== (test.detection_level_id ?? '');
// Sync from server only on test-identity change (route nav), not on every
// polling refetch — see RedZone for the rationale.
useEffect(() => {
setComment(test.blue_comment_md ?? '');
setLevelId(test.detection_level_id ?? '');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [test.id]);
const save = useMutation({
mutationFn: (body: UpdateMissionTestPayload) =>
apiPut<MissionTestDetail>(
`/missions/${missionId}/tests/${test.id}`,
body,
),
onSuccess: (next) => {
qc.setQueryData(missionTestKeys.detail(missionId, test.id), next);
qc.invalidateQueries({ queryKey: missionKeys.detail(missionId) });
},
});
const upload = useMutation({
mutationFn: async (file: File) => {
const fd = new FormData();
fd.append('file', file);
const res = await apiFetch(
`/missions/${missionId}/tests/${test.id}/evidence`,
{ method: 'POST', body: fd },
);
if (!res.ok) {
const body = await res
.json()
.catch(() => ({ error: 'upload_failed' as const }));
throw new ApiError(res.status, body);
}
return (await res.json()) as MissionTestEvidence;
},
onSuccess: () => {
qc.invalidateQueries({
queryKey: missionTestKeys.detail(missionId, test.id),
});
qc.invalidateQueries({ queryKey: missionKeys.detail(missionId) });
},
});
const remove = useMutation({
mutationFn: (evidenceId: string) =>
apiDelete<{ ok: boolean }>(`/evidence/${evidenceId}`),
onSuccess: () => {
qc.invalidateQueries({
queryKey: missionTestKeys.detail(missionId, test.id),
});
qc.invalidateQueries({ queryKey: missionKeys.detail(missionId) });
},
});
function submit() {
save.mutate({
blue_comment_md: comment.trim() || null,
detection_level_id: levelId || null,
});
}
function handleFiles(files: FileList | null) {
if (!files || files.length === 0) return;
setDropError(null);
// Per spec §M7, max 25 MB + ext whitelist are server-enforced; the client
// checks too so a drag-and-drop of a `.exe` doesn't waste a roundtrip
// and the operator gets immediate feedback.
for (const f of Array.from(files)) {
const dotIdx = f.name.lastIndexOf('.');
const ext = dotIdx >= 0 ? f.name.slice(dotIdx).toLowerCase() : '';
if (!ext || !(EVIDENCE_ALLOWED_EXTENSIONS as readonly string[]).includes(ext)) {
setDropError(
`"${f.name}" has an unsupported extension. Accepted: ${EVIDENCE_ALLOWED_EXTENSIONS.join(', ')}.`,
);
continue;
}
if (f.size > EVIDENCE_MAX_BYTES) {
setDropError(`"${f.name}" exceeds the 25 MB limit.`);
continue;
}
upload.mutate(f);
}
}
const apiErr =
save.error instanceof ApiError
? save.error
: upload.error instanceof ApiError
? upload.error
: remove.error instanceof ApiError
? remove.error
: null;
return (
<Card accent="cyan" className="flex flex-col gap-3" data-testid="blue-zone">
<SectionHeader prefix="Blue" highlight="Detection" accent="cyan" />
{apiErr && (
<Alert accent="red">
{apiErr.message}
{typeof apiErr.payload === 'object' &&
apiErr.payload &&
'message' in (apiErr.payload as Record<string, unknown>)
? `${(apiErr.payload as { message?: string }).message}`
: ''}
</Alert>
)}
<label className="flex flex-col gap-1">
<span className="font-mono text-2xs uppercase tracking-wider2 text-text-dim">
Detection level
</span>
<select
value={levelId}
onChange={(e) => setLevelId(e.target.value)}
disabled={!canWriteBlue}
className="rounded-md border border-border bg-bg-card px-2 py-2 font-mono text-xs text-text"
data-testid="blue-detection-level"
>
<option value=""> none </option>
{detectionLevels.map((lvl) => (
<option key={lvl.id} value={lvl.id}>
{lvl.label_en} ({lvl.key})
</option>
))}
</select>
</label>
<MarkdownField
label="Comment"
value={comment}
onChange={setComment}
disabled={!canWriteBlue}
data-testid="blue-comment"
/>
<div className="flex justify-end">
<Button
accent="cyan"
onClick={submit}
disabled={!canWriteBlue || !dirty || save.isPending}
data-testid="blue-save"
>
{save.isPending ? 'Saving…' : 'Save blue fields'}
</Button>
</div>
<div className="flex flex-col gap-2">
<p className="font-mono text-2xs uppercase tracking-wider2 text-text-dim">
Evidence
</p>
<div
className={`rounded-md border-2 border-dashed p-4 text-center ${
canWriteBlue ? 'border-cyan/60' : 'border-border'
}`}
onDragOver={(e) => {
if (!canWriteBlue) return;
e.preventDefault();
}}
onDrop={(e) => {
if (!canWriteBlue) return;
e.preventDefault();
handleFiles(e.dataTransfer.files);
}}
data-testid="evidence-dropzone"
>
<p className="font-mono text-2xs text-text-dim">
{canWriteBlue
? 'Drag files here or click to pick'
: 'Read-only — no upload permission'}
</p>
{canWriteBlue && (
<p
className="mt-1 font-mono text-3xs uppercase tracking-wider2 text-text-dim"
data-testid="evidence-allowed-formats"
>
Accepted: {EVIDENCE_ALLOWED_EXTENSIONS.join(' · ')} ·{' '}
max 25 MB / file
</p>
)}
<input
ref={fileInputRef}
type="file"
multiple
// `accept` is the native filter the OS file-picker honours. It
// accepts a comma-separated list of extensions or MIME types.
// Servers re-check both, so this is a UX nicety, not a security
// boundary.
accept={EVIDENCE_ALLOWED_EXTENSIONS.join(',')}
className="hidden"
onChange={(e) => handleFiles(e.target.files)}
data-testid="evidence-file-input"
/>
{canWriteBlue && (
<Button
variant="ghost"
accent="cyan"
className="mt-2"
onClick={() => fileInputRef.current?.click()}
disabled={upload.isPending}
data-testid="evidence-pick"
>
{upload.isPending ? 'Uploading…' : 'Pick files'}
</Button>
)}
{dropError && (
<p className="mt-2 font-mono text-2xs text-red">{dropError}</p>
)}
</div>
{test.evidence.length === 0 ? (
<p className="font-mono text-2xs text-text-dim">No evidence yet.</p>
) : (
<table className="w-full font-mono text-2xs">
<thead>
<tr className="text-text-dim uppercase tracking-wider2">
<th className="text-left py-1">File</th>
<th className="text-left py-1">Size</th>
<th className="text-left py-1">By</th>
<th className="text-left py-1">SHA256</th>
<th />
</tr>
</thead>
<tbody data-testid="evidence-list">
{test.evidence.map((ev) => (
<tr
key={ev.id}
className="border-t border-border/40"
data-testid={`evidence-row-${ev.id}`}
>
<td className="py-1 text-text-bright">{ev.original_filename}</td>
<td className="py-1">{formatBytes(ev.size_bytes)}</td>
<td className="py-1 text-text">
{ev.uploaded_by_email ?? '<deleted>'}
</td>
<td className="py-1">
<code className="text-text-dim">{ev.sha256.slice(0, 12)}</code>
</td>
<td className="py-1 text-right">
<a
href={`/api/v1/evidence/${ev.id}?download=true`}
target="_blank"
rel="noreferrer"
className="text-cyan font-mono text-2xs"
data-testid={`evidence-download-${ev.id}`}
>
download
</a>
{canWriteBlue && (
<button
type="button"
onClick={() => {
if (
window.confirm(
`Soft-delete evidence "${ev.original_filename}"?`,
)
) {
remove.mutate(ev.id);
}
}}
className="ml-2 text-rose font-mono text-2xs"
data-testid={`evidence-delete-${ev.id}`}
disabled={remove.isPending}
>
delete
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</Card>
);
}
// --------------------------------------------------------------------------- //
// Top-level page //
// --------------------------------------------------------------------------- //
export function MissionTestPage() {
const params = useParams<{ id: string; testId: string }>();
const missionId = params.id ?? '';
const testId = params.testId ?? '';
const navigate = useNavigate();
const { state } = useAuth();
const qc = useQueryClient();
const detail = useMissionTest(missionId, testId);
const levels = useDetectionLevels(
!!state.user &&
(state.user.is_admin ||
state.user.permissions.includes('detection_level.read')),
);
const onActivityTouched = useCallback(() => {
qc.invalidateQueries({
queryKey: missionTestKeys.detail(missionId, testId),
});
}, [qc, missionId, testId]);
useActivityWatcher(missionId, testId, onActivityTouched);
const test = detail.data;
const perms = useMemo(() => {
const isAdmin = !!state.user?.is_admin;
const codes = state.user?.permissions ?? [];
return {
isAdmin,
canWriteRed: isAdmin || codes.includes('mission.write_red_fields'),
canWriteBlue: isAdmin || codes.includes('mission.write_blue_fields'),
};
}, [state.user]);
if (!missionId || !testId) {
return (
<Alert accent="red">Missing mission or test id in URL.</Alert>
);
}
if (detail.isLoading) {
return <p className="font-mono text-xs text-text-dim">Loading</p>;
}
if (detail.error instanceof ApiError && detail.error.status === 404) {
return (
<Alert accent="rose">
Mission test not found, or you are not a member of this mission.
</Alert>
);
}
if (!test) {
return <Alert accent="red">Failed to load mission test.</Alert>;
}
return (
<div className="flex flex-col gap-4" data-testid="mission-test-page">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div>
<Link
to={`/missions/${missionId}`}
className="font-mono text-2xs uppercase tracking-wider2 text-text-dim hover:text-text-bright"
data-testid="back-to-mission"
>
Back to mission
</Link>
<h1 className="mt-1 font-mono text-xl font-bold text-text-bright">
{test.snapshot_name}
</h1>
<p className="font-mono text-2xs text-text-dim">
Last touched{' '}
<span data-testid="last-actor-rel">{formatRelative(test.updated_at)}</span>
{test.last_actor_email
? ` by ${test.last_actor_display_name ?? test.last_actor_email}`
: ''}
</p>
</div>
{/* Post-amendement 2026-05-15 bis: no transition buttons. The pill
is a passive indicator of where the implicit lifecycle stands
(driven by who has written data). */}
<div className="flex flex-wrap items-center gap-2">
<span data-testid="state-pill">
<Tag accent={MISSION_TEST_STATE_ACCENT[test.state]}>
{MISSION_TEST_STATE_LABEL[test.state]}
</Tag>
</span>
</div>
</div>
<Card className="flex flex-col gap-2">
<div className="flex flex-wrap gap-2">
{test.mitre_tags.map((tag) => (
<Tag accent="cyan" key={`${tag.kind}-${tag.external_id}`}>
{tag.external_id} {tag.name}
</Tag>
))}
<Tag accent="teal">OPSEC: {test.snapshot_opsec_level}</Tag>
</div>
{test.snapshot_objective && (
<p className="font-mono text-xs text-text">
<span className="text-text-dim">Objective: </span>
{test.snapshot_objective}
</p>
)}
{test.snapshot_procedure_md && (
<details className="font-mono text-2xs text-text-dim" data-testid="procedure">
<summary className="cursor-pointer text-text">Procedure</summary>
<pre className="mt-1 whitespace-pre-wrap text-text">
{test.snapshot_procedure_md}
</pre>
</details>
)}
{test.snapshot_expected_red_md && (
<details className="font-mono text-2xs text-text-dim">
<summary className="cursor-pointer text-text">Expected (red)</summary>
<pre className="mt-1 whitespace-pre-wrap text-text">
{test.snapshot_expected_red_md}
</pre>
</details>
)}
{test.snapshot_expected_blue_md && (
<details className="font-mono text-2xs text-text-dim">
<summary className="cursor-pointer text-text">Expected (blue)</summary>
<pre className="mt-1 whitespace-pre-wrap text-text">
{test.snapshot_expected_blue_md}
</pre>
</details>
)}
</Card>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<RedZone test={test} missionId={missionId} canWriteRed={perms.canWriteRed} />
<BlueZone
test={test}
missionId={missionId}
canWriteBlue={perms.canWriteBlue}
detectionLevels={levels.data?.items ?? []}
/>
</div>
{/* Navigation hint when state moves to archived parent — out of M7 scope, but
we still let the page render. */}
{detail.error instanceof ApiError &&
detail.error.status >= 400 &&
detail.error.status < 500 && (
<Button accent="cyan" onClick={() => navigate(`/missions/${missionId}`)}>
Back to mission
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,404 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { MarkdownField } from '@/components/MarkdownField';
import { Alert } from '@/components/ui/Alert';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { Tag } from '@/components/ui/Tag';
import { TextField } from '@/components/ui/TextField';
import { ApiError, apiGet, apiPost } from '@/lib/api';
import { useAuth } from '@/lib/auth';
import {
missionKeys,
type CreateMissionPayload,
type Mission,
type MissionRoleHint,
} from '@/lib/missions';
import {
templateKeys,
type ScenarioTemplate,
type ScenarioTemplateListResponse,
} from '@/lib/templates';
interface RosterUser {
id: string;
email: string;
display_name: string | null;
}
interface RosterResponse {
items: RosterUser[];
}
interface MetaState {
name: string;
client_target: string;
date_start: string;
date_end: string;
description_md: string;
}
interface MemberSelection {
user_id: string;
role_hint: MissionRoleHint;
}
const STEPS: Array<{ key: 'meta' | 'scenarios' | 'members'; label: string }> = [
{ key: 'meta', label: 'Metadata' },
{ key: 'scenarios', label: 'Scenarios' },
{ key: 'members', label: 'Members' },
];
function blankMeta(): MetaState {
return {
name: '',
client_target: '',
date_start: '',
date_end: '',
description_md: '',
};
}
function useScenarioCatalogue() {
return useQuery({
queryKey: templateKeys.scenarios(''),
queryFn: () =>
apiGet<ScenarioTemplateListResponse>('/scenario-templates?limit=500'),
});
}
function useRoster() {
return useQuery({
queryKey: ['users', 'roster'],
queryFn: () => apiGet<RosterResponse>('/users/roster'),
});
}
export function MissionsCreatePage() {
const { state } = useAuth();
const me = state.user;
const navigate = useNavigate();
const qc = useQueryClient();
const [stepIdx, setStepIdx] = useState(0);
const step = STEPS[stepIdx];
const [meta, setMeta] = useState<MetaState>(blankMeta);
const [scenarioIds, setScenarioIds] = useState<string[]>([]);
const [members, setMembers] = useState<MemberSelection[]>(() =>
me && !me.is_admin
? [{ user_id: me.id, role_hint: 'red' }]
: [],
);
const scenarios = useScenarioCatalogue();
const roster = useRoster();
const createMutation = useMutation({
mutationFn: (body: CreateMissionPayload) => apiPost<Mission>('/missions', body),
onSuccess: (created) => {
qc.invalidateQueries({ queryKey: missionKeys.listPrefix() });
navigate(`/missions/${created.id}`);
},
});
const apiErr =
createMutation.error instanceof ApiError ? createMutation.error : null;
const metaInvalid = meta.name.trim().length === 0;
const datesInvalid =
meta.date_start &&
meta.date_end &&
meta.date_end < meta.date_start;
const scenarioById = useMemo(() => {
const m = new Map<string, ScenarioTemplate>();
for (const sc of scenarios.data?.items ?? []) m.set(sc.id, sc);
return m;
}, [scenarios.data]);
const next = () => setStepIdx((i) => Math.min(i + 1, STEPS.length - 1));
const prev = () => setStepIdx((i) => Math.max(i - 1, 0));
function toggleScenario(id: string) {
setScenarioIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
}
function setMemberRole(user_id: string, role_hint: MissionRoleHint) {
setMembers((prev) =>
prev.some((m) => m.user_id === user_id)
? prev.map((m) => (m.user_id === user_id ? { ...m, role_hint } : m))
: [...prev, { user_id, role_hint }],
);
}
function removeMember(user_id: string) {
setMembers((prev) => prev.filter((m) => m.user_id !== user_id));
}
function submit() {
const payload: CreateMissionPayload = {
name: meta.name.trim(),
client_target: meta.client_target.trim() || null,
date_start: meta.date_start || null,
date_end: meta.date_end || null,
description_md: meta.description_md.trim() || null,
scenario_template_ids: scenarioIds,
members,
};
createMutation.mutate(payload);
}
const totalSelectedTests = useMemo(
() =>
scenarioIds.reduce(
(acc, id) => acc + (scenarioById.get(id)?.tests_count ?? 0),
0,
),
[scenarioIds, scenarioById],
);
return (
<section data-testid="missions-create">
<SectionHeader prefix="New" highlight="Mission" accent="cyan" />
<Card className="mb-6">
<ol className="flex items-center gap-2" data-testid="missions-create-steps">
{STEPS.map((s, i) => {
const active = i === stepIdx;
const done = i < stepIdx;
const accent: 'cyan' | 'green' | 'teal' = active
? 'cyan'
: done
? 'green'
: 'teal';
return (
<li key={s.key} className="flex items-center gap-2">
<Tag accent={accent}>{i + 1}. {s.label}</Tag>
{i < STEPS.length - 1 && (
<span className="text-text-dim font-mono"></span>
)}
</li>
);
})}
</ol>
</Card>
{apiErr && (
<div data-testid="missions-create-error" className="mb-4">
<Alert accent="red">{apiErr.message}</Alert>
</div>
)}
{step.key === 'meta' && (
<Card title="Metadata" sub="Identification and scope">
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<TextField
label="Name"
required
value={meta.name}
onChange={(e) => setMeta((p) => ({ ...p, name: e.target.value }))}
data-testid="meta-name"
placeholder="purple-q2-2026"
/>
<TextField
label="Client / target"
value={meta.client_target}
onChange={(e) =>
setMeta((p) => ({ ...p, client_target: e.target.value }))
}
data-testid="meta-client"
placeholder="Acme Corp"
/>
<TextField
label="Start date"
type="date"
value={meta.date_start}
onChange={(e) =>
setMeta((p) => ({ ...p, date_start: e.target.value }))
}
data-testid="meta-date-start"
/>
<TextField
label="End date"
type="date"
value={meta.date_end}
onChange={(e) =>
setMeta((p) => ({ ...p, date_end: e.target.value }))
}
data-testid="meta-date-end"
/>
</div>
<div className="mt-4">
<MarkdownField
label="ROE / Description"
value={meta.description_md}
onChange={(v) =>
setMeta((p) => ({ ...p, description_md: v }))
}
data-testid="meta-description"
/>
</div>
{datesInvalid && (
<p className="mt-3 font-mono text-2xs text-red" data-testid="meta-date-error">
End date must be on or after start date.
</p>
)}
</Card>
)}
{step.key === 'scenarios' && (
<Card
title="Scenarios"
sub={`Select reusable scenarios — ${totalSelectedTests} tests will be snapshotted`}
>
{scenarios.isError && (
<Alert accent="red">Failed to load scenarios.</Alert>
)}
{scenarios.isLoading && (
<p className="font-mono text-xs text-text-dim">Loading</p>
)}
<ul
className="grid grid-cols-1 gap-2 md:grid-cols-2"
data-testid="scenarios-picker"
>
{scenarios.data?.items.map((sc) => {
const selected = scenarioIds.includes(sc.id);
return (
<li key={sc.id}>
<button
type="button"
className={`w-full rounded-md border ${
selected ? 'border-cyan text-cyan' : 'border-border text-text'
} bg-bg-card p-3 text-left font-mono text-xs hover:border-cyan`}
onClick={() => toggleScenario(sc.id)}
data-testid={`scenario-toggle-${sc.id}`}
aria-pressed={selected}
>
<div className="flex items-center justify-between">
<span className="text-text-bright">{sc.name}</span>
<Tag accent="purple">{sc.tests_count} tests</Tag>
</div>
{sc.description && (
<p className="mt-1 text-text-dim">{sc.description}</p>
)}
</button>
</li>
);
})}
</ul>
{scenarios.data && scenarios.data.items.length === 0 && (
<p className="font-mono text-xs text-text-dim">
No scenarios in the catalogue yet create one in
{' '}<a href="/admin/scenarios" className="text-cyan underline">Admin Scenarios</a>{' '}
first.
</p>
)}
</Card>
)}
{step.key === 'members' && (
<Card title="Members" sub="Who works on this mission and on which side">
{roster.isError && (
<Alert accent="red">Failed to load roster.</Alert>
)}
{roster.isLoading && (
<p className="font-mono text-xs text-text-dim">Loading users</p>
)}
<ul
className="flex flex-col gap-2"
data-testid="members-picker"
>
{roster.data?.items.map((u) => {
const selected = members.find((m) => m.user_id === u.id);
return (
<li
key={u.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-border bg-bg-card p-3"
data-testid={`member-row-${u.id}`}
>
<div>
<p className="font-mono text-xs text-text-bright">
{u.display_name ?? u.email}
</p>
{u.display_name && (
<p className="font-mono text-2xs text-text-dim">{u.email}</p>
)}
</div>
<div className="flex items-center gap-2">
<Button
accent="red"
variant={selected?.role_hint === 'red' ? 'solid' : 'outline'}
onClick={() => setMemberRole(u.id, 'red')}
data-testid={`member-${u.id}-red`}
>
Red
</Button>
<Button
accent="cyan"
variant={selected?.role_hint === 'blue' ? 'solid' : 'outline'}
onClick={() => setMemberRole(u.id, 'blue')}
data-testid={`member-${u.id}-blue`}
>
Blue
</Button>
{selected && (
<Button
accent="rose"
variant="ghost"
onClick={() => removeMember(u.id)}
data-testid={`member-${u.id}-clear`}
>
</Button>
)}
</div>
</li>
);
})}
</ul>
</Card>
)}
<div className="mt-6 flex items-center justify-between">
<Button
accent="teal"
variant="ghost"
onClick={prev}
disabled={stepIdx === 0}
data-testid="missions-create-prev"
>
Back
</Button>
{stepIdx < STEPS.length - 1 ? (
<Button
accent="cyan"
onClick={next}
disabled={step.key === 'meta' && (metaInvalid || !!datesInvalid)}
data-testid="missions-create-next"
>
Next
</Button>
) : (
<Button
accent="green"
onClick={submit}
disabled={
createMutation.isPending ||
metaInvalid ||
!!datesInvalid
}
data-testid="missions-create-submit"
>
{createMutation.isPending ? 'Creating…' : 'Create mission'}
</Button>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,167 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Alert } from '@/components/ui/Alert';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { Tag } from '@/components/ui/Tag';
import { TextField } from '@/components/ui/TextField';
import { ApiError, apiGet } from '@/lib/api';
import { useAuth } from '@/lib/auth';
import {
MISSION_STATUS_ACCENT,
MISSION_STATUS_LABEL,
buildMissionQueryString,
missionKeys,
type MissionFilters,
type MissionListResponse,
type MissionStatus,
} from '@/lib/missions';
const STATUS_OPTIONS: Array<{ value: '' | MissionStatus; label: string }> = [
{ value: '', label: 'All statuses' },
{ value: 'draft', label: MISSION_STATUS_LABEL.draft },
{ value: 'in_progress', label: MISSION_STATUS_LABEL.in_progress },
{ value: 'completed', label: MISSION_STATUS_LABEL.completed },
{ value: 'archived', label: MISSION_STATUS_LABEL.archived },
];
function useMissions(filters: MissionFilters) {
return useQuery({
queryKey: missionKeys.list(filters),
queryFn: () =>
apiGet<MissionListResponse>(`/missions${buildMissionQueryString(filters)}`),
});
}
function formatDateRange(start: string | null, end: string | null): string {
if (!start && !end) return '—';
if (start && end) return `${start}${end}`;
return start ?? end ?? '—';
}
export function MissionsListPage() {
const { state } = useAuth();
const canCreate =
state.user?.is_admin || state.user?.permissions.includes('mission.create');
const [q, setQ] = useState('');
const [status, setStatus] = useState<'' | MissionStatus>('');
const [client, setClient] = useState('');
const filters = useMemo<MissionFilters>(
() => ({
q: q.trim() || undefined,
status: status || undefined,
client: client.trim() || undefined,
}),
[q, status, client],
);
const { data, error, isLoading } = useMissions(filters);
const apiErr = error instanceof ApiError ? error : null;
return (
<section data-testid="missions-list">
<div className="flex items-baseline justify-between">
<SectionHeader prefix="Plan" highlight="Missions" accent="cyan" />
{canCreate && (
<Link to="/missions/new" data-testid="missions-new-link">
<Button accent="cyan">+ New mission</Button>
</Link>
)}
</div>
<Card className="mb-6">
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<TextField
label="Search"
placeholder="name or description"
value={q}
onChange={(e) => setQ(e.target.value)}
data-testid="missions-filter-q"
/>
<TextField
label="Client"
placeholder="acme corp"
value={client}
onChange={(e) => setClient(e.target.value)}
data-testid="missions-filter-client"
/>
<div className="flex flex-col gap-1">
<label className="font-mono text-2xs uppercase tracking-wider2 text-text-dim">
Status
</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value as '' | MissionStatus)}
data-testid="missions-filter-status"
className="bg-bg-card border border-border rounded px-3 py-2 font-mono text-xs text-text-bright"
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
</Card>
{apiErr && (
<div data-testid="missions-error">
<Alert accent="red">{apiErr.message}</Alert>
</div>
)}
{isLoading && (
<p className="font-mono text-xs text-text-dim">Loading missions</p>
)}
{data && data.items.length === 0 && !isLoading && (
<Card>
<p className="font-mono text-xs text-text-dim" data-testid="missions-empty">
No missions match the filters. {canCreate ? 'Create one to get started.' : ''}
</p>
</Card>
)}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2" data-testid="missions-grid">
{data?.items.map((m) => {
const accent = MISSION_STATUS_ACCENT[m.status];
return (
<Link key={m.id} to={`/missions/${m.id}`} data-testid={`mission-card-${m.id}`}>
<Card
accent={accent}
title={m.name}
sub={m.client_target ?? 'No client'}
className="h-full"
>
<div className="flex flex-wrap items-center gap-2">
<Tag accent={accent}>{MISSION_STATUS_LABEL[m.status]}</Tag>
<Tag accent="cyan">{m.scenarios_count} scenarios</Tag>
<Tag accent="purple">{m.tests_count} tests</Tag>
<Tag accent="teal">{m.members_count} members</Tag>
</div>
<p className="mt-3 font-mono text-2xs text-text-dim">
{formatDateRange(m.date_start, m.date_end)}
</p>
</Card>
</Link>
);
})}
</div>
{data && (
<p
className="mt-4 font-mono text-2xs text-text-dim"
data-testid="missions-total"
>
{data.total} mission{data.total === 1 ? '' : 's'} total
</p>
)}
</section>
);
}

View File

@@ -9,7 +9,12 @@ import { SectionHeader } from '@/components/ui/SectionHeader';
import { Tag } from '@/components/ui/Tag'; import { Tag } from '@/components/ui/Tag';
import { ApiError, apiGet, apiPost } from '@/lib/api'; import { ApiError, apiGet, apiPost } from '@/lib/api';
import { useAuth } from '@/lib/auth'; import { useAuth } from '@/lib/auth';
import { mitreKeys, type MitreStatus, type MitreTag } from '@/lib/mitre'; import {
mitreKeys,
type MitreStatus,
type MitreSyncResult,
type MitreTag,
} from '@/lib/mitre';
export function MitrePage() { export function MitrePage() {
const { state } = useAuth(); const { state } = useAuth();
@@ -24,14 +29,14 @@ export function MitrePage() {
}); });
const sync = useMutation({ const sync = useMutation({
mutationFn: () => apiPost<Record<string, unknown>>('/mitre/sync'), mutationFn: () => apiPost<MitreSyncResult>('/mitre/sync'),
onMutate: () => { onMutate: () => {
setSyncResult(null); setSyncResult(null);
setSyncError(null); setSyncError(null);
}, },
onSuccess: async (res) => { onSuccess: async (res) => {
const counts = `${res.tactics_upserted} tactics, ${res.techniques_upserted} techniques, ${res.subtechniques_upserted} subtechniques`; const counts = `${res.tactics_upserted} tactics, ${res.techniques_upserted} techniques, ${res.subtechniques_upserted} subtechniques`;
setSyncResult(`Sync completed in ${(res as { duration_ms: number }).duration_ms / 1000}s — ${counts}.`); setSyncResult(`Sync completed in ${(res.duration_ms / 1000).toFixed(1)}s — ${counts}.`);
await qc.invalidateQueries({ queryKey: ['mitre'] }); await qc.invalidateQueries({ queryKey: ['mitre'] });
}, },
onError: (e) => { onError: (e) => {

View File

@@ -78,6 +78,57 @@ project: Metamorph
- **`podman compose stop api` puis `up -d api` casse les dépendances** entre containers (`db` healthy → `api` depends on it) : podman-compose ne résout pas la chaîne de deps quand on cible un seul service. Pour un override d'env, mieux vaut `make down && APP_ENV=test make up`. - **`podman compose stop api` puis `up -d api` casse les dépendances** entre containers (`db` healthy → `api` depends on it) : podman-compose ne résout pas la chaîne de deps quand on cible un seul service. Pour un override d'env, mieux vaut `make down && APP_ENV=test make up`.
- **`/diag/reset` test-only** : exposer un endpoint qui truncate la DB est tentant pour les e2e mais ouvre une grosse surface en cas de fuite. Compromise actuel : autorisé en `dev` ET `test` (pas en prod), avec un log `WARNING` à chaque appel. Si jamais on déploie une stack dev publique, **désactiver** l'endpoint via env var. - **`/diag/reset` test-only** : exposer un endpoint qui truncate la DB est tentant pour les e2e mais ouvre une grosse surface en cas de fuite. Compromise actuel : autorisé en `dev` ET `test` (pas en prod), avec un log `WARNING` à chaque appel. Si jamais on déploie une stack dev publique, **désactiver** l'endpoint via env var.
## 2026-05-15 — M7 amendement : 5 champs blue + vue tabulaire
- **`e.errors()` de Pydantic v2 embarque l'exception originale dans `ctx`** quand un `AfterValidator` lève. `jsonify(e.errors())` crash avec `TypeError: Object of type ValueError is not JSON serializable`. Fix project-wide : `e.errors(include_context=False, include_url=False)` — strippe le ctx et l'URL doc, garde le reste qui est déjà JSON-safe. Sed global sur `backend/app/api/*.py`. Mémo : si la stack ajoute un nouveau handler `except ValidationError as e:`, prendre le même pattern.
- **Pydantic `EmailStr` reste trop strict pour le projet** (lessons M2 captured this). Pour le destinataire d'alerte, j'ai utilisé `Annotated[str, AfterValidator(_validate_email_shape)]` avec une regex `^[^@\s]+@[^@\s]+\.[^@\s]+$`. Pas de validation TLD. Si un futur champ "production" exige de la rigueur, on aura besoin de deux types : `_InternalEmail` (permissive) et `_PublicEmail` (strict). Pas le cas aujourd'hui — outil interne.
- **Naïve datetime + `timestamptz` = piège silencieux**. Postgres interprète la datetime naïve dans la session TZ ; sur l'API la majorité des clients enverra `2026-05-15T11:00:00` sans `Z`. Réponse : `Annotated[datetime, AfterValidator(_ensure_aware_datetime)]` qui rejette `tzinfo is None` avec 400. Le front respecte déjà la convention (append `:00Z` au datetime-local). À reproduire sur tout nouveau champ `timestamptz` accepté en write.
- **Élargir `MissionTestView` (la vue nested dans `GET /missions/{id}`) est OK** tant qu'on garde la requête en O(1) — j'ai ajouté ~15 fields mais batch-load les détections + last-actor users en 2 queries totales, peu importe le nombre de tests. Sans le batch, c'était un classique N+1.
- **Pattern édition inline en table** :
- State `editingTestId` *au-dessus* du tableau (un seul row en édition à la fois sur toute la mission).
- `draft` localement à `MissionScenarioTable`, copié depuis le test à l'entrée d'édition.
- `draftDiff(test, draft)` retourne `null` si rien n'a changé (évite un `PUT` vide).
- `useEffect([editingTestId])` re-derive le draft seulement quand l'identité change (pas sur polling refetch — leçon M7 déjà capturée).
- `window.confirm` sur Esc-with-dirty et sur double-click d'une autre ligne avec dirty draft.
- **Full-bleed escape `max-w-page`** : `marginLeft: 'calc(50% - 50vw)'` + `marginRight: 'calc(50% - 50vw)'` + `width: '100vw'`. Pattern déjà inventé pour le picker MITRE en M4 ; testé OK pour 7 colonnes denses. À factoriser dans un composant `<FullBleed>` si on en a un 3ᵉ usage.
- **`detection_level` rendu en pill dans la cellule Commentaires** plutôt que comme 8ᵉ colonne : la spec listait 7 colonnes héritées d'Excel ; ajouter une 8ᵉ aurait cassé le mental model du user. Pill au-dessus du commentaire est plus naturel + économise l'espace horizontal.
## 2026-05-14 — M7 execution + evidence + activity
- **`logging.LogRecord` reserves `created`** — same trap as `name` (M3 lessons): `extra={"created": n}` raises `KeyError: "Attempt to overwrite 'created' in LogRecord"`. Pattern: prefix with the entity (`rows_created`). The `created` is the LogRecord timestamp, hence the conflict. Reserved-key cheatsheet (kept growing): `name, msg, args, levelname, levelno, pathname, filename, module, funcName, created, msecs, lineno, thread, threadName, process`.
- **Query string `+` is `%20` once `request.args` decodes it.** A naked ISO datetime in `?since=2026-05-14T07:55:16+00:00` arrives as `2026-05-14T07:55:16 00:00`, which `datetime.fromisoformat` rejects with `ValueError`. The fix is on the *client* (URL-encode) — not on the server (a tolerant "space → +" reparse would conflate real-spaces with un-encoded plusses). Now codified in `testing-m7.md` §7 + every test that hits `/activity?since=` calls `urllib.parse.quote`.
- **Field-level perm enforcement must happen *before* the SQL transaction.** First M7 draft did `_load_test(...)` then `if not allowed: raise`. Two issues: (a) extra DB hit on a refused request, (b) audit log conflated "row exists" with "perm denied". Refactor: classify the touched fields → check perms → only then enter `session_scope`. Cleaner audit log and one fewer round-trip on the 403 path.
- **Streamed upload + atomic move is the canonical pattern for content-addressed evidence.** Writing chunks to a tmpfile *inside* the final per-test dir lets `shutil.move` reduce to a POSIX `rename(2)` (atomic). If the SHA256 already exists on disk (re-upload of the same bytes), we drop the tmp and reuse — a fresh DB row records *who* uploaded it, even though no new bytes hit the disk. Saves storage AND preserves provenance.
- **Pyright's "underscore prefix unused" rule does not silence destructured tuple slots.** `ev, _test, scenario = chain` still triggers `"_test is not accessed"`. Workaround: use a single underscore (`_`) or index the tuple. Single underscore is conventional in Python for "I'm intentionally ignoring this".
- **TanStack v5 `useQueryClient.setQueryData(detailKey, next)`** is the right idiom after a mutation that returns the freshly-saved row — avoids a refetch, and the polling query still invalidates correctly on activity events. Pattern: `onSuccess: (next) => { qc.setQueryData(detailKey, next); qc.invalidateQueries({ queryKey: parentKey }); }`.
- **Activity polling must gate on `document.visibilityState === 'visible'`** or every backgrounded tab hits the API every 15 s, multiplying for free across a team's tab graveyard. Single-line check; massive impact.
- **PUT vs transition split kept the model coherent.** Tempting to fold "mark executed" into PUT `{state:'executed'}` but it conflates two concerns: state lifecycle vs field write. Keeping the transition POST separate makes the side-effect (`executed_at = now()`) easy to reason about and the perm gate per-target trivial.
- **`/diag/reset` must clean the evidence dir in test mode** otherwise the e2e suite accumulates 24 MB blobs across runs. Gated on `APP_ENV == "test"` so `dev` keeps the operator's manual uploads.
- **The `last_actor_id` migration adds an index on `updated_at`** — without it, the activity poll's `WHERE updated_at > since ORDER BY updated_at DESC` was sequential-scanning. With the index, the plan switches to an index range scan even on the empty case (which is the most common one when nothing has changed).
## 2026-05-13 — M6 missions + snapshot
- **Snapshot independence requires more than column copies — denormalise the join tables too.** `mission_tests` copies every scalar template field, but if `mission_test_mitre_tags` kept FKs to `mitre_*` rows, a future re-sync that drops a technique would cascade through `ON DELETE CASCADE` and silently mutate frozen missions. The M1 schema already split `mission_test_mitre_tags` with frozen `(mitre_external_id, mitre_name, mitre_url)` columns and no FK — at snapshot time we denormalise via a 3-query batch lookup (`_resolve_mitre_lookup`) and build the rows in-memory. Pattern to reuse for any "frozen reference" relationship in the future.
- **`/diag/reset` truncate order is FK-aware**: `mission_scenarios.source_scenario_template_id` and `mission_tests.source_test_template_id` are `ON DELETE SET NULL`. Truncating template tables first would force PG to NULL those columns one by one. Reverse the order — wipe mission tables (which cascade to members/scenarios/tests/tags/categories from `missions`) BEFORE the templates. Saves a round-trip + keeps the truncate logically aligned with the dependency graph.
- **Membership visibility = 404, not 403.** Returning 403 for "mission exists but you're not a member" leaks the existence of the mission. The service returns 404 in both "doesn't exist" and "not visible to you" cases via the same `MissionNotFound` exception. The decorator stack handles perm-level 403 (you can't even GET /missions); the service handles row-level 404. Pattern: gate "type of action" via decorator perms, gate "which rows" via service-level membership filters that collapse to 404.
- **Auto-add the non-admin creator as a member.** Without this, a redteamer who creates a mission and forgets to add themselves to `members[]` immediately loses visibility (403 on subsequent GETs because they're not a member). Solved at the service layer: `if not creator_is_admin and creator_id not in members: prepend (creator_id, 'red')`. Admin creators don't auto-add because they bypass membership anyway. Documented in the docstring + tested explicitly (`test_non_admin_creator_auto_added`).
- **Minimum-surface roster endpoint pattern**: `/users` returns admin metadata (is_admin via groups, is_active, group memberships). The mission wizard needs a list of assignable users from a non-admin redteamer's perspective — exposing /users to them would leak admin metadata. Added a dedicated `GET /users/roster` returning only `(id, email, display_name)` and gated by **any of** `user.read`, `mission.create`, `mission.update`. Pattern: when a cross-feature needs a smaller slice of an admin endpoint, create a dedicated lightweight endpoint rather than relaxing the admin one.
- **Pyright is not always wrong about "unused" parameters** — the original `_to_list_item(s: Session, m: Mission)` took `s` but never accessed it (the function uses already-`selectinload`ed relationships). Removed the param. Lesson: when adding a `Session` parameter to a view-assembly helper, audit whether the body actually issues queries through it.
- **`flask.abort()` is not typed `NoReturn` in this project's Pyright config** so `def f() -> X: if x is None: abort(...); return x` raises a return-type error. Workaround: add `assert user is not None` after the abort to narrow the type. Cleaner than `cast(...)`. Pattern to reuse anywhere we abort-and-return.
- **Snapshot of multiple scenarios is a 4-query write** regardless of test count: (1) load N scenario_templates with their join rows, (2) load M test_templates by id with mitre_tags, (3) batch-resolve MITRE rows (3 queries for tactic/technique/sub), (4) insert mission_scenarios + mission_tests + mission_test_mitre_tags via the SQLAlchemy unit of work. Avoid the temptation to query inside per-test loops — it explodes to O(scenarios × tests × tag_kinds) easily.
## 2026-05-12 — M5 templates + scenarios
- **`extra={"name": ...}` dans `log.info()` crash silencieusement** — Python's `logging.LogRecord` réserve `name` (le logger name). Coût : 500 sur le POST, message peu parlant (`KeyError: "Attempt to overwrite 'name' in LogRecord"`). Fix : renommer la clé (`template_name`). Liste réservée à éviter : `name`, `msg`, `args`, `levelname`, `levelno`, `pathname`, `filename`, `module`, `funcName`, `created`, `msecs`, `lineno`, `thread`, `threadName`, `process`. Pattern : préfixer les clés extra par l'entité (`template_name`, `group_id`, `user_id` est OK mais `id` aussi est piégeux dans certains setups).
- **React 18 + `setX((prev) => ({...prev, val: e.currentTarget.value }))` → page blanche au 1er input.** `e.currentTarget` est cleared après la fin du bubble, AVANT que l'updater fonctionnel exécute. Le synthetic event survit (pas de pooling depuis React 17), mais `currentTarget` est setté/cleared par le dispatcher. Fix : `e.target.value` (qui persiste sur le synthetic event), ou capturer `const v = e.currentTarget.value;` avant le `setX`. À garder en tête : tout `onChange` qui passe par un updater fonctionnel doit lire `e.target`, pas `e.currentTarget`.
- **Sentinel `Any = object()` plutôt que `... (Ellipsis)`** pour les "field unset" optional en service Python. Pyright voit `... = object()` correctement comme `Any`, alors que `description: str | None | object = ...` rend `description.strip()` invalide. Pattern : `_UNSET: Any = object()` au top du module + `description: Any = _UNSET` dans la signature + `if description is not _UNSET: ...`. Net + typecheck-friendly.
- **Postgres UNIQUE(scenario_id, position) + position-swap = ON CONFLICT pendant l'UPDATE.** Pour réordonner, le pattern naïf (UPDATE position) viole la contrainte sur le 1er swap. Trois options : (a) full delete + re-insert dans la même tx [retenu, atomique + lisible], (b) shift d'offset (UPDATE position = position + 1000 puis renumérotation), (c) deferred constraint. (a) gagne en simplicité — la liste rarement >50 éléments, le coût est négligeable.
- **`@dnd-kit/sortable` requires `useSortable({ id })` IDs to be unique and stable across renders.** Si on utilise un index numérique comme id, drag-and-drop ne réagit pas. Utiliser `test_template_id` (UUID stable) marche directement.
- **Frontend deps ajoutés à `package.json` sans `package-lock.json`** : le Dockerfile fait `npm install --no-audit --no-fund` sur fallback. OK pour M5 (3 deps `@dnd-kit/*`). À l'avenir, freeze un lockfile avant M14 pour build reproductibles.
- **Playwright `getByTestId` est défini par `testIdAttributeName: 'data-testid'`** dans `playwright.config.ts`. Pour qu'un test-id descende sur l'input via TextField, il faut que `...rest` soit spread sur l'input (déjà OK dans `TextField.tsx`). Mais avec un wrapper `<div><label/><input/></div>`, `getByTestId` matche le DIV si le test-id est dessus. Bien le mettre sur l'élément interactif (input/button), pas sur le container.
- **`/diag/reset` truncate order matters** : `scenario_template_tests.test_template_id` est FK `ON DELETE RESTRICT`, donc il faut truncate `scenario_template_tests` AVANT `test_templates`. Hierarchy : `scenario_template_tests → scenario_templates → test_template_mitre_tags → test_templates → mitre_*`. Maintenant inscrite dans `diag.py`.
- **Modal embarquant le `MitreTagPicker` complet (15 cols × 50 techniques)** : le picker se charge via `/mitre/matrix` (~94 KB). Affichage instantané, OK. Pour de futurs modals lourds, considérer le lazy-render derrière un toggle ou tab.
<!-- <!--
Template for future entries: Template for future entries:

View File

@@ -1,11 +1,17 @@
--- ---
type: spec type: spec
date: "2026-05-08" date: "2026-05-08"
revised: "2026-05-15"
tags: [spec, ready] tags: [spec, ready]
status: ready status: ready
project: Metamorph project: Metamorph
--- ---
> **2026-05-15 amendment** — added 5 blue-side review fields to the M7
> scope (cf. §4 in-scope bullet on blue saisie, §F6, §8 model) and
> reworked the per-test UX to a full-bleed tabular view with inline edit.
> All other commitments stand. Detailed delta at the bottom of §4.
# Metamorph — Spec # Metamorph — Spec
> Spec finalisée après tour de questions du 2026-05-08. §12 et §13 vides : prête pour l'exécution. Le tracking quotidien bascule sur `Templates/Project.md`. > Spec finalisée après tour de questions du 2026-05-08. §12 et §13 vides : prête pour l'exécution. Le tracking quotidien bascule sur `Templates/Project.md`.
@@ -46,7 +52,8 @@ Remplace l'aller-retour Excel actuel par une UI partagée temps réel, multi-rô
- Snapshot des templates au moment de l'instanciation dans une mission (modifier un template ne touche pas les missions existantes). - Snapshot des templates au moment de l'instanciation dans une mission (modifier un template ne touche pas les missions existantes).
- Saisie des résultats red (texte uniquement : commande, output, commentaires) avec horodatage auto au clic « Marquer exécuté » + override manuel. - Saisie des résultats red (texte uniquement : commande, output, commentaires) avec horodatage auto au clic « Marquer exécuté » + override manuel.
- Saisie des preuves blue : multi-fichiers (PNG/JPG/PDF/TXT/LOG/JSON/CSV/EVTX/ZIP, max 25 Mo/fichier, SHA256 stocké) + commentaires markdown + niveau de détection (taxonomie custom paramétrable par admin, seed par défaut : `detected_blocked / detected_alert / logged_only / not_detected`). - Saisie des preuves blue : multi-fichiers (PNG/JPG/PDF/TXT/LOG/JSON/CSV/EVTX/ZIP, max 25 Mo/fichier, SHA256 stocké) + commentaires markdown + niveau de détection (taxonomie custom paramétrable par admin, seed par défaut : `detected_blocked / detected_alert / logged_only / not_detected`).
- Workflow par test instance : `pending → executed → reviewed_by_blue` + voies `skipped / blocked`. - **Saisie côté blue — fiche de review étendue (amendement 2026-05-15)** : en plus du commentaire et du niveau de détection, la fiche de review d'un test capture les 5 champs additionnels que la blue maintenait en Excel — **`log_source`** (texte court : Firewall / NDR / Proxy / AV / EDR / …), **`siem_logs`** (texte long, extrait brut de logs collectés au SIEM), **sous-record cyber-incident** `(incident_at: timestamptz, incident_number: texte court, incident_recipient_email: email)` qui matérialise l'alerte envoyée à l'équipe SOC. Tous ces champs sont blue-side et gated par `mission.write_blue_fields`.
- **Cycle de vie d'un test instance — implicite, piloté par les écritures (amendement 2026-05-15 bis)** : aucun bouton de transition exposé en UI. La colonne `state` du DB reste (`pending / executed / reviewed_by_blue / skipped / blocked`) mais elle est **auto-promue** par le service à chaque PUT : (1) toute écriture côté red sur un test `pending|skipped|blocked` fait passer à `executed` et auto-stamp `executed_at = now()` si absent ; (2) toute écriture côté blue sur un test `executed` fait passer à `reviewed_by_blue`. Une écriture blue sur un test `pending` n'auto-promote pas (seule la red implique l'exécution). L'endpoint `/transition` est conservé pour back-fill admin mais n'est plus appelé par l'UI.
- Visibilité mission : whitebox totale pour la blue team dès la création (pas de masquage des procédures). - Visibilité mission : whitebox totale pour la blue team dès la création (pas de masquage des procédures).
- Édition concurrente : last-write-wins + indicateur « modifié par X il y a Ns » via polling léger. Conflits red/blue impossibles par construction (champs disjoints). - Édition concurrente : last-write-wins + indicateur « modifié par X il y a Ns » via polling léger. Conflits red/blue impossibles par construction (champs disjoints).
- Notifications in-app uniquement (badge + liste), pas de SMTP. - Notifications in-app uniquement (badge + liste), pas de SMTP.
@@ -90,7 +97,7 @@ Remplace l'aller-retour Excel actuel par une UI partagée temps réel, multi-rô
- **F3** — CRUD scénarios = liste ordonnée (drag-and-drop) de tests unitaires. - **F3** — CRUD scénarios = liste ordonnée (drag-and-drop) de tests unitaires.
- **F4** — CRUD missions (métadonnées §4) composées d'un ou plusieurs scénarios, snapshot des templates à l'instanciation. - **F4** — CRUD missions (métadonnées §4) composées d'un ou plusieurs scénarios, snapshot des templates à l'instanciation.
- **F5** — Saisie côté red : commande lancée, output texte, commentaires markdown, statut, timestamp auto+override. - **F5** — Saisie côté red : commande lancée, output texte, commentaires markdown, statut, timestamp auto+override.
- **F6** — Saisie côté blue : niveau de détection (enum custom plateforme), commentaires markdown, multi-fichiers (whitelist). - **F6** — Saisie côté blue : niveau de détection (enum custom plateforme), commentaires markdown, multi-fichiers (whitelist), **source de log** (texte court, max 120 caractères, free-form en v1 — promu en taxonomie M8+), **extrait SIEM** (texte long, max 200_000 caractères côté API), **sous-record cyber-incident** dont les 3 champs sont **indépendants et tous optionnels** : `incident_at` (timestamptz, exige un offset explicite — `Z` ou `+HH:MM` ; les naïves sont rejetées en 400), `incident_number` (texte court, max 120), `incident_recipient_email` (texte avec validation RFC-shape permissive — autorise `.local` / `.corp` / `.test` pour les domaines internes). UI: **vue tabulaire pleine largeur d'écran** (échappe le `max-w-page` du layout) à raison d'**un tableau par scénario, une ligne par test**. **Colonnes** : `Test | Procédure | Exécution | Source de log | Commentaires | Logs SIEM | Cyber Incident`. Le `detection_level` est rendu **dans la cellule Commentaires** sous forme de pill colorée au-dessus du commentaire (pas de 8ᵉ colonne). **Édition inline** : double-clic sur une ligne → un *seul* row passe en édition à la fois (les autres restent en lecture, le double-clic d'une autre ligne propose de save/discard la précédente) ; les cellules deviennent des inputs gated par les perms red/blue de l'utilisateur ; **Esc** annule (revert vers le snapshot serveur), **Save** commit, **clic en dehors** prompt si dirty. La page détail d'un test (`/missions/{id}/tests/{test_id}`) reste accessible pour l'upload de preuves (dropzone + table).
- **F7** — Génération slide reveal.js standalone + export PDF client, groupé par MITRE Tactic (custom optionnel). - **F7** — Génération slide reveal.js standalone + export PDF client, groupé par MITRE Tactic (custom optionnel).
- **F8** — Notifications in-app (badge + flux) à chaque transition de statut d'un test concernant l'utilisateur. - **F8** — Notifications in-app (badge + flux) à chaque transition de statut d'un test concernant l'utilisateur.
- **F9** — Export mission : JSON complet (API + UI), CSV agrégé. - **F9** — Export mission : JSON complet (API + UI), CSV agrégé.
@@ -129,7 +136,7 @@ Remplace l'aller-retour Excel actuel par une UI partagée temps réel, multi-rô
- `users`, `groups`, `permissions`, `user_groups`, `group_permissions`, `invitations` - `users`, `groups`, `permissions`, `user_groups`, `group_permissions`, `invitations`
- `mitre_tactics`, `mitre_techniques`, `mitre_subtechniques` - `mitre_tactics`, `mitre_techniques`, `mitre_subtechniques`
- `test_templates`, `scenario_templates`, `scenario_template_tests` (jointure ordonnée) - `test_templates`, `scenario_templates`, `scenario_template_tests` (jointure ordonnée)
- `missions`, `mission_members`, `mission_scenarios` (snapshot), `mission_tests` (snapshot + state d'exécution), `mission_categories` (custom) - `missions`, `mission_members`, `mission_scenarios` (snapshot), `mission_tests` (snapshot + state d'exécution + annotations red `red_command/red_output/red_comment_md` + annotations blue `blue_comment_md/detection_level_id/blue_log_source/blue_siem_logs/blue_incident_at/blue_incident_number/blue_incident_recipient_email`), `mission_categories` (custom)
- `evidence_files` (FK `mission_test_id`, sha256, mime, size, path) - `evidence_files` (FK `mission_test_id`, sha256, mime, size, path)
- `notifications` (in-app) - `notifications` (in-app)
- `detection_levels` (taxonomie custom, seedée avec 4 niveaux par défaut) - `detection_levels` (taxonomie custom, seedée avec 4 niveaux par défaut)

View File

@@ -21,7 +21,7 @@ make seed-mitre # télécharge le bundle pinné v19.0 (~50 MB, ~1 s parse)
## 2. Tests automatisés ## 2. Tests automatisés
```bash ```bash
make test-api # 53 tests pytest dont 14 nouveaux MITRE (parser + 5 read endpoints + matrix + status) make test-api # 58 tests pytest dont 19 MITRE (parser, idempotence, security guards, all endpoints, dotted fallback, version clearing)
make e2e # 34 tests Playwright dont 6 M4 make e2e # 34 tests Playwright dont 6 M4
``` ```

131
tasks/testing-m5.md Normal file
View File

@@ -0,0 +1,131 @@
---
type: testing
milestone: M5
date: "2026-05-12"
project: Metamorph
---
# Testing M5 — Templates : tests unitaires & scénarios
## 1. Lancement de la stack
```bash
make clean
make up
make migrate
make seed-mitre # tag picker needs the catalogue
```
> L'admin stable `admin@metamorph.local / AdminPass1234!` est restauré
> automatiquement par le hook `afterAll` du spec e2e M5 — mais la 1ʳᵉ fois,
> bootstrappe-le via `/setup` ou laisse les tests faire le travail.
## 2. Tests automatisés
```bash
make test-api # 81 tests pytest dont 23 M5 (CRUD, perm, mitre tags, reorder, AND-semantics, extra="forbid", item caps, empty-clear)
make e2e # 38 tests Playwright dont 4 M5 (API CRUD + scenario reorder + SPA list/filter)
```
Rapport HTML : `e2e/playwright-report/`. JUnit : `e2e/playwright-report/junit.xml`.
## 3. Smoke navigateur
### Pré-requis
- Stack `make up` + admin loggé.
- MITRE seedé (vérifier via `/mitre`).
### 3.1 Catalogue de tests (`/admin/tests`)
1. Cliquer **Tests** dans la nav admin → page chargée.
2. Cliquer **+ New test** → modal s'ouvre avec :
- Champs : Name, Description, Objective, Procedure (markdown), Prerequisites, Red expected, Blue expected, OPSEC, Free tags, Expected IOCs.
- Sous-section **MITRE ATT&CK tags** : matrice complète, mêmes interactions que `/mitre`.
3. Remplir au minimum `Name=phish-link`, OPSEC=`low`, ajouter 2 tags MITRE (ex. `TA0001 + T1566`) → **Create** → carte apparaît dans la liste avec chips OPSEC + MITRE.
4. Cliquer **Edit** sur la carte → modal pré-remplie, modifier OPSEC à `high`**Save** → la card est repeinte avec l'accent rouge OPSEC.
5. Filtres en haut :
- `Search` (full-text q sur nom/description)
- `Tactic external_id` (ex. `TA0001`)
- `OPSEC` (select : —all— / low / medium / high)
- `Free tag` (mot-clé libre)
6. Cliquer **Delete** sur une carte → confirm popup → la card disparaît (soft-delete : visible via `?include_deleted=true` côté API).
### 3.2 Catalogue de scénarios (`/admin/scenarios`)
1. Cliquer **Scenarios** dans la nav admin.
2. **+ New scenario** → modal.
- Champs Name + Description.
- Catalogue picker en bas : champ de recherche + liste des tests dispos (max 50).
3. Cliquer 3 tests dans le catalogue → ils s'empilent dans la liste ordonnée avec leurs indices `01/02/03`.
4. **Drag-and-drop** : empoigner la poignée `☰` à gauche d'une ligne et glisser vers le haut/bas → la liste se réordonne. La grille met à jour les indices au relâchement.
5. **Save** → carte apparaît avec un Tag « N tests » + l'aperçu des 4 premiers tests dans l'ordre choisi.
6. Re-ouvrir Edit → l'ordre est persisté côté serveur (vérifie le numéro 01, 02, 03 dans la modal).
7. Supprimer un `test_template` dont un scénario dépend (via `/admin/tests`) → la card scénario marque le test en rose dans le résumé (`test_template_deleted: true`).
### 3.3 Permissions
1. Inviter Bob via Admin > Invitations sans groupe → Bob peut se logger mais reçoit `403` sur `/api/v1/test-templates`.
2. Lui attacher un groupe avec seulement `test_template.read` → Bob voit `/admin/tests`... non, l'UI gate sur `is_admin`. La perm seule donne l'accès API ; l'UI ne l'expose pas pour les non-admins (par design M5).
3. Bob tente `POST /api/v1/test-templates``403` (manque `test_template.create`).
## 4. Smoke API
### 4.1 Login admin
```bash
ACCESS=$(curl -sX POST http://localhost:8080/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@metamorph.local","password":"AdminPass1234!"}' | jq -r .access_token)
```
### 4.2 Créer un test taggué MITRE
```bash
curl -sX POST http://localhost:8080/api/v1/test-templates \
-H "Authorization: Bearer $ACCESS" \
-H 'Content-Type: application/json' \
-d '{
"name": "lsass-dump",
"opsec_level": "high",
"tags": ["creds"],
"mitre_tags": [
{"kind":"technique","external_id":"T1003"},
{"kind":"subtechnique","external_id":"T1003.001"}
]
}' | jq
```
### 4.3 Créer un scénario ordonné
```bash
# Suppose 3 ids: $A $B $C
curl -sX POST http://localhost:8080/api/v1/scenario-templates \
-H "Authorization: Bearer $ACCESS" \
-H 'Content-Type: application/json' \
-d "{\"name\":\"chained\",\"test_template_ids\":[\"$A\",\"$B\",\"$C\"]}" | jq
# Reorder (full replace)
curl -sX PUT http://localhost:8080/api/v1/scenario-templates/<scn_id>/tests \
-H "Authorization: Bearer $ACCESS" \
-H 'Content-Type: application/json' \
-d "{\"test_template_ids\":[\"$C\",\"$A\",\"$B\"]}" | jq
```
### 4.4 Filtre par tactic
```bash
curl -s "http://localhost:8080/api/v1/test-templates?tactic=TA0006" \
-H "Authorization: Bearer $ACCESS" | jq '.items[].name'
```
## 5. Points de contrôle critiques
- [x] `POST /test-templates` rejette MITRE inconnu avec `400 unknown_mitre_tag`.
- [x] `POST /test-templates` rejette opsec hors `low/medium/high`.
- [x] `PUT /test-templates/{id}` partial keeps unset fields.
- [x] `PUT /test-templates/{id}` avec `mitre_tags` **remplace** la collection (pas d'append).
- [x] `DELETE /test-templates/{id}` soft-delete (visible avec `?include_deleted=true`).
- [x] `POST /scenario-templates` rejette test_template inconnu ou soft-deleted.
- [x] `PUT /scenario-templates/{id}/tests` rewrite atomique (delete + re-insert, contrainte UNIQUE(position) honorée).
- [x] Un test soft-deleted **après** linking reste référencé : `test_template_deleted: true` sur le scénario.
- [x] Filtres list: `q`, `tactic`, `technique`, `subtechnique`, `opsec`, `tag` cumulatifs.
- [x] Perm gating : `test_template.{read,create,update,delete}` + `scenario_template.{read,create,update,delete}`.
- [x] `/diag/reset` truncate les 4 nouvelles tables (`scenario_template_tests`, `scenario_templates`, `test_template_mitre_tags`, `test_templates`) avant les tables MITRE.
- [x] UI : drag-and-drop @dnd-kit/sortable réordonne la liste, save persistant.

124
tasks/testing-m6.md Normal file
View File

@@ -0,0 +1,124 @@
---
type: testing
milestone: M6
date: "2026-05-13"
project: Metamorph
---
# Testing M6 — Missions & snapshot
## 1. Lancement de la stack
```bash
make clean
make up
make migrate
make seed-mitre # MITRE tags are snapshotted onto mission_tests; without them
# the snapshot will simply have an empty mitre_tags array
```
> L'admin stable `admin@metamorph.local / AdminPass1234!` est restauré
> automatiquement par le hook `afterAll` du spec e2e M6, mais la 1ʳᵉ fois,
> bootstrappe-le via `/setup` (ou laisse les tests faire le travail).
## 2. Tests automatisés
```bash
make test-api # 103 tests pytest dont 22 M6 (snapshot, membership, transitions, members CRUD, perm gating)
make e2e # 43 tests Playwright dont 5 M6 (snapshot freezing, non-admin visibility, transitions, wizard, list filter)
```
Rapport HTML : `e2e/playwright-report/`.
## 3. Smoke navigateur
### Pré-requis
- Stack `make up` + admin loggé.
- MITRE seedé (`/mitre` montre 15 tactics).
- Au moins **1 test_template** et **1 scenario_template** dans le catalogue M5
(pour avoir quelque chose à snapshotter).
### 3.1 Liste & création (`/missions`)
1. Cliquer **Missions** dans la nav (visible si tu as la perm `mission.read` ou tu es admin) → la liste s'affiche avec un message vide la 1ʳᵉ fois.
2. Cliquer **+ New mission** → page wizard `/missions/new`.
3. **Étape 1 — Metadata** :
- `Name` (requis) → `purple-2026-Q2`
- `Client / target``Acme Corp`
- `Start date` / `End date` → si tu inverses, un message en rouge apparaît et **Next** est désactivé.
- `ROE / Description` (markdown) → optionnel.
4. **Next****Étape 2 — Scenarios** :
- Le catalogue M5 s'affiche en grille de boutons. Cliquer un scénario le sélectionne (bordure cyan).
- Le sous-titre du Card affiche le total de tests qui seront snapshotés.
5. **Next****Étape 3 — Members** :
- Le roster (issu de `/users/roster`) liste les utilisateurs actifs.
- Pour chaque user, deux boutons **Red** / **Blue** togglent l'inclusion + le rôle. ✕ retire.
- Si tu es un redteamer non-admin, tu es pré-sélectionné en `red` (auto-add côté backend si tu oublies).
6. **Create mission** → redirection vers `/missions/<id>`. La nouvelle mission apparaît en haut de la liste après retour.
### 3.2 Filtres (`/missions`)
- **Search** : full-text sur `name` / `description_md`.
- **Client** : LIKE sur `client_target`.
- **Status** : select draft / in_progress / completed / archived.
- Les filtres sont combinés en AND (ex : `status=in_progress & client=acme`).
### 3.3 Page détail (`/missions/<id>`)
1. En-tête : nom + status pill + boutons de transition.
- **draft** → boutons `→ In Progress` et `→ Archived`.
- **in_progress** → `→ Completed` et `→ Archived`.
- **completed** → `→ Archived` uniquement.
- **archived** → aucun bouton.
2. Cliquer un bouton → status update immédiat (cache invalidé, badge re-rendu).
3. **Delete** (en rose) → confirm prompt → soft-delete → redirige vers `/missions`. Réapparait via `?include_deleted=true` (admin only).
4. **Tabs** :
- **tests** : tableau par scénario avec `# | Test | MITRE | OPSEC | State`. Les MITRE chips affichent l'external_id frozen.
- **members** : pills Red/Blue avec email + display_name.
- **synthesis** : placeholder « lands in M10 ».
- **export** : placeholder « lands in M11 ».
## 4. Vérification du snapshot (DoD)
1. Crée une mission qui référence un scenario_template `sc1` contenant `test_template_t1`.
2. Aller dans `/admin/tests`, éditer `test_template_t1` : changer le nom et les tags MITRE.
3. Retour sur `/missions/<id>` (rafraîchir si la cache TanStack tient encore) → la table montre **toujours** l'ancien nom et l'ancien tag MITRE. Le snapshot est gelé. ✅
## 5. Vérification visibilité par membership
1. Login en admin, créer 2 missions :
- `m-only-admin` sans aucun membre.
- `m-shared` avec Alice (red) en membre.
2. Login en Alice.
3. `/missions` → seule `m-shared` apparaît dans la liste. `GET /api/v1/missions/<m-only-admin>` retourne **404** (pas 403 — pas de fuite d'existence).
4. Alice tente de PUT/transition/delete sur `m-only-admin` → 404 idem.
## 6. Vérification transitions
| from | to | result |
|-------------|---------------|--------|
| draft | in_progress | 200 |
| draft | archived | 200 |
| draft | completed | **409 invalid_transition** |
| in_progress | completed | 200 |
| in_progress | archived | 200 |
| completed | archived | 200 |
| completed | in_progress | **409** |
| archived | (anything) | **409** |
| any | (same status) | 200 (no-op) |
```bash
curl -X POST -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
-d '{"status":"completed"}' \
http://localhost:8080/api/v1/missions/<id>/transition
```
## 7. Quick teardown
```bash
make down
# ou pour un reset complet :
curl -X POST http://localhost:8080/api/v1/diag/reset # test-only, wipes everything
```
> Reminder: `make test-api` and `make e2e` **share the dev DB container** —
> running them mid-session WILL wipe user data. The M6 spec's `afterAll`
> restores the stable admin and re-seeds MITRE, but custom templates / missions
> you've created by hand are lost. Cf. `tasks/lessons.md` (M5 lessons section).

237
tasks/testing-m7.md Normal file
View File

@@ -0,0 +1,237 @@
---
type: testing
milestone: M7
date: "2026-05-14"
project: Metamorph
---
# Testing M7 — Red & blue execution on a mission test
## 1. Lancement de la stack
```bash
make up
make migrate # applies the M7 last_actor_id migration (91a4e7c6d2f3)
```
Le boot seede automatiquement les 4 detection_levels par défaut
(`detected_blocked` / `detected_alert` / `logged_only` / `not_detected`) via
`seed_detection_levels()`. Si tu pars d'un stack pré-existant, un `make
restart` (down+up) suffit — le seed est idempotent.
> L'admin stable `admin@metamorph.local / AdminPass1234!` est restauré par
> le hook `afterAll` du spec e2e M7. La 1ʳᵉ fois, bootstrappe-le via `/setup`.
## 2. Tests automatisés
```bash
make test-api # 131 tests pytest, dont 25 M7 (perm gating, state machine, evidence, activity)
make e2e # 48 tests Playwright, dont 5 M7 (red/blue gating, 24/26 MB, SHA256, SPA)
```
Rapport HTML : `e2e/playwright-report/`.
> **Reminder** : `make test-api` et `make e2e` partagent le Postgres dev.
> Lancer en milieu de session **wipe** les données — l'`afterAll` re-bootstrap
> l'admin stable, mais les missions/tests/uploads sur le disque créés à la
> main sont perdus.
## 3. Smoke navigateur
### Pré-requis
- Stack `make up` + admin loggé.
- Une mission existante avec au moins **1 scenario** snapshotté contenant
**≥ 1 test** (voir `testing-m6.md` pour le chemin de création).
### 3.0 Vue tabulaire (`/missions/<id>` — onglet tests, amendement 2026-05-15)
L'onglet **tests** rend désormais un tableau plein écran **par scénario**
(un row par test). Largeur pleine viewport (`max-w-page` échappé via la
recette `calc(50% - 50vw)` — même mécanisme que le picker MITRE).
**Colonnes** :
| Colonne | Read mode | Edit mode | Perm requise |
|---------|-----------|-----------|--------------|
| **Test** | nom + chips MITRE | (read-only) | — |
| **Procédure** | snapshot_objective / description tronqués 180 chars | (read-only) | — |
| **Exécution** | `executed_at` + `red_command` tronqué | datetime-local + textarea command | `mission.write_red_fields` |
| **Source de log** | `blue_log_source` | input texte (placeholder `EDR / Firewall / NDR …`) | `mission.write_blue_fields` |
| **Commentaires** | pill state + pill detection_level + commentaire | select detection_level + textarea commentaire | `mission.write_blue_fields` |
| **Logs SIEM** | `blue_siem_logs` tronqué 240 chars | textarea 6 lignes | `mission.write_blue_fields` |
| **Cyber Incident** | `incident_at` / `incident_number` / `incident_recipient_email` empilés | 3 inputs (datetime-local / texte / email) | `mission.write_blue_fields` |
| **Actions** | lien `open ↗` vers `/missions/<id>/tests/<test_id>` (full detail + evidence) | boutons `Save` / `Cancel` | — |
**Workflow d'édition** :
1. Double-clic sur une ligne → la ligne entre en mode édition (les cellules
deviennent des inputs). Une seule ligne en édition à la fois — un double-clic
sur une autre ligne propose `Discard unsaved changes?` si la précédente
est dirty.
2. **Esc** = cancel (prompt si dirty).
3. **Save** = `PUT /missions/{id}/tests/{test_id}` avec **uniquement les champs
modifiés**. Les cellules qu'un user ne peut pas écrire restent disabled ;
le serveur revalide quoi qu'il arrive (defense in depth).
4. Pour l'upload de preuves : cliquer `open ↗` qui ouvre la page détail
(zone Red / zone Blue + dropzone evidence).
### 3.1 Page de test (`/missions/<id>/tests/<test_id>`)
1. Depuis `/missions/<id>`, onglet **tests**, cliquer une ligne (ou le nom du
test). Redirection vers la page dédiée.
2. **En-tête** :
- `← Back to mission` (link `data-testid="back-to-mission"`).
- Nom du test (snapshot).
- Ligne *"Last touched Xs ago by Y"* — vide à la création, remplie dès qu'un
champ est sauvé.
- Status pill (`Pending` / `Executed` / `Reviewed` / `Skipped` / `Blocked`).
- Boutons de transitions autorisés depuis l'état courant (voir matrice en
§6).
3. **Card metadata** : MITRE chips, OPSEC tag, et 4 `<details>` pliés
(Objective / Procedure / Expected red / Expected blue).
### 3.2 Zone Red (bordure rouge)
- `Command` (mono, `data-testid="red-command"`).
- `Output` (textarea mono multilign, `data-testid="red-output"`).
- `Comment` (markdown, `data-testid="red-comment"`).
- Toggle **Override executed-at** + input datetime-local — disabled tant que
le test n'est pas `executed` / `reviewed_by_blue`.
- Bouton **Save red fields** :
- disabled si rien n'a changé ou si l'utilisateur n'a pas
`mission.write_red_fields`.
- Sur succès, le bandeau "Last touched" se met à jour (cache invalidé).
### 3.3 Zone Blue (bordure cyan)
- Select `Detection level` (sourcé de `/detection-levels`).
- `Comment` (markdown, `data-testid="blue-comment"`).
- Bouton **Save blue fields** (analogue à la zone red).
- **Evidence dropzone** :
- Drag & drop ou bouton **Pick files** (multi-fichiers).
- Sous la dropzone, la liste des extensions acceptées est affichée :
`Accepted: .png · .jpg · .jpeg · .pdf · .txt · .log · .json · .csv · .evtx · .zip · max 25 MB / file`
(testid `evidence-allowed-formats`).
- Le sélecteur OS est pré-filtré via l'attribut `accept` — pas de "All files".
- Un drag&drop d'une extension hors-liste est rejeté côté client avec
le message d'erreur en rouge ; le serveur re-vérifie quoi qu'il
arrive (`unsupported_extension` / `too_large`).
- Limite : **25 MB / fichier** (côté client = garde-fou UX, côté serveur =
stricte avec stream cap chunk-par-chunk).
- Table récap : nom · taille · uploader · `sha256[:12]…` · link download +
bouton soft-delete.
### 3.5 Override executed-at — pièges timezone
- Le toggle **Override executed-at timestamp** affiche un input
`datetime-local` qui parle en **heure locale du navigateur** (ex.
Europe/Paris UTC+2). L'état local du composant garde la valeur sous
forme `YYYY-MM-DDTHH:MM` ; la conversion en UTC ISO n'a lieu qu'au
submit. Donc si tu tapes `10:30` à Paris, le serveur reçoit
`08:30:00+00:00` — c'est attendu.
- Le polling activity (15 s) ne re-sync l'état local **que** sur changement
d'identité du test (`useEffect([test.id])`) — une frappe en cours n'est
jamais écrasée par un refetch.
### 3.4 Indicateur d'activité
- À l'arrivée sur la page, le polling `GET /missions/<id>/activity` démarre
(toutes les 15 s, gated sur `document.visibilityState === 'visible'`).
- Si un autre user édite le test, la query est invalidée → la page reload
les champs (TanStack cache replaced).
- Le `server_time` est passé en `?since=` à l'appel suivant pour ne recevoir
que ce qui a bougé depuis.
## 4. Vérifications fonctionnelles (DoD)
### 4.1 Red écrit en parallèle de Blue, sans conflit
1. Sur un test `pending`, login en red dans 1 onglet, en blue dans un autre.
2. Red : remplit `red_command` + sauve.
3. Blue : sélectionne `detected_alert` + commentaire + sauve.
4. Les 2 saves passent en 200, aucun conflit.
5. Rafraîchir l'onglet red → les champs blue apparaissent (et réciproquement).
### 4.2 Perm gating field-level
| User | red_command | red_comment | blue_comment | detection_level | upload |
|--------------|------------:|------------:|-------------:|----------------:|-------:|
| red | ✓ | ✓ | **403** | **403** | **403** |
| blue | **403** | **403** | ✓ | ✓ | ✓ |
| red + blue | ✓ | ✓ | ✓ | ✓ | ✓ |
| admin | ✓ | ✓ | ✓ | ✓ | ✓ |
### 4.3 Evidence upload — limites
1. Upload un fichier `.evtx` de **24 MB** → 201, body inclut `sha256`,
`size_bytes=25165824`, `mime=application/octet-stream`.
2. Vérif `sha256` côté client : `sha256sum file24.evtx` == `body.sha256`.
3. Upload un fichier `.evtx` de **26 MB** → 400 `{error:"too_large"}`.
4. Upload un fichier `.exe` (1 octet) → 400 `{error:"unsupported_extension"}`.
5. Download via le lien `download` → bytes byte-for-byte identiques.
### 4.4 Soft delete d'evidence
1. Upload un PDF, vérif qu'il apparaît dans la table.
2. Cliquer **delete** → confirmation → row disparaît.
3. `GET /evidence/<id>` → 404 (le row reste en DB avec `deleted_at` set,
mais le service l'occulte).
4. Sur disque, `/data/evidence/<mission_id>/<test_id>/<sha256>.pdf` est
**conservé** (purge physique = M12).
## 5. Vérification du state machine
| from | to | result | side requis |
|--------------------|-------------------|--------|-------------|
| pending | executed | 200 | red |
| pending | skipped | 200 | any |
| pending | blocked | 200 | any |
| pending | reviewed_by_blue | **409** | — |
| executed | reviewed_by_blue | 200 | blue |
| executed | pending | 200 | red (reset) |
| reviewed_by_blue | executed | 200 | blue |
| reviewed_by_blue | pending | **409** | — |
| skipped | pending | 200 | any |
| blocked | pending | 200 | any |
| any | (same state) | 200 | — (no-op) |
```bash
curl -X POST -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
-d '{"target_state":"executed"}' \
http://localhost:8080/api/v1/missions/<mid>/tests/<tid>/transition
```
Side-effect attendu : `target_state="executed"` stamp `executed_at=now()` et
remet `executed_at_overridden=false`. Le retour à `pending` efface
`executed_at`.
## 6. Vérification override executed_at
1. État `pending` → PUT `{"executed_at": "...", "executed_at_overridden": true}`
**400** (refusé tant que le test n'a pas été marqué executed).
2. Transition `pending → executed``executed_at` auto-stamp.
3. PUT `{"executed_at":"2026-05-14T10:00:00+00:00","executed_at_overridden":true}`
→ 200, body reflète la nouvelle date + override=true.
4. Blue user tente le même PUT → **403** (executed_at est red-side).
## 7. Vérification activity polling
```bash
# Snapshot t0
curl -H "Authorization: Bearer $T" \
http://localhost:8080/api/v1/missions/<mid>/activity \
| jq .server_time
# Mutate
curl -X PUT -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
-d '{"red_comment_md":"poke"}' \
http://localhost:8080/api/v1/missions/<mid>/tests/<tid>
# Poll t1 (URL-encode the timestamp's `+`)
SINCE=$(python -c "import urllib.parse;print(urllib.parse.quote('${T0}'))")
curl -H "Authorization: Bearer $T" \
"http://localhost:8080/api/v1/missions/<mid>/activity?since=${SINCE}"
```
Réponse attendue : 1 entrée pour le test mis à jour, avec `last_actor_email`
peuplé.
## 8. Quick teardown
```bash
make down
# ou reset complet (test-only) :
curl -X POST http://localhost:8080/api/v1/diag/reset
```

View File

@@ -117,7 +117,7 @@ spec: tasks/spec.md
--- ---
## M5 — Templates : tests unitaires & scénarios ## M5 — Templates : tests unitaires & scénarios
**But** : admin peut bâtir le catalogue réutilisable. **But** : admin peut bâtir le catalogue réutilisable.
@@ -133,7 +133,7 @@ spec: tasks/spec.md
--- ---
## M6 — Missions & snapshot ## M6 — Missions & snapshot
**But** : transformer les templates en missions vivantes. **But** : transformer les templates en missions vivantes.
@@ -149,21 +149,33 @@ spec: tasks/spec.md
--- ---
## M7 — Saisie red & blue sur un test ## M7 — Saisie red & blue sur un test ☑ (+ amendement 2026-05-15 ↻)
**But** : exécution de la mission, le cœur du produit. **But** : exécution de la mission, le cœur du produit.
- Modale ou page dédiée `Mission > Test #N` avec deux zones distinctes (red / blue), bordures accentuées par couleur (rouge / cyan). - Modale ou page dédiée `Mission > Test #N` avec deux zones distinctes (red / blue), bordures accentuées par couleur (rouge / cyan).
- Côté red : champ commande (mono), output (mono multiline), commentaire markdown, bouton « Marquer exécuté » qui set `state=executed` + `executed_at=now()` ; édition de `executed_at` derrière un toggle « override ». - Côté red : champ commande (mono), output (mono multiline), commentaire markdown, bouton « Marquer exécuté » qui set `state=executed` + `executed_at=now()` ; édition de `executed_at` derrière un toggle « override ».
- Côté blue : sélecteur `detection_level`, commentaire markdown, zone d'upload multi-fichiers (drag-and-drop). - Côté blue : sélecteur `detection_level`, commentaire markdown, zone d'upload multi-fichiers (drag-and-drop).
- Upload preuves : `POST /missions/{id}/tests/{test_id}/evidence` (multipart, validation extension+MIME+taille≤25Mo, calcul SHA256, stockage `/data/evidence/<mission_id>/<test_id>/<sha256>{ext}`). - Upload preuves : `POST /missions/{id}/tests/{test_id}/evidence` (multipart, validation extension+MIME+taille≤25Mo, calcul SHA256, stockage `/data/evidence/<mission_id>/<test_id>/<sha256>{ext}`).
- `GET /evidence/{id}` (download, vérif perm) ; `DELETE /evidence/{id}` (soft). - `GET /evidence/{id}` (download, vérif perm) ; `DELETE /evidence/{id}` (soft).
- Permissions : tout endpoint d'écriture vérifie `mission.write_red_fields` ou `mission.write_blue_fields` selon le champ touché ; les deux peuvent coexister sur un même groupe (pas exclusifs en code). - Permissions : tout endpoint d'écriture vérifie `mission.write_red_fields` ou `mission.write_blue_fields` selon le champ touché ; les deux peuvent coexister sur un même groupe (pas exclusifs en code).
- Bouton « Statut » avec choix `executed`, `reviewed_by_blue`, `skipped`, `blocked` (transitions contrôlées : pending↔skipped/blocked, executed→reviewed_by_blue). - Bouton « Statut » avec choix `executed`, `reviewed_by_blue`, `skipped`, `blocked` (transitions contrôlées : pending↔skipped/blocked, executed→reviewed_by_blue).
- Indicateur « modifié par X il y a Ns » : polling `GET /missions/{id}/activity?since=…` toutes les 15 s tant que la page est active. - Indicateur « modifié par X il y a Ns » : polling `GET /missions/{id}/activity?since=…` toutes les 15 s tant que la page est active.
**DoD** : red et blue saisissent en parallèle sans conflit ; un user sans `write_blue_fields` reçoit 403 sur les champs blue ; un fichier .evtx de 24 Mo est uploadé, un de 26 Mo est rejeté ; le hash SHA256 est correct. **DoD** : red et blue saisissent en parallèle sans conflit ; un user sans `write_blue_fields` reçoit 403 sur les champs blue ; un fichier .evtx de 24 Mo est uploadé, un de 26 Mo est rejeté ; le hash SHA256 est correct.
### Amendement 2026-05-15 — fiche de review blue étendue + vue tabulaire
- ☑ Migration `c2a8f4b1d6e9` : 5 nouvelles colonnes sur `mission_tests``blue_log_source` (varchar 120), `blue_siem_logs` (text), `blue_incident_at` (timestamptz), `blue_incident_number` (varchar 120), `blue_incident_recipient_email` (varchar 255).
- ☑ Service `mission_tests` : `_BLUE_FIELDS` étendu aux 5 nouveaux champs, `update_mission_test_fields` accepte chaque kwarg, perm gating identique (red user → 403 sur chaque champ).
- ☑ Service `missions` : `MissionTestView` (vue nested dans `GET /missions/{id}`) inclut désormais toutes les annotations red/blue + `last_actor_*` + `updated_at` + `detection_level_key`, avec batch-lookup détection-level/users pour rester O(1) en nombre de tests.
- ☑ API : `UpdateMissionTestPayload` + serializer `_serialize_test` / `_serialize_test_detail` mis à jour, validation length-cap par champ.
- ☑ Tests pytest : 3 nouveaux (`test_blue_user_writes_new_blue_review_fields`, `test_red_user_cannot_write_new_blue_review_fields`, `test_blue_review_fields_survive_round_trip_via_get`) — 136 verts.
- ☐ Frontend : vue tabulaire pleine largeur dans l'onglet **tests** du detail page, un tableau par scénario, colonnes `Test | Procédure | Exécution | Source de log | Commentaires | Logs SIEM | Cyber Incident`, double-clic = mode édition inline gated par perms. La page `/missions/<id>/tests/<test_id>` reste pour l'upload de preuves.
- ☐ Docs : CHANGELOG section dédiée, testing-m7.md mise à jour pour la matrice de colonnes + le workflow d'édition inline.
**DoD amendement** : blue user double-clique sur une ligne, saisit `log_source` + `siem_logs` + le sous-record incident, sauve ; rafraîchir la mission → tout est persisté ; red user double-clique sur la même ligne → ne peut éditer que `Exécution` (`executed_at`, `red_command`).
--- ---
## M8 — Niveaux de détection custom ☐ ## M8 — Niveaux de détection custom ☐