Compare commits
21 Commits
37e9e03f02
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c1675966d | |||
|
|
b62651a215 | ||
|
|
4d2b6731ac | ||
|
|
e1b51db25f | ||
|
|
00b7557e30 | ||
| a57d91f176 | |||
|
|
a7e5bc030f | ||
|
|
873aa3774a | ||
|
|
ce4bd40551 | ||
|
|
a559823386 | ||
|
|
2781ce4117 | ||
|
|
b8fd99a5f4 | ||
| e5f3de8f55 | |||
|
|
2c85f9b57e | ||
|
|
8b1de6e258 | ||
|
|
54adfee690 | ||
|
|
63b48addc0 | ||
|
|
7a69f10f3e | ||
|
|
b52cb0e5e4 | ||
|
|
8742fb2b6e | ||
|
|
7dbe2dbc28 |
105
CHANGELOG.md
105
CHANGELOG.md
@@ -4,6 +4,94 @@ All notable changes to this project will be documented here. Format: [Keep a Cha
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 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 +102,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 +115,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 +133,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` → **51 pytest pass** (1 health + 8 schema + 15 auth + 15 RBAC + 12 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.
|
||||||
|
|
||||||
|
|||||||
@@ -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**: M0–M4 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**: M0–M5 delivered (bootstrap → DB schema → auth → RBAC → MITRE ATT&CK reference → test & scenario templates). See `tasks/spec.md` for the full specification and `tasks/todo.md` for the milestone-by-milestone plan.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
@@ -11,6 +11,8 @@ 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.
|
||||||
- **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 +95,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-m6.md) (current: `testing-m6.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 +137,7 @@ The hooks run `ruff` + `ruff-format` on the backend and `eslint` / `tsc --noEmit
|
|||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
See `tasks/todo.md`. Current milestone: **M4 — MITRE ATT&CK Enterprise**. Next: M5 (test & scenario templates).
|
See `tasks/todo.md`. Current milestone: **M6 — Missions & snapshot** (done). Next: M7 (red/blue execution on a mission test).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,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
|
||||||
|
|||||||
498
backend/app/api/missions.py
Normal file
498
backend/app/api/missions.py
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
"""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`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import Blueprint, abort, g, jsonify, request
|
||||||
|
from pydantic import BaseModel, Field, ValidationError
|
||||||
|
|
||||||
|
from app.core.auth_decorators import AuthenticatedUser, require_auth, require_perm
|
||||||
|
from app.services import missions as svc
|
||||||
|
|
||||||
|
bp = Blueprint("missions", __name__, url_prefix="/missions")
|
||||||
|
log = logging.getLogger("metamorph.api.missions")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 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
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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()}), 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()}), 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()}), 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()}), 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()}), 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})
|
||||||
@@ -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")
|
||||||
|
|
||||||
@@ -175,6 +191,65 @@ def list_subtechniques():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/matrix")
|
||||||
|
@require_auth
|
||||||
|
def matrix():
|
||||||
|
"""Return the full Enterprise matrix: tactics → techniques → sub-techniques.
|
||||||
|
|
||||||
|
One-shot endpoint so the SPA can render the flat attack.mitre.org-style
|
||||||
|
grid without firing 15 parallel queries. The payload is ~55 KB serialised
|
||||||
|
against MITRE v19 (15 tactics × ~50 techniques × ~3 subs).
|
||||||
|
"""
|
||||||
|
with session_scope() as s:
|
||||||
|
# All techniques + their tactics (selectin-loaded by the relationship).
|
||||||
|
techniques = s.scalars(
|
||||||
|
select(MitreTechnique).order_by(MitreTechnique.external_id.asc())
|
||||||
|
).all()
|
||||||
|
# Sub-techniques bucketed by parent.
|
||||||
|
subs_by_parent: dict = {}
|
||||||
|
for sb in s.scalars(
|
||||||
|
select(MitreSubtechnique).order_by(MitreSubtechnique.external_id.asc())
|
||||||
|
).all():
|
||||||
|
subs_by_parent.setdefault(sb.technique_id, []).append(
|
||||||
|
{
|
||||||
|
"id": str(sb.id),
|
||||||
|
"external_id": sb.external_id,
|
||||||
|
"name": sb.name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Tactics in canonical kill-chain order (matches attack.mitre.org).
|
||||||
|
tactics = s.scalars(
|
||||||
|
select(MitreTactic).order_by(MitreTactic.external_id.asc())
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Group techniques by tactic short_name.
|
||||||
|
techs_by_tactic: dict = {}
|
||||||
|
for t in techniques:
|
||||||
|
entry = {
|
||||||
|
"id": str(t.id),
|
||||||
|
"external_id": t.external_id,
|
||||||
|
"name": t.name,
|
||||||
|
"subtechniques": subs_by_parent.get(t.id, []),
|
||||||
|
}
|
||||||
|
for tac in t.tactics:
|
||||||
|
techs_by_tactic.setdefault(tac.short_name, []).append(entry)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"tactics": [
|
||||||
|
{
|
||||||
|
"id": str(t.id),
|
||||||
|
"external_id": t.external_id,
|
||||||
|
"short_name": t.short_name,
|
||||||
|
"name": t.name,
|
||||||
|
"techniques": techs_by_tactic.get(t.short_name, []),
|
||||||
|
}
|
||||||
|
for t in tactics
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/status")
|
@bp.get("/status")
|
||||||
@require_auth
|
@require_auth
|
||||||
def status():
|
def status():
|
||||||
@@ -189,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
|
||||||
@@ -202,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)
|
||||||
|
|||||||
208
backend/app/api/scenario_templates.py
Normal file
208
backend/app/api/scenario_templates.py
Normal 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()}), 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()}), 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()}), 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})
|
||||||
257
backend/app/api/test_templates.py
Normal file
257
backend/app/api/test_templates.py
Normal 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()}), 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()}), 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})
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ from app.api.diag import bp as diag_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 +27,6 @@ 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)
|
||||||
|
|||||||
944
backend/app/services/missions.py
Normal file
944
backend/app/services/missions.py
Normal file
@@ -0,0 +1,944 @@
|
|||||||
|
"""Mission CRUD + snapshot service.
|
||||||
|
|
||||||
|
A mission is a *materialised* run of one or more scenario templates: when the
|
||||||
|
mission is created (or scenarios are appended later), the service copies the
|
||||||
|
template rows into `mission_scenarios` / `mission_tests` / `mission_test_mitre_tags`
|
||||||
|
verbatim. Editing the source templates afterwards does not touch the mission —
|
||||||
|
that's the snapshot contract from spec §11.
|
||||||
|
|
||||||
|
Visibility rule (spec §4, last bullet): a non-admin user can only see a mission
|
||||||
|
they are a member of. The decorator layer enforces *which type of action* is
|
||||||
|
allowed (perm codes); this service enforces *which mission* is visible.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from typing import Any, Iterable
|
||||||
|
|
||||||
|
from sqlalchemy import func, or_, select, text
|
||||||
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
|
from app.db.session import session_scope
|
||||||
|
from app.db.types import (
|
||||||
|
MISSION_ROLE_HINTS,
|
||||||
|
MISSION_STATUSES,
|
||||||
|
)
|
||||||
|
from app.models.auth import User
|
||||||
|
from app.models.mission import (
|
||||||
|
Mission,
|
||||||
|
MissionMember,
|
||||||
|
MissionScenario,
|
||||||
|
MissionTest,
|
||||||
|
MissionTestMitreTag,
|
||||||
|
)
|
||||||
|
from app.models.mitre import MitreSubtechnique, MitreTactic, MitreTechnique
|
||||||
|
from app.models.template import (
|
||||||
|
ScenarioTemplate,
|
||||||
|
TestTemplate,
|
||||||
|
TestTemplateMitreTag,
|
||||||
|
)
|
||||||
|
|
||||||
|
_UNSET: Any = object()
|
||||||
|
|
||||||
|
|
||||||
|
# Status transition graph. A target status that's not in the source's set is
|
||||||
|
# rejected as InvalidTransition. `archived` is a one-way sink (un-archiving
|
||||||
|
# would require an explicit restore endpoint, out of M6 scope).
|
||||||
|
_VALID_TRANSITIONS: dict[str, frozenset[str]] = {
|
||||||
|
"draft": frozenset({"in_progress", "archived"}),
|
||||||
|
"in_progress": frozenset({"completed", "archived"}),
|
||||||
|
"completed": frozenset({"archived"}),
|
||||||
|
"archived": frozenset(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Exceptions
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
class MissionNotFound(Exception):
|
||||||
|
"""Mission missing, soft-deleted, or not visible to the viewer."""
|
||||||
|
|
||||||
|
|
||||||
|
class UnknownScenarioTemplate(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UnknownUser(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidTransition(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidMemberPayload(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Views (detached dataclasses — safe to return after session_scope exits)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MemberAssignment:
|
||||||
|
"""Inbound member spec. The service resolves the user and validates the hint."""
|
||||||
|
|
||||||
|
user_id: uuid.UUID
|
||||||
|
role_hint: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MissionMemberView:
|
||||||
|
user_id: uuid.UUID
|
||||||
|
user_email: str
|
||||||
|
user_display_name: str | None
|
||||||
|
role_hint: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MissionMitreTagView:
|
||||||
|
kind: str
|
||||||
|
external_id: str
|
||||||
|
name: str
|
||||||
|
url: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MissionTestView:
|
||||||
|
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
|
||||||
|
mitre_tags: list[MissionMitreTagView]
|
||||||
|
source_test_template_id: uuid.UUID | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MissionScenarioView:
|
||||||
|
id: uuid.UUID
|
||||||
|
position: int
|
||||||
|
snapshot_name: str
|
||||||
|
snapshot_description: str | None
|
||||||
|
tests: list[MissionTestView]
|
||||||
|
source_scenario_template_id: uuid.UUID | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MissionListItemView:
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
client_target: str | None
|
||||||
|
date_start: date | None
|
||||||
|
date_end: date | None
|
||||||
|
status: str
|
||||||
|
description_md: str | None
|
||||||
|
visibility_mode: str
|
||||||
|
scenarios_count: int
|
||||||
|
tests_count: int
|
||||||
|
members_count: int
|
||||||
|
deleted_at: datetime | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MissionView:
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
client_target: str | None
|
||||||
|
date_start: date | None
|
||||||
|
date_end: date | None
|
||||||
|
status: str
|
||||||
|
description_md: str | None
|
||||||
|
visibility_mode: str
|
||||||
|
scenarios_count: int
|
||||||
|
tests_count: int
|
||||||
|
members_count: int
|
||||||
|
deleted_at: datetime | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
scenarios: list[MissionScenarioView]
|
||||||
|
members: list[MissionMemberView]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Helpers
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _opt_str(value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
v = value.strip()
|
||||||
|
return v or None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_name(value: str) -> str:
|
||||||
|
name = (value or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise ValueError("name is required")
|
||||||
|
if len(name) > 255:
|
||||||
|
raise ValueError("name must be ≤ 255 characters")
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_dates(date_start: date | None, date_end: date | None) -> None:
|
||||||
|
if date_start and date_end and date_end < date_start:
|
||||||
|
raise ValueError("date_end must be on or after date_start")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_status(value: str) -> str:
|
||||||
|
if value not in MISSION_STATUSES:
|
||||||
|
raise ValueError(f"status must be one of {MISSION_STATUSES}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_role_hint(value: str) -> str:
|
||||||
|
if value not in MISSION_ROLE_HINTS:
|
||||||
|
raise InvalidMemberPayload(f"role_hint must be one of {MISSION_ROLE_HINTS}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _escape_like(raw: str) -> str:
|
||||||
|
"""Escape LIKE wildcards so user-typed `%` / `_` / `\\` stay literal."""
|
||||||
|
return raw.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_scenario_ids_for_snapshot(s: Session, scenario_ids: list[uuid.UUID]) -> None:
|
||||||
|
"""Acquire a per-scenario `pg_advisory_xact_lock` for every source scenario
|
||||||
|
we're about to snapshot.
|
||||||
|
|
||||||
|
Why: a concurrent admin invoking `set_scenario_tests(scenario_id)` (M5)
|
||||||
|
deletes-then-reinserts the `scenario_template_tests` join rows mid-transaction.
|
||||||
|
Under READ COMMITTED, `_snapshot_scenarios` could observe a partial view
|
||||||
|
(selectinload re-queries) and freeze a torn snapshot. Sharing the same lock
|
||||||
|
key as `app.services.scenario_templates.set_scenario_tests` makes the
|
||||||
|
snapshot wait until the reorder commits (and vice versa).
|
||||||
|
|
||||||
|
The lock keys are derived deterministically from the scenario UUIDs via
|
||||||
|
blake2b (cf. lessons: `hash()` is randomised per-worker). We sort the keys
|
||||||
|
before acquiring to avoid deadlocks with another snapshotter that holds
|
||||||
|
them in a different order.
|
||||||
|
"""
|
||||||
|
if not scenario_ids:
|
||||||
|
return
|
||||||
|
keys: list[int] = []
|
||||||
|
for sid in scenario_ids:
|
||||||
|
digest = hashlib.blake2b(sid.bytes, digest_size=8).digest()
|
||||||
|
keys.append(int.from_bytes(digest, "big", signed=True))
|
||||||
|
for key in sorted(keys):
|
||||||
|
s.execute(
|
||||||
|
text("SELECT pg_advisory_xact_lock(CAST(:key AS bigint))"),
|
||||||
|
{"key": key},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_member(s: Session, mission_id: uuid.UUID, viewer_id: uuid.UUID) -> bool:
|
||||||
|
return (
|
||||||
|
s.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(MissionMember)
|
||||||
|
.where(
|
||||||
|
MissionMember.mission_id == mission_id,
|
||||||
|
MissionMember.user_id == viewer_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def _membership_filter(viewer_id: uuid.UUID):
|
||||||
|
"""SQL predicate restricting to missions where viewer_id is a member."""
|
||||||
|
return Mission.id.in_(
|
||||||
|
select(MissionMember.mission_id).where(MissionMember.user_id == viewer_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_users_map(s: Session, ids: Iterable[uuid.UUID]) -> dict[uuid.UUID, User]:
|
||||||
|
ids_list = [i for i in ids]
|
||||||
|
if not ids_list:
|
||||||
|
return {}
|
||||||
|
rows = s.scalars(
|
||||||
|
select(User).where(User.id.in_(ids_list), User.deleted_at.is_(None))
|
||||||
|
).all()
|
||||||
|
return {u.id: u for u in rows}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# MITRE denormalisation
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_mitre_ids(
|
||||||
|
tag_rows: Iterable[TestTemplateMitreTag],
|
||||||
|
) -> tuple[set[uuid.UUID], set[uuid.UUID], set[uuid.UUID]]:
|
||||||
|
tactic_ids: set[uuid.UUID] = set()
|
||||||
|
technique_ids: set[uuid.UUID] = set()
|
||||||
|
sub_ids: set[uuid.UUID] = set()
|
||||||
|
for tag in tag_rows:
|
||||||
|
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)
|
||||||
|
return tactic_ids, technique_ids, sub_ids
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_mitre_lookup(
|
||||||
|
s: Session,
|
||||||
|
tactic_ids: set[uuid.UUID],
|
||||||
|
technique_ids: set[uuid.UUID],
|
||||||
|
sub_ids: set[uuid.UUID],
|
||||||
|
) -> tuple[
|
||||||
|
dict[uuid.UUID, MitreTactic],
|
||||||
|
dict[uuid.UUID, MitreTechnique],
|
||||||
|
dict[uuid.UUID, MitreSubtechnique],
|
||||||
|
]:
|
||||||
|
"""Batch-load all MITRE rows referenced by a snapshot in 3 queries."""
|
||||||
|
tactic_map: dict[uuid.UUID, MitreTactic] = {}
|
||||||
|
technique_map: dict[uuid.UUID, MitreTechnique] = {}
|
||||||
|
sub_map: dict[uuid.UUID, MitreSubtechnique] = {}
|
||||||
|
if tactic_ids:
|
||||||
|
tactic_map = {
|
||||||
|
r.id: r
|
||||||
|
for r in s.scalars(
|
||||||
|
select(MitreTactic).where(MitreTactic.id.in_(tactic_ids))
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
if technique_ids:
|
||||||
|
technique_map = {
|
||||||
|
r.id: r
|
||||||
|
for r in s.scalars(
|
||||||
|
select(MitreTechnique).where(MitreTechnique.id.in_(technique_ids))
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
if sub_ids:
|
||||||
|
sub_map = {
|
||||||
|
r.id: r
|
||||||
|
for r in s.scalars(
|
||||||
|
select(MitreSubtechnique).where(MitreSubtechnique.id.in_(sub_ids))
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
return tactic_map, technique_map, sub_map
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_tag(
|
||||||
|
tag: TestTemplateMitreTag,
|
||||||
|
tactic_map: dict[uuid.UUID, MitreTactic],
|
||||||
|
technique_map: dict[uuid.UUID, MitreTechnique],
|
||||||
|
sub_map: dict[uuid.UUID, MitreSubtechnique],
|
||||||
|
) -> MissionTestMitreTag | None:
|
||||||
|
"""Convert a template's polymorphic MITRE tag into a frozen mission tag.
|
||||||
|
|
||||||
|
Returns None if the referenced MITRE row vanished between read and snapshot
|
||||||
|
(paranoid: should not happen inside one tx).
|
||||||
|
"""
|
||||||
|
if tag.mitre_kind == "tactic" and tag.tactic_id in tactic_map:
|
||||||
|
r = tactic_map[tag.tactic_id]
|
||||||
|
return MissionTestMitreTag(
|
||||||
|
mitre_kind="tactic",
|
||||||
|
mitre_external_id=r.external_id,
|
||||||
|
mitre_name=r.name,
|
||||||
|
mitre_url=r.url,
|
||||||
|
)
|
||||||
|
if tag.mitre_kind == "technique" and tag.technique_id in technique_map:
|
||||||
|
r = technique_map[tag.technique_id]
|
||||||
|
return MissionTestMitreTag(
|
||||||
|
mitre_kind="technique",
|
||||||
|
mitre_external_id=r.external_id,
|
||||||
|
mitre_name=r.name,
|
||||||
|
mitre_url=r.url,
|
||||||
|
)
|
||||||
|
if tag.mitre_kind == "subtechnique" and tag.subtechnique_id in sub_map:
|
||||||
|
r = sub_map[tag.subtechnique_id]
|
||||||
|
return MissionTestMitreTag(
|
||||||
|
mitre_kind="subtechnique",
|
||||||
|
mitre_external_id=r.external_id,
|
||||||
|
mitre_name=r.name,
|
||||||
|
mitre_url=r.url,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Snapshot
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _load_scenario_templates_for_snapshot(
|
||||||
|
s: Session, scenario_ids: list[uuid.UUID]
|
||||||
|
) -> dict[uuid.UUID, ScenarioTemplate]:
|
||||||
|
"""Load scenarios in eager-load mode and reject unknowns/soft-deleted upfront."""
|
||||||
|
if not scenario_ids:
|
||||||
|
return {}
|
||||||
|
rows = s.scalars(
|
||||||
|
select(ScenarioTemplate)
|
||||||
|
.options(selectinload(ScenarioTemplate.tests))
|
||||||
|
.where(ScenarioTemplate.id.in_(scenario_ids))
|
||||||
|
).all()
|
||||||
|
by_id = {sc.id: sc for sc in rows}
|
||||||
|
missing = set(scenario_ids) - by_id.keys()
|
||||||
|
if missing:
|
||||||
|
raise UnknownScenarioTemplate(
|
||||||
|
f"unknown scenario_template ids: {sorted(str(m) for m in missing)}"
|
||||||
|
)
|
||||||
|
deleted = [sc.id for sc in rows if sc.deleted_at is not None]
|
||||||
|
if deleted:
|
||||||
|
raise UnknownScenarioTemplate(
|
||||||
|
f"cannot snapshot soft-deleted scenario_template ids: "
|
||||||
|
f"{sorted(str(d) for d in deleted)}"
|
||||||
|
)
|
||||||
|
return by_id
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_scenarios(
|
||||||
|
s: Session,
|
||||||
|
mission_id: uuid.UUID,
|
||||||
|
scenario_ids: list[uuid.UUID],
|
||||||
|
start_position: int,
|
||||||
|
) -> None:
|
||||||
|
"""Append `scenario_ids` as new MissionScenario+MissionTest rows under the mission.
|
||||||
|
|
||||||
|
Position counter continues from `start_position`. Each scenario_template's
|
||||||
|
`tests` order is preserved 1:1. MITRE tags on the source templates are
|
||||||
|
copied as denormalised `MissionTestMitreTag` rows (frozen external_id/name/url).
|
||||||
|
"""
|
||||||
|
if not scenario_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
_lock_scenario_ids_for_snapshot(s, scenario_ids)
|
||||||
|
sc_by_id = _load_scenario_templates_for_snapshot(s, scenario_ids)
|
||||||
|
|
||||||
|
# Collect the underlying test_template ids in stable order.
|
||||||
|
ordered_test_ids: list[uuid.UUID] = []
|
||||||
|
for sid in scenario_ids:
|
||||||
|
sc = sc_by_id[sid]
|
||||||
|
for link in sc.tests:
|
||||||
|
ordered_test_ids.append(link.test_template_id)
|
||||||
|
|
||||||
|
test_template_map: dict[uuid.UUID, TestTemplate] = {}
|
||||||
|
if ordered_test_ids:
|
||||||
|
test_template_rows = s.scalars(
|
||||||
|
select(TestTemplate)
|
||||||
|
.options(selectinload(TestTemplate.mitre_tags))
|
||||||
|
.where(TestTemplate.id.in_(set(ordered_test_ids)))
|
||||||
|
).all()
|
||||||
|
test_template_map = {t.id: t for t in test_template_rows}
|
||||||
|
# A test_template may be soft-deleted between the scenario authoring and
|
||||||
|
# the mission creation. We do not refuse the snapshot (the user expects
|
||||||
|
# the scenario's planned tests to appear); we just freeze the last
|
||||||
|
# known content, which is what a snapshot is for.
|
||||||
|
missing_t = set(ordered_test_ids) - test_template_map.keys()
|
||||||
|
if missing_t:
|
||||||
|
raise UnknownScenarioTemplate(
|
||||||
|
f"scenario references missing test_template ids: "
|
||||||
|
f"{sorted(str(m) for m in missing_t)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pre-load all MITRE rows referenced by any tag across all involved templates.
|
||||||
|
all_tag_rows: list[TestTemplateMitreTag] = []
|
||||||
|
for t in test_template_map.values():
|
||||||
|
all_tag_rows.extend(t.mitre_tags)
|
||||||
|
tactic_map, technique_map, sub_map = _resolve_mitre_lookup(
|
||||||
|
s, *_collect_mitre_ids(all_tag_rows)
|
||||||
|
)
|
||||||
|
|
||||||
|
pos = start_position
|
||||||
|
for sid in scenario_ids:
|
||||||
|
sc = sc_by_id[sid]
|
||||||
|
ms = MissionScenario(
|
||||||
|
mission_id=mission_id,
|
||||||
|
source_scenario_template_id=sc.id,
|
||||||
|
snapshot_name=sc.name,
|
||||||
|
snapshot_description=sc.description,
|
||||||
|
position=pos,
|
||||||
|
)
|
||||||
|
s.add(ms)
|
||||||
|
s.flush() # populate ms.id for the child tests
|
||||||
|
|
||||||
|
test_pos = 0
|
||||||
|
for link in sc.tests:
|
||||||
|
tt = test_template_map[link.test_template_id]
|
||||||
|
mt = MissionTest(
|
||||||
|
scenario_id=ms.id,
|
||||||
|
source_test_template_id=tt.id,
|
||||||
|
position=test_pos,
|
||||||
|
snapshot_name=tt.name,
|
||||||
|
snapshot_description=tt.description,
|
||||||
|
snapshot_objective=tt.objective,
|
||||||
|
snapshot_procedure_md=tt.procedure_md,
|
||||||
|
snapshot_prerequisites_md=tt.prerequisites_md,
|
||||||
|
snapshot_expected_red_md=tt.expected_result_red_md,
|
||||||
|
snapshot_expected_blue_md=tt.expected_detection_blue_md,
|
||||||
|
snapshot_opsec_level=tt.opsec_level,
|
||||||
|
snapshot_tags=list(tt.tags or []),
|
||||||
|
snapshot_expected_iocs=list(tt.expected_iocs or []),
|
||||||
|
state="pending",
|
||||||
|
)
|
||||||
|
s.add(mt)
|
||||||
|
s.flush()
|
||||||
|
for src_tag in tt.mitre_tags:
|
||||||
|
snap = _snapshot_tag(src_tag, tactic_map, technique_map, sub_map)
|
||||||
|
if snap is not None:
|
||||||
|
snap.mission_test_id = mt.id
|
||||||
|
s.add(snap)
|
||||||
|
test_pos += 1
|
||||||
|
pos += 1
|
||||||
|
s.flush()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# View assembly
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _member_views(s: Session, mission: Mission) -> list[MissionMemberView]:
|
||||||
|
if not mission.members:
|
||||||
|
return []
|
||||||
|
users = _load_users_map(s, [m.user_id for m in mission.members])
|
||||||
|
out: list[MissionMemberView] = []
|
||||||
|
for m in mission.members:
|
||||||
|
u = users.get(m.user_id)
|
||||||
|
out.append(
|
||||||
|
MissionMemberView(
|
||||||
|
user_id=m.user_id,
|
||||||
|
user_email=u.email if u else "<deleted>",
|
||||||
|
user_display_name=(u.display_name if u else None),
|
||||||
|
role_hint=m.role_hint,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
out.sort(key=lambda mv: (mv.role_hint, mv.user_email))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _scenario_views(scenarios: list[MissionScenario]) -> list[MissionScenarioView]:
|
||||||
|
"""Assemble scenario views. `mission_scenarios` and `mission_tests` both
|
||||||
|
carry `SoftDeleteMixin`; M6 doesn't surface soft-deletion of those rows in
|
||||||
|
any endpoint, but the filter is applied here so future deletions (M7+)
|
||||||
|
don't drift the rendered list silently."""
|
||||||
|
views: list[MissionScenarioView] = []
|
||||||
|
live = [sc for sc in scenarios if sc.deleted_at is None]
|
||||||
|
for sc in sorted(live, key=lambda s_: s_.position):
|
||||||
|
test_views: list[MissionTestView] = []
|
||||||
|
live_tests = [t for t in sc.tests if t.deleted_at is None]
|
||||||
|
for t in sorted(live_tests, key=lambda t_: t_.position):
|
||||||
|
tag_views = [
|
||||||
|
MissionMitreTagView(
|
||||||
|
kind=tag.mitre_kind,
|
||||||
|
external_id=tag.mitre_external_id,
|
||||||
|
name=tag.mitre_name,
|
||||||
|
url=tag.mitre_url,
|
||||||
|
)
|
||||||
|
for tag in sorted(
|
||||||
|
t.mitre_tags, key=lambda tg: (tg.mitre_kind, tg.mitre_external_id)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
test_views.append(
|
||||||
|
MissionTestView(
|
||||||
|
id=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=list(t.snapshot_tags or []),
|
||||||
|
snapshot_expected_iocs=list(t.snapshot_expected_iocs or []),
|
||||||
|
state=t.state,
|
||||||
|
executed_at=t.executed_at,
|
||||||
|
executed_at_overridden=t.executed_at_overridden,
|
||||||
|
mitre_tags=tag_views,
|
||||||
|
source_test_template_id=t.source_test_template_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
views.append(
|
||||||
|
MissionScenarioView(
|
||||||
|
id=sc.id,
|
||||||
|
position=sc.position,
|
||||||
|
snapshot_name=sc.snapshot_name,
|
||||||
|
snapshot_description=sc.snapshot_description,
|
||||||
|
tests=test_views,
|
||||||
|
source_scenario_template_id=sc.source_scenario_template_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return views
|
||||||
|
|
||||||
|
|
||||||
|
def _to_detail_view(s: Session, m: Mission) -> MissionView:
|
||||||
|
scenarios = [sc for sc in m.scenarios if sc.deleted_at is None]
|
||||||
|
members = _member_views(s, m)
|
||||||
|
scenario_views = _scenario_views(scenarios)
|
||||||
|
tests_count = sum(len(sc.tests) for sc in scenario_views)
|
||||||
|
return MissionView(
|
||||||
|
id=m.id,
|
||||||
|
name=m.name,
|
||||||
|
client_target=m.client_target,
|
||||||
|
date_start=m.date_start,
|
||||||
|
date_end=m.date_end,
|
||||||
|
status=m.status,
|
||||||
|
description_md=m.description_md,
|
||||||
|
visibility_mode=m.visibility_mode,
|
||||||
|
scenarios_count=len(scenario_views),
|
||||||
|
tests_count=tests_count,
|
||||||
|
members_count=len(members),
|
||||||
|
deleted_at=m.deleted_at,
|
||||||
|
created_at=m.created_at,
|
||||||
|
updated_at=m.updated_at,
|
||||||
|
scenarios=scenario_views,
|
||||||
|
members=members,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_list_item(m: Mission) -> MissionListItemView:
|
||||||
|
# Cheap counts via the loaded relationships (selectinloaded by the caller).
|
||||||
|
# We filter soft-deleted children consistently with `_scenario_views` so
|
||||||
|
# the list and the detail page agree.
|
||||||
|
live_scenarios = [sc for sc in m.scenarios if sc.deleted_at is None]
|
||||||
|
tests_count = sum(
|
||||||
|
len([t for t in sc.tests if t.deleted_at is None]) for sc in live_scenarios
|
||||||
|
)
|
||||||
|
return MissionListItemView(
|
||||||
|
id=m.id,
|
||||||
|
name=m.name,
|
||||||
|
client_target=m.client_target,
|
||||||
|
date_start=m.date_start,
|
||||||
|
date_end=m.date_end,
|
||||||
|
status=m.status,
|
||||||
|
description_md=m.description_md,
|
||||||
|
visibility_mode=m.visibility_mode,
|
||||||
|
scenarios_count=len(live_scenarios),
|
||||||
|
tests_count=tests_count,
|
||||||
|
members_count=len(m.members),
|
||||||
|
deleted_at=m.deleted_at,
|
||||||
|
created_at=m.created_at,
|
||||||
|
updated_at=m.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Public API — list / get
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def list_missions(
|
||||||
|
*,
|
||||||
|
viewer_id: uuid.UUID,
|
||||||
|
viewer_is_admin: bool,
|
||||||
|
q: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
client: str | None = None,
|
||||||
|
include_deleted: bool = False,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> tuple[list[MissionListItemView], int]:
|
||||||
|
with session_scope() as s:
|
||||||
|
stmt = (
|
||||||
|
select(Mission)
|
||||||
|
.options(
|
||||||
|
selectinload(Mission.scenarios).selectinload(MissionScenario.tests),
|
||||||
|
selectinload(Mission.members),
|
||||||
|
)
|
||||||
|
.order_by(Mission.created_at.desc(), Mission.id.desc())
|
||||||
|
)
|
||||||
|
count_stmt = select(func.count()).select_from(Mission)
|
||||||
|
|
||||||
|
if not include_deleted:
|
||||||
|
stmt = stmt.where(Mission.deleted_at.is_(None))
|
||||||
|
count_stmt = count_stmt.where(Mission.deleted_at.is_(None))
|
||||||
|
if not viewer_is_admin:
|
||||||
|
stmt = stmt.where(_membership_filter(viewer_id))
|
||||||
|
count_stmt = count_stmt.where(_membership_filter(viewer_id))
|
||||||
|
if status:
|
||||||
|
_validate_status(status)
|
||||||
|
stmt = stmt.where(Mission.status == status)
|
||||||
|
count_stmt = count_stmt.where(Mission.status == status)
|
||||||
|
if client:
|
||||||
|
like = f"%{_escape_like(client.lower())}%"
|
||||||
|
cond = func.lower(Mission.client_target).like(like, escape="\\")
|
||||||
|
stmt = stmt.where(cond)
|
||||||
|
count_stmt = count_stmt.where(cond)
|
||||||
|
if q:
|
||||||
|
like = f"%{_escape_like(q.lower())}%"
|
||||||
|
cond = or_(
|
||||||
|
func.lower(Mission.name).like(like, escape="\\"),
|
||||||
|
func.lower(Mission.description_md).like(like, escape="\\"),
|
||||||
|
)
|
||||||
|
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_list_item(m) for m in rows], int(total)
|
||||||
|
|
||||||
|
|
||||||
|
def get_mission(
|
||||||
|
mission_id: uuid.UUID,
|
||||||
|
*,
|
||||||
|
viewer_id: uuid.UUID,
|
||||||
|
viewer_is_admin: bool,
|
||||||
|
include_deleted: bool = False,
|
||||||
|
) -> MissionView:
|
||||||
|
with session_scope() as s:
|
||||||
|
m = s.get(Mission, mission_id)
|
||||||
|
if m is None:
|
||||||
|
raise MissionNotFound()
|
||||||
|
if m.deleted_at is not None and not include_deleted:
|
||||||
|
raise MissionNotFound()
|
||||||
|
if not viewer_is_admin and not _is_member(s, mission_id, viewer_id):
|
||||||
|
raise MissionNotFound()
|
||||||
|
return _to_detail_view(s, m)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Public API — write
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_members(s: Session, members: list[MemberAssignment]) -> None:
|
||||||
|
"""Reject duplicates, bad role hints, unknown/soft-deleted users."""
|
||||||
|
seen: set[uuid.UUID] = set()
|
||||||
|
for m in members:
|
||||||
|
if m.user_id in seen:
|
||||||
|
raise InvalidMemberPayload(f"duplicate user_id: {m.user_id}")
|
||||||
|
seen.add(m.user_id)
|
||||||
|
_validate_role_hint(m.role_hint)
|
||||||
|
if not members:
|
||||||
|
return
|
||||||
|
user_map = _load_users_map(s, seen)
|
||||||
|
missing = seen - user_map.keys()
|
||||||
|
if missing:
|
||||||
|
raise UnknownUser(f"unknown or deleted user_ids: {sorted(str(u) for u in missing)}")
|
||||||
|
|
||||||
|
|
||||||
|
def create_mission(
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
creator_id: uuid.UUID,
|
||||||
|
creator_is_admin: bool,
|
||||||
|
client_target: str | None = None,
|
||||||
|
date_start: date | None = None,
|
||||||
|
date_end: date | None = None,
|
||||||
|
description_md: str | None = None,
|
||||||
|
scenario_template_ids: list[uuid.UUID] | None = None,
|
||||||
|
members: list[MemberAssignment] | None = None,
|
||||||
|
) -> MissionView:
|
||||||
|
"""Create a mission and snapshot the requested scenarios + their tests.
|
||||||
|
|
||||||
|
Side effect: if `creator_is_admin` is False and the creator is not in
|
||||||
|
`members`, they are added with `role_hint='red'`. This prevents the
|
||||||
|
non-admin creator from immediately losing visibility on the mission they
|
||||||
|
just created (membership-based visibility, see spec §4).
|
||||||
|
"""
|
||||||
|
name_norm = _normalize_name(name)
|
||||||
|
_validate_dates(date_start, date_end)
|
||||||
|
scenarios = list(scenario_template_ids or [])
|
||||||
|
members_list = list(members or [])
|
||||||
|
|
||||||
|
with session_scope() as s:
|
||||||
|
_validate_members(s, members_list)
|
||||||
|
|
||||||
|
# Auto-add the non-admin creator as a member so they retain visibility.
|
||||||
|
if not creator_is_admin and not any(m.user_id == creator_id for m in members_list):
|
||||||
|
members_list = [
|
||||||
|
MemberAssignment(user_id=creator_id, role_hint="red"),
|
||||||
|
*members_list,
|
||||||
|
]
|
||||||
|
# Defensive re-validation in case the creator id was bogus.
|
||||||
|
_validate_members(s, members_list)
|
||||||
|
|
||||||
|
mission = Mission(
|
||||||
|
name=name_norm,
|
||||||
|
client_target=_opt_str(client_target),
|
||||||
|
date_start=date_start,
|
||||||
|
date_end=date_end,
|
||||||
|
description_md=_opt_str(description_md),
|
||||||
|
status="draft",
|
||||||
|
visibility_mode="whitebox",
|
||||||
|
)
|
||||||
|
s.add(mission)
|
||||||
|
s.flush()
|
||||||
|
|
||||||
|
for m in members_list:
|
||||||
|
s.add(
|
||||||
|
MissionMember(
|
||||||
|
mission_id=mission.id,
|
||||||
|
user_id=m.user_id,
|
||||||
|
role_hint=m.role_hint,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if scenarios:
|
||||||
|
_snapshot_scenarios(s, mission.id, scenarios, start_position=0)
|
||||||
|
|
||||||
|
s.flush()
|
||||||
|
s.refresh(mission)
|
||||||
|
return _to_detail_view(s, mission)
|
||||||
|
|
||||||
|
|
||||||
|
def update_mission_metadata(
|
||||||
|
mission_id: uuid.UUID,
|
||||||
|
*,
|
||||||
|
viewer_id: uuid.UUID,
|
||||||
|
viewer_is_admin: bool,
|
||||||
|
name: str | None = None,
|
||||||
|
client_target: Any = _UNSET,
|
||||||
|
date_start: Any = _UNSET,
|
||||||
|
date_end: Any = _UNSET,
|
||||||
|
description_md: Any = _UNSET,
|
||||||
|
) -> MissionView:
|
||||||
|
with session_scope() as s:
|
||||||
|
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()
|
||||||
|
if name is not None:
|
||||||
|
m.name = _normalize_name(name)
|
||||||
|
if client_target is not _UNSET:
|
||||||
|
m.client_target = _opt_str(client_target)
|
||||||
|
if date_start is not _UNSET:
|
||||||
|
m.date_start = date_start
|
||||||
|
if date_end is not _UNSET:
|
||||||
|
m.date_end = date_end
|
||||||
|
# Validate the combined date pair regardless of which side was passed.
|
||||||
|
_validate_dates(m.date_start, m.date_end)
|
||||||
|
if description_md is not _UNSET:
|
||||||
|
m.description_md = _opt_str(description_md)
|
||||||
|
s.flush()
|
||||||
|
s.refresh(m)
|
||||||
|
return _to_detail_view(s, m)
|
||||||
|
|
||||||
|
|
||||||
|
def add_scenarios_to_mission(
|
||||||
|
mission_id: uuid.UUID,
|
||||||
|
scenario_template_ids: list[uuid.UUID],
|
||||||
|
*,
|
||||||
|
viewer_id: uuid.UUID,
|
||||||
|
viewer_is_admin: bool,
|
||||||
|
) -> MissionView:
|
||||||
|
"""Append more snapshot scenarios to an existing mission.
|
||||||
|
|
||||||
|
They land at `current_max_position + 1` and onwards. Empty list is a no-op
|
||||||
|
and just returns the current view.
|
||||||
|
"""
|
||||||
|
with session_scope() as s:
|
||||||
|
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()
|
||||||
|
if scenario_template_ids:
|
||||||
|
max_pos = s.scalar(
|
||||||
|
select(func.coalesce(func.max(MissionScenario.position), -1)).where(
|
||||||
|
MissionScenario.mission_id == mission_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_snapshot_scenarios(
|
||||||
|
s,
|
||||||
|
mission_id,
|
||||||
|
list(scenario_template_ids),
|
||||||
|
start_position=int(max_pos) + 1,
|
||||||
|
)
|
||||||
|
s.flush()
|
||||||
|
s.refresh(m)
|
||||||
|
return _to_detail_view(s, m)
|
||||||
|
|
||||||
|
|
||||||
|
def set_mission_members(
|
||||||
|
mission_id: uuid.UUID,
|
||||||
|
members: list[MemberAssignment],
|
||||||
|
*,
|
||||||
|
viewer_id: uuid.UUID,
|
||||||
|
viewer_is_admin: bool,
|
||||||
|
) -> MissionView:
|
||||||
|
"""Replace the entire member set. Wipe + insert, like the scenario reorder."""
|
||||||
|
with session_scope() as s:
|
||||||
|
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()
|
||||||
|
_validate_members(s, members)
|
||||||
|
for link in list(m.members):
|
||||||
|
s.delete(link)
|
||||||
|
s.flush()
|
||||||
|
for assignment in members:
|
||||||
|
s.add(
|
||||||
|
MissionMember(
|
||||||
|
mission_id=m.id,
|
||||||
|
user_id=assignment.user_id,
|
||||||
|
role_hint=assignment.role_hint,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
s.flush()
|
||||||
|
s.refresh(m)
|
||||||
|
return _to_detail_view(s, m)
|
||||||
|
|
||||||
|
|
||||||
|
def transition_mission_status(
|
||||||
|
mission_id: uuid.UUID,
|
||||||
|
target_status: str,
|
||||||
|
*,
|
||||||
|
viewer_id: uuid.UUID,
|
||||||
|
viewer_is_admin: bool,
|
||||||
|
) -> MissionView:
|
||||||
|
"""Move the mission's status one step along the lifecycle graph."""
|
||||||
|
_validate_status(target_status)
|
||||||
|
with session_scope() as s:
|
||||||
|
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()
|
||||||
|
if target_status == m.status:
|
||||||
|
# No-op transitions are valid: a client retry should not 409.
|
||||||
|
s.refresh(m)
|
||||||
|
return _to_detail_view(s, m)
|
||||||
|
allowed = _VALID_TRANSITIONS.get(m.status, frozenset())
|
||||||
|
if target_status not in allowed:
|
||||||
|
raise InvalidTransition(
|
||||||
|
f"cannot transition from {m.status!r} to {target_status!r}"
|
||||||
|
)
|
||||||
|
m.status = target_status
|
||||||
|
s.flush()
|
||||||
|
s.refresh(m)
|
||||||
|
return _to_detail_view(s, m)
|
||||||
|
|
||||||
|
|
||||||
|
def soft_delete_mission(
|
||||||
|
mission_id: uuid.UUID,
|
||||||
|
*,
|
||||||
|
viewer_id: uuid.UUID,
|
||||||
|
viewer_is_admin: bool,
|
||||||
|
) -> None:
|
||||||
|
with session_scope() as s:
|
||||||
|
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()
|
||||||
|
m.deleted_at = datetime.now(tz=timezone.utc)
|
||||||
@@ -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,9 +477,10 @@ 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)
|
||||||
|
|
||||||
|
|||||||
259
backend/app/services/scenario_templates.py
Normal file
259
backend/app/services/scenario_templates.py
Normal 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)
|
||||||
495
backend/app/services/test_templates.py
Normal file
495
backend/app/services/test_templates.py
Normal 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)
|
||||||
781
backend/tests/test_missions.py
Normal file
781
backend/tests/test_missions.py
Normal 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
|
||||||
@@ -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(
|
||||||
@@ -358,3 +362,92 @@ def test_search_filter_on_name(app, admin_credentials, fixture_bundle_path):
|
|||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
ext_ids = [t["external_id"] for t in r.get_json()["items"]]
|
ext_ids = [t["external_id"] for t in r.get_json()["items"]]
|
||||||
assert ext_ids == ["T1078"]
|
assert ext_ids == ["T1078"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_matrix_endpoint_returns_nested_grid(app, admin_credentials, fixture_bundle_path):
|
||||||
|
"""GET /mitre/matrix returns the flat tactic→technique→subtechnique grid."""
|
||||||
|
mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None)
|
||||||
|
with app.test_client() as c:
|
||||||
|
access = _login(c, admin_credentials["email"], admin_credentials["password"])
|
||||||
|
r = c.get("/api/v1/mitre/matrix", headers={"Authorization": f"Bearer {access}"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.get_json()
|
||||||
|
tactics = body["tactics"]
|
||||||
|
assert {t["external_id"] for t in tactics} == {"TA0001", "TA0002"}
|
||||||
|
|
||||||
|
# TA0001 has T1059 (multi-tactic) + T1078; T1059 carries its sub.
|
||||||
|
ta0001 = next(t for t in tactics if t["external_id"] == "TA0001")
|
||||||
|
techs = {t["external_id"]: t for t in ta0001["techniques"]}
|
||||||
|
assert set(techs.keys()) == {"T1059", "T1078"}
|
||||||
|
assert techs["T1059"]["subtechniques"][0]["external_id"] == "T1059.001"
|
||||||
|
assert techs["T1078"]["subtechniques"] == []
|
||||||
|
|
||||||
|
# TA0002 only carries T1059 (no T1078).
|
||||||
|
ta0002 = next(t for t in tactics if t["external_id"] == "TA0002")
|
||||||
|
assert [t["external_id"] for t in ta0002["techniques"]] == ["T1059"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_matrix_endpoint_requires_auth(app, fixture_bundle_path):
|
||||||
|
mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None)
|
||||||
|
with app.test_client() as c:
|
||||||
|
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
|
||||||
|
|||||||
567
backend/tests/test_templates.py
Normal file
567
backend/tests/test_templates.py
Normal 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
|
||||||
@@ -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 }) => {
|
||||||
@@ -109,7 +115,7 @@ test.describe('M4 — MITRE ATT&CK reference', () => {
|
|||||||
expect(body.default_version).toBeTruthy();
|
expect(body.default_version).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('SPA MITRE page renders + picker walks tactic → technique → subtechnique', async ({ page }) => {
|
test('SPA MITRE matrix renders + click cells to select technique + sub-technique', async ({ page }) => {
|
||||||
await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD);
|
await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||||
await page.goto('/mitre');
|
await page.goto('/mitre');
|
||||||
|
|
||||||
@@ -118,25 +124,35 @@ test.describe('M4 — MITRE ATT&CK reference', () => {
|
|||||||
|
|
||||||
const picker = page.getByTestId('mitre-tag-picker');
|
const picker = page.getByTestId('mitre-tag-picker');
|
||||||
await expect(picker).toBeVisible();
|
await expect(picker).toBeVisible();
|
||||||
|
// The matrix has a column per tactic.
|
||||||
|
await expect(picker.getByTestId('mitre-column-TA0006')).toBeVisible();
|
||||||
|
|
||||||
// 1. Click on TA0006 (Credential Access)
|
// 1. Click the T1003 cell (Credential Dumping under TA0006) → technique selected.
|
||||||
await picker.getByTestId('mitre-tactic-TA0006').click();
|
const t1003 = picker.getByTestId('mitre-technique-T1003').first();
|
||||||
// 2. Techniques column populates; click T1003
|
await t1003.scrollIntoViewIfNeeded();
|
||||||
await expect(picker.getByTestId('mitre-technique-T1003')).toBeVisible();
|
await t1003.click();
|
||||||
await picker.getByTestId('mitre-technique-T1003').click();
|
await expect(page.getByTestId('mitre-selected')).toContainText('T1003');
|
||||||
// 3. Sub-techniques column populates with T1003.001 onward
|
await expect(t1003).toHaveAttribute('aria-pressed', 'true');
|
||||||
await expect(picker.getByTestId('mitre-subtechnique-T1003.001')).toBeVisible();
|
|
||||||
// 4. Select the sub-technique → chip appears in the selection bar
|
// 2. Expand T1003's sub-techniques inline via the +N chevron.
|
||||||
await picker.getByTestId('mitre-subtechnique-T1003.001').click();
|
await picker.getByTestId('mitre-expand-T1003').first().click();
|
||||||
|
const sub = picker.getByTestId('mitre-subtechnique-T1003.001').first();
|
||||||
|
await expect(sub).toBeVisible();
|
||||||
|
|
||||||
|
// 3. Click the sub-technique → chip + JSON preview both update.
|
||||||
|
await sub.click();
|
||||||
await expect(page.getByTestId('mitre-selected')).toContainText('T1003.001');
|
await expect(page.getByTestId('mitre-selected')).toContainText('T1003.001');
|
||||||
// 5. Preview payload card shows the JSON encoded selection
|
|
||||||
await expect(page.getByTestId('mitre-selected-json')).toContainText('"T1003.001"');
|
await expect(page.getByTestId('mitre-selected-json')).toContainText('"T1003.001"');
|
||||||
|
|
||||||
|
// 4. Filter the matrix on "valid" → TA0006/T1003 are hidden but TA0001/T1078 visible.
|
||||||
|
await picker.getByLabel(/^filter$/i).fill('valid');
|
||||||
|
await expect(picker.getByTestId('mitre-technique-T1078').first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
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 },
|
||||||
|
|||||||
253
e2e/tests/m5-templates.spec.ts
Normal file
253
e2e/tests/m5-templates.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
405
e2e/tests/m6-missions.spec.ts
Normal file
405
e2e/tests/m6-missions.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -6,10 +6,15 @@ 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 { 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 +63,30 @@ 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
|
<Route
|
||||||
path="/admin/users"
|
path="/admin/users"
|
||||||
element={
|
element={
|
||||||
@@ -82,6 +111,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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
45
frontend/src/components/MarkdownField.tsx
Normal file
45
frontend/src/components/MarkdownField.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,98 +5,86 @@ import { Alert } from '@/components/ui/Alert';
|
|||||||
import { Tag } from '@/components/ui/Tag';
|
import { Tag } from '@/components/ui/Tag';
|
||||||
import { TextField } from '@/components/ui/TextField';
|
import { TextField } from '@/components/ui/TextField';
|
||||||
import { apiGet } from '@/lib/api';
|
import { apiGet } from '@/lib/api';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
import {
|
import {
|
||||||
mitreKeys,
|
mitreKeys,
|
||||||
type MitreSubtechnique,
|
type MatrixTechnique,
|
||||||
type MitreTactic,
|
type MitreMatrix,
|
||||||
type MitreTag,
|
type MitreTag,
|
||||||
type MitreTechnique,
|
|
||||||
type Paginated,
|
|
||||||
} from '@/lib/mitre';
|
} from '@/lib/mitre';
|
||||||
import { cn } from '@/lib/cn';
|
|
||||||
|
|
||||||
interface MitreTagPickerProps {
|
interface MitreTagPickerProps {
|
||||||
/** Already-selected tags. The parent owns the state. */
|
/** Already-selected tags. The parent owns the state. */
|
||||||
value: MitreTag[];
|
value: MitreTag[];
|
||||||
/** Called whenever the selection changes (replace semantics). */
|
/** Replace-style change handler — called with the new full selection. */
|
||||||
onChange: (next: MitreTag[]) => void;
|
onChange: (next: MitreTag[]) => void;
|
||||||
/** Hide the search box(es). Useful for compact embed in a sidebar. */
|
|
||||||
compact?: boolean;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useTactics(q: string) {
|
function useMatrix() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: mitreKeys.tactics(q),
|
queryKey: mitreKeys.matrix,
|
||||||
queryFn: () =>
|
queryFn: () => apiGet<MitreMatrix>('/mitre/matrix'),
|
||||||
apiGet<Paginated<MitreTactic>>(
|
staleTime: 5 * 60_000,
|
||||||
`/mitre/tactics${q ? `?q=${encodeURIComponent(q)}` : ''}`,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function useTechniques(tactic: string | null, q: string) {
|
|
||||||
return useQuery({
|
|
||||||
enabled: tactic !== null,
|
|
||||||
queryKey: mitreKeys.techniques(tactic ?? '', q),
|
|
||||||
queryFn: () => {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (tactic) params.set('tactic', tactic);
|
|
||||||
if (q) params.set('q', q);
|
|
||||||
return apiGet<Paginated<MitreTechnique>>(
|
|
||||||
`/mitre/techniques${params.toString() ? `?${params}` : ''}`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function useSubtechniques(technique: string | null, q: string) {
|
|
||||||
return useQuery({
|
|
||||||
enabled: technique !== null,
|
|
||||||
queryKey: mitreKeys.subtechniques(technique ?? '', q),
|
|
||||||
queryFn: () => {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (technique) params.set('technique', technique);
|
|
||||||
if (q) params.set('q', q);
|
|
||||||
return apiGet<Paginated<MitreSubtechnique>>(
|
|
||||||
`/mitre/subtechniques${params.toString() ? `?${params}` : ''}`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Three-column picker — Tactic > Technique > Sub-technique — with multi-select.
|
* Flat ATT&CK matrix in the attack.mitre.org/# style — 15 columns share the
|
||||||
* Selected tags accumulate in the chips at the top.
|
* available width (no horizontal scroll on standard desktop). Cells show the
|
||||||
|
* technique NAME only; the external_id surfaces in the chips at the top and in
|
||||||
|
* the hover tooltip. A `▸/▾` chevron beside a technique expands its
|
||||||
|
* sub-techniques inline within the column.
|
||||||
*/
|
*/
|
||||||
export function MitreTagPicker({ value, onChange, compact, className }: MitreTagPickerProps) {
|
export function MitreTagPicker({ value, onChange, className }: MitreTagPickerProps) {
|
||||||
const [activeTactic, setActiveTactic] = useState<string | null>(null);
|
const matrix = useMatrix();
|
||||||
const [activeTechnique, setActiveTechnique] = useState<string | null>(null);
|
const [filter, setFilter] = useState('');
|
||||||
const [qTactic, setQTactic] = useState('');
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
const [qTechnique, setQTechnique] = useState('');
|
|
||||||
const [qSub, setQSub] = useState('');
|
|
||||||
|
|
||||||
const tactics = useTactics(qTactic);
|
const filterNorm = filter.trim().toLowerCase();
|
||||||
const techniques = useTechniques(activeTactic, qTechnique);
|
|
||||||
const subtechniques = useSubtechniques(activeTechnique, qSub);
|
|
||||||
|
|
||||||
const selectedKey = useMemo(() => new Set(value.map((t) => `${t.kind}:${t.external_id}`)), [value]);
|
// O(1) selection lookup.
|
||||||
|
const selectedKeys = useMemo(
|
||||||
|
() => new Set(value.map((t) => `${t.kind}:${t.external_id}`)),
|
||||||
|
[value],
|
||||||
|
);
|
||||||
|
|
||||||
|
function isSelected(kind: MitreTag['kind'], external_id: string): boolean {
|
||||||
|
return selectedKeys.has(`${kind}:${external_id}`);
|
||||||
|
}
|
||||||
|
|
||||||
function toggle(tag: MitreTag) {
|
function toggle(tag: MitreTag) {
|
||||||
const key = `${tag.kind}:${tag.external_id}`;
|
const key = `${tag.kind}:${tag.external_id}`;
|
||||||
if (selectedKey.has(key)) {
|
if (selectedKeys.has(key)) {
|
||||||
onChange(value.filter((t) => `${t.kind}:${t.external_id}` !== key));
|
onChange(value.filter((t) => `${t.kind}:${t.external_id}` !== key));
|
||||||
} else {
|
} else {
|
||||||
onChange([...value, tag]);
|
onChange([...value, tag]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function colorForKind(kind: 'tactic' | 'technique' | 'subtechnique') {
|
function toggleExpand(techExtId: string) {
|
||||||
return kind === 'tactic' ? 'cyan' : kind === 'technique' ? 'orange' : 'purple';
|
setExpanded((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(techExtId)) next.delete(techExtId);
|
||||||
|
else next.add(techExtId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches(t: MatrixTechnique): boolean {
|
||||||
|
if (!filterNorm) return true;
|
||||||
|
if (t.external_id.toLowerCase().includes(filterNorm)) return true;
|
||||||
|
if (t.name.toLowerCase().includes(filterNorm)) return true;
|
||||||
|
return t.subtechniques.some(
|
||||||
|
(sb) =>
|
||||||
|
sb.external_id.toLowerCase().includes(filterNorm) ||
|
||||||
|
sb.name.toLowerCase().includes(filterNorm),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 */}
|
||||||
{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">
|
||||||
{value.map((t) => (
|
{value.map((t) => (
|
||||||
@@ -107,7 +95,7 @@ export function MitreTagPicker({ value, onChange, compact, className }: MitreTag
|
|||||||
className="inline-flex items-center"
|
className="inline-flex items-center"
|
||||||
aria-label={`Remove ${t.external_id}`}
|
aria-label={`Remove ${t.external_id}`}
|
||||||
>
|
>
|
||||||
<Tag accent={colorForKind(t.kind)}>
|
<Tag accent={t.kind === 'tactic' ? 'cyan' : t.kind === 'technique' ? 'orange' : 'purple'}>
|
||||||
{t.external_id} · {t.name} ✕
|
{t.external_id} · {t.name} ✕
|
||||||
</Tag>
|
</Tag>
|
||||||
</button>
|
</button>
|
||||||
@@ -115,126 +103,153 @@ export function MitreTagPicker({ value, onChange, compact, className }: MitreTag
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
{/* Filter + counts */}
|
||||||
{/* Tactics column */}
|
<div className="mb-3 flex items-end justify-between gap-3">
|
||||||
<div>
|
|
||||||
{!compact && (
|
|
||||||
<TextField
|
<TextField
|
||||||
label="Tactic search"
|
label="Filter"
|
||||||
value={qTactic}
|
placeholder="external_id or name (e.g. TA0006, T1003, powershell)"
|
||||||
onChange={(e) => setQTactic(e.target.value)}
|
value={filter}
|
||||||
placeholder="e.g. Credential"
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
className="max-w-md"
|
||||||
/>
|
/>
|
||||||
|
{matrix.data && (
|
||||||
|
<span className="font-sans text-xs text-text-dim mb-2 whitespace-nowrap">
|
||||||
|
{matrix.data.tactics.length} tactics ·{' '}
|
||||||
|
{matrix.data.tactics.reduce((sum, t) => sum + t.techniques.length, 0)} mappings
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="mt-2 max-h-72 overflow-y-auto" data-testid="mitre-tactics-column">
|
|
||||||
{tactics.isLoading && <p className="text-2xs text-text-dim">Loading…</p>}
|
|
||||||
{tactics.data?.items.map((t) => {
|
|
||||||
const active = activeTactic === t.external_id;
|
|
||||||
const selected = selectedKey.has(`tactic:${t.external_id}`);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={t.id}
|
|
||||||
className={cn(
|
|
||||||
'group flex items-center gap-2 rounded border px-2 py-1 cursor-pointer',
|
|
||||||
active ? 'border-cyan' : 'border-transparent hover:border-cyan',
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
setActiveTactic(t.external_id);
|
|
||||||
setActiveTechnique(null);
|
|
||||||
}}
|
|
||||||
data-testid={`mitre-tactic-${t.external_id}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selected}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onChange={() =>
|
|
||||||
toggle({ kind: 'tactic', id: t.id, external_id: t.external_id, name: t.name })
|
|
||||||
}
|
|
||||||
aria-label={`Select ${t.external_id}`}
|
|
||||||
/>
|
|
||||||
<span className="font-mono text-2xs text-cyan">{t.external_id}</span>
|
|
||||||
<span className="text-2xs">{t.name}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Techniques column */}
|
{matrix.isLoading && <p className="font-mono text-xs text-text-dim">Loading matrix…</p>}
|
||||||
<div>
|
{matrix.isError && (
|
||||||
{!compact && (
|
<Alert accent="red">Failed to load /mitre/matrix — has `make seed-mitre` been run?</Alert>
|
||||||
<TextField
|
|
||||||
label="Technique search"
|
|
||||||
value={qTechnique}
|
|
||||||
onChange={(e) => setQTechnique(e.target.value)}
|
|
||||||
placeholder="e.g. T1059"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<div className="mt-2 max-h-72 overflow-y-auto" data-testid="mitre-techniques-column">
|
|
||||||
{activeTactic === null && (
|
{matrix.data && (
|
||||||
<p className="text-2xs text-text-dim">Select a tactic to list its techniques.</p>
|
<div
|
||||||
)}
|
data-testid="mitre-matrix-scroll"
|
||||||
{techniques.isLoading && <p className="text-2xs text-text-dim">Loading…</p>}
|
role="region"
|
||||||
{techniques.data?.items.map((t) => {
|
aria-label="MITRE ATT&CK matrix"
|
||||||
const active = activeTechnique === t.external_id;
|
/* `minmax(7rem, 1fr)` ensures every cell is wide enough for the
|
||||||
const selected = selectedKey.has(`technique:${t.external_id}`);
|
* longest single word in MITRE names (no mid-word breaks), and
|
||||||
|
* stretches to fill the container otherwise. The wrapper scrolls
|
||||||
|
* horizontally — placing `overflow-x-auto` on the grid itself fails
|
||||||
|
* 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={{
|
||||||
|
gridTemplateColumns: `repeat(${matrix.data.tactics.length}, minmax(7rem, 1fr))`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{matrix.data.tactics.map((tactic) => {
|
||||||
|
const visible = tactic.techniques.filter(matches);
|
||||||
|
const tacticSel = isSelected('tactic', tactic.external_id);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={t.id}
|
key={tactic.id}
|
||||||
className={cn(
|
className="bg-bg-base flex flex-col min-w-0"
|
||||||
'group flex items-center gap-2 rounded border px-2 py-1 cursor-pointer',
|
data-testid={`mitre-column-${tactic.external_id}`}
|
||||||
active ? 'border-orange' : 'border-transparent hover:border-orange',
|
|
||||||
)}
|
|
||||||
onClick={() => setActiveTechnique(t.external_id)}
|
|
||||||
data-testid={`mitre-technique-${t.external_id}`}
|
|
||||||
>
|
>
|
||||||
<input
|
{/* Tactic header — name only (attack.mitre.org style) */}
|
||||||
type="checkbox"
|
<button
|
||||||
checked={selected}
|
type="button"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={() =>
|
||||||
onChange={() =>
|
|
||||||
toggle({
|
toggle({
|
||||||
kind: 'technique',
|
kind: 'tactic',
|
||||||
id: t.id,
|
id: tactic.id,
|
||||||
external_id: t.external_id,
|
external_id: tactic.external_id,
|
||||||
name: t.name,
|
name: tactic.name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
aria-label={`Select ${t.external_id}`}
|
className={cn(
|
||||||
/>
|
'w-full text-left px-2 py-1.5 font-sans border-b transition',
|
||||||
<span className="font-mono text-2xs text-orange">{t.external_id}</span>
|
tacticSel
|
||||||
<span className="text-2xs">{t.name}</span>
|
? 'accent-fill-cyan border-cyan text-text-bright'
|
||||||
</div>
|
: 'bg-bg-card border-border hover:bg-cyan/10 text-text-bright',
|
||||||
);
|
|
||||||
})}
|
|
||||||
{techniques.data && techniques.data.items.length === 0 && activeTactic && (
|
|
||||||
<p className="text-2xs text-text-dim">No techniques for this tactic.</p>
|
|
||||||
)}
|
)}
|
||||||
|
title={`${tactic.external_id} — ${tactic.name}`}
|
||||||
|
data-testid={`mitre-tactic-${tactic.external_id}`}
|
||||||
|
aria-pressed={tacticSel}
|
||||||
|
>
|
||||||
|
<div className="text-xs font-semibold leading-tight break-normal hyphens-none">
|
||||||
|
{tactic.name}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-[10px] text-text-dim mt-0.5">
|
||||||
|
{tactic.techniques.length} technique{tactic.techniques.length === 1 ? '' : 's'}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Techniques cells */}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{visible.length === 0 && filterNorm && (
|
||||||
|
<div className="px-2 py-1 font-sans text-[10px] text-text-dim italic">
|
||||||
|
(filtered out)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{visible.map((tech) => {
|
||||||
|
const techSel = isSelected('technique', tech.external_id);
|
||||||
|
const isExpanded = expanded.has(tech.external_id);
|
||||||
|
const hasSubs = tech.subtechniques.length > 0;
|
||||||
|
return (
|
||||||
|
<div key={tech.id} className="border-b border-border last:border-b-0">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-stretch text-xs',
|
||||||
|
techSel
|
||||||
|
? 'accent-fill-orange text-text-bright'
|
||||||
|
: 'hover:bg-orange/10',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
toggle({
|
||||||
|
kind: 'technique',
|
||||||
|
id: tech.id,
|
||||||
|
external_id: tech.external_id,
|
||||||
|
name: tech.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="flex-1 min-w-0 text-left px-2 py-1.5 font-sans break-normal hyphens-none"
|
||||||
|
title={`${tech.external_id} — ${tech.name}`}
|
||||||
|
data-testid={`mitre-technique-${tech.external_id}`}
|
||||||
|
aria-pressed={techSel}
|
||||||
|
>
|
||||||
|
<span className="leading-tight">{tech.name}</span>
|
||||||
|
</button>
|
||||||
|
{hasSubs && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleExpand(tech.external_id)}
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 px-1 border-l text-[10px] font-mono leading-none',
|
||||||
|
techSel
|
||||||
|
? 'border-text-bright/30 text-text-bright hover:bg-text-bright/10'
|
||||||
|
: 'border-border text-purple hover:bg-purple/10',
|
||||||
|
)}
|
||||||
|
aria-label={`${isExpanded ? 'Collapse' : 'Expand'} ${tech.external_id} sub-techniques`}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
data-testid={`mitre-expand-${tech.external_id}`}
|
||||||
|
title={`${tech.subtechniques.length} sub-technique${tech.subtechniques.length === 1 ? '' : 's'}`}
|
||||||
|
>
|
||||||
|
<span className="text-purple">{isExpanded ? '▾' : '▸'}</span>
|
||||||
|
<span className="ml-0.5 align-middle">{tech.subtechniques.length}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sub-techniques column */}
|
{isExpanded && hasSubs && (
|
||||||
<div>
|
<div className="bg-bg-card">
|
||||||
{!compact && (
|
{tech.subtechniques.map((sb) => {
|
||||||
<TextField
|
const subSel = isSelected('subtechnique', sb.external_id);
|
||||||
label="Sub-technique search"
|
|
||||||
value={qSub}
|
|
||||||
onChange={(e) => setQSub(e.target.value)}
|
|
||||||
placeholder="e.g. Powershell"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="mt-2 max-h-72 overflow-y-auto" data-testid="mitre-subtechniques-column">
|
|
||||||
{activeTechnique === null && (
|
|
||||||
<p className="text-2xs text-text-dim">Select a technique to list its sub-techniques.</p>
|
|
||||||
)}
|
|
||||||
{subtechniques.isLoading && <p className="text-2xs text-text-dim">Loading…</p>}
|
|
||||||
{subtechniques.data?.items.map((sb) => {
|
|
||||||
const selected = selectedKey.has(`subtechnique:${sb.external_id}`);
|
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
key={sb.id}
|
key={sb.id}
|
||||||
className="flex items-center gap-2 rounded border border-transparent hover:border-purple px-2 py-1 cursor-pointer"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
toggle({
|
toggle({
|
||||||
kind: 'subtechnique',
|
kind: 'subtechnique',
|
||||||
@@ -243,30 +258,36 @@ export function MitreTagPicker({ value, onChange, compact, className }: MitreTag
|
|||||||
name: sb.name,
|
name: sb.name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
className={cn(
|
||||||
|
'block w-full text-left pl-5 pr-2 py-1 font-sans text-xs border-l-2 break-normal hyphens-none',
|
||||||
|
subSel
|
||||||
|
? 'accent-fill-purple border-purple text-text-bright'
|
||||||
|
: 'border-purple/30 hover:bg-purple/10',
|
||||||
|
)}
|
||||||
|
title={`${sb.external_id} — ${sb.name}`}
|
||||||
data-testid={`mitre-subtechnique-${sb.external_id}`}
|
data-testid={`mitre-subtechnique-${sb.external_id}`}
|
||||||
|
aria-pressed={subSel}
|
||||||
>
|
>
|
||||||
<input
|
<span className="leading-tight">{sb.name}</span>
|
||||||
type="checkbox"
|
</button>
|
||||||
checked={selected}
|
);
|
||||||
readOnly
|
})}
|
||||||
aria-label={`Select ${sb.external_id}`}
|
</div>
|
||||||
/>
|
)}
|
||||||
<span className="font-mono text-2xs text-purple">{sb.external_id}</span>
|
|
||||||
<span className="text-2xs">{sb.name}</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{subtechniques.data && subtechniques.data.items.length === 0 && activeTechnique && (
|
</div>
|
||||||
<p className="text-2xs text-text-dim">No sub-techniques.</p>
|
</div>
|
||||||
)}
|
);
|
||||||
</div>
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(tactics.isError || techniques.isError || subtechniques.isError) && (
|
|
||||||
<Alert accent="red" className="mt-3">
|
|
||||||
Failed to load MITRE data — has `make seed-mitre` been run?
|
|
||||||
</Alert>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<p className="mt-3 font-sans text-[11px] text-text-dim">
|
||||||
|
Hover a cell for its <code className="font-mono text-purple">external_id</code>. Click a cell to toggle selection. Use <span className="text-purple font-mono">▸</span> to reveal sub-techniques inline. Click a chip above to remove.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
167
frontend/src/lib/missions.ts
Normal file
167
frontend/src/lib/missions.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
};
|
||||||
@@ -51,11 +51,55 @@ 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,
|
||||||
tactics: (q?: string) => ['mitre', 'tactics', q ?? ''] as const,
|
tactics: (q?: string) => ['mitre', 'tactics', q ?? ''] as const,
|
||||||
techniques: (tactic?: string, q?: string) =>
|
techniques: (tactic?: string, q?: string) =>
|
||||||
['mitre', 'techniques', tactic ?? '', q ?? ''] as const,
|
['mitre', 'techniques', tactic ?? '', q ?? ''] as const,
|
||||||
subtechniques: (technique?: string, q?: string) =>
|
subtechniques: (technique?: string, q?: string) =>
|
||||||
['mitre', 'subtechniques', technique ?? '', q ?? ''] as const,
|
['mitre', 'subtechniques', technique ?? '', q ?? ''] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface MatrixSubtechnique {
|
||||||
|
id: string;
|
||||||
|
external_id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatrixTechnique {
|
||||||
|
id: string;
|
||||||
|
external_id: string;
|
||||||
|
name: string;
|
||||||
|
subtechniques: MatrixSubtechnique[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatrixTactic {
|
||||||
|
id: string;
|
||||||
|
external_id: string;
|
||||||
|
short_name: string;
|
||||||
|
name: string;
|
||||||
|
techniques: MatrixTechnique[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MitreMatrix {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
136
frontend/src/lib/templates.ts
Normal file
136
frontend/src/lib/templates.ts
Normal 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}` : '';
|
||||||
|
}
|
||||||
443
frontend/src/pages/AdminScenariosPage.tsx
Normal file
443
frontend/src/pages/AdminScenariosPage.tsx
Normal 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';
|
||||||
|
}
|
||||||
404
frontend/src/pages/AdminTestsPage.tsx
Normal file
404
frontend/src/pages/AdminTestsPage.tsx
Normal 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&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';
|
||||||
|
}
|
||||||
@@ -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 & blue test orchestration — M4 milestone (MITRE ATT&CK)
|
Collaborative red & blue test orchestration — M6 milestone (Missions & snapshot)
|
||||||
</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 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 & scenario templates
|
M7 — Red & blue execution on a mission test
|
||||||
</code>
|
</code>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
807
frontend/src/pages/MissionDetailPage.tsx
Normal file
807
frontend/src/pages/MissionDetailPage.tsx
Normal file
@@ -0,0 +1,807 @@
|
|||||||
|
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,
|
||||||
|
type AddScenariosPayload,
|
||||||
|
type MemberPayload,
|
||||||
|
type Mission,
|
||||||
|
type MissionRoleHint,
|
||||||
|
type MissionStatus,
|
||||||
|
type SetMembersPayload,
|
||||||
|
type TransitionPayload,
|
||||||
|
type UpdateMissionPayload,
|
||||||
|
} from '@/lib/missions';
|
||||||
|
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' && (
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-baseline justify-between flex-wrap gap-2 mb-3">
|
||||||
|
<p className="font-mono text-2xs text-text-dim">
|
||||||
|
Snapshots are frozen at append time — editing a source template
|
||||||
|
does not propagate.
|
||||||
|
</p>
|
||||||
|
{canEdit && (
|
||||||
|
<Button
|
||||||
|
accent="cyan"
|
||||||
|
onClick={() => setAddScenarios(true)}
|
||||||
|
data-testid="mission-add-scenarios"
|
||||||
|
>
|
||||||
|
+ Add scenarios
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{m.scenarios.length === 0 ? (
|
||||||
|
<p className="font-mono text-xs text-text-dim">
|
||||||
|
No scenarios snapshotted yet.
|
||||||
|
{canEdit && ' Click "Add scenarios" to append one.'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4" data-testid="mission-scenarios">
|
||||||
|
{m.scenarios.map((sc) => (
|
||||||
|
<div
|
||||||
|
key={sc.id}
|
||||||
|
className="rounded-md border border-border bg-bg-card p-3"
|
||||||
|
data-testid={`mission-scenario-${sc.id}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Tag accent="cyan">#{sc.position + 1}</Tag>
|
||||||
|
<p className="font-mono text-xs text-text-bright">
|
||||||
|
{sc.snapshot_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{sc.snapshot_description && (
|
||||||
|
<p className="mb-2 font-mono text-2xs text-text-dim">
|
||||||
|
{sc.snapshot_description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<table className="w-full font-mono text-2xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-text-dim uppercase tracking-wider2">
|
||||||
|
<th className="text-left py-1">#</th>
|
||||||
|
<th className="text-left py-1">Test</th>
|
||||||
|
<th className="text-left py-1">MITRE</th>
|
||||||
|
<th className="text-left py-1">OPSEC</th>
|
||||||
|
<th className="text-left py-1">State</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sc.tests.map((t) => (
|
||||||
|
<tr
|
||||||
|
key={t.id}
|
||||||
|
className="border-t border-border/40"
|
||||||
|
data-testid={`mission-test-${t.id}`}
|
||||||
|
>
|
||||||
|
<td className="py-1 text-text-dim">{t.position + 1}</td>
|
||||||
|
<td className="py-1 text-text-bright">{t.snapshot_name}</td>
|
||||||
|
<td className="py-1">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{t.mitre_tags.map((tag) => (
|
||||||
|
<Tag
|
||||||
|
accent="cyan"
|
||||||
|
key={`${tag.kind}-${tag.external_id}`}
|
||||||
|
>
|
||||||
|
{tag.external_id}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-1 text-text">
|
||||||
|
{t.snapshot_opsec_level}
|
||||||
|
</td>
|
||||||
|
<td className="py-1">
|
||||||
|
<Tag
|
||||||
|
accent={
|
||||||
|
t.state === 'pending'
|
||||||
|
? 'teal'
|
||||||
|
: t.state === 'executed'
|
||||||
|
? 'orange'
|
||||||
|
: t.state === 'reviewed_by_blue'
|
||||||
|
? 'green'
|
||||||
|
: 'rose'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t.state}
|
||||||
|
</Tag>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
404
frontend/src/pages/MissionsCreatePage.tsx
Normal file
404
frontend/src/pages/MissionsCreatePage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
167
frontend/src/pages/MissionsListPage.tsx
Normal file
167
frontend/src/pages/MissionsListPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) => {
|
||||||
@@ -92,7 +97,21 @@ export function MitrePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SectionHeader prefix="Tag" highlight="Picker" accent="orange" />
|
<SectionHeader prefix="Tag" highlight="Picker" accent="orange" />
|
||||||
|
{/* Full-bleed the matrix beyond max-w-page so it uses the full viewport
|
||||||
|
* width. `calc(50% - 50vw)` is the canonical CSS recipe: the element's
|
||||||
|
* left edge slides back to viewport x=0 regardless of how big the
|
||||||
|
* outer max-w-page container is. `px-[60px]` mirrors the page padding
|
||||||
|
* so cells don't touch the viewport edge. */}
|
||||||
|
<div
|
||||||
|
className="px-[60px]"
|
||||||
|
style={{
|
||||||
|
marginLeft: 'calc(50% - 50vw)',
|
||||||
|
marginRight: 'calc(50% - 50vw)',
|
||||||
|
width: '100vw',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<MitreTagPicker value={selected} onChange={setSelected} />
|
<MitreTagPicker value={selected} onChange={setSelected} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{selected.length > 0 && (
|
{selected.length > 0 && (
|
||||||
<Card accent="purple" className="mt-6" title="Selected (preview payload)">
|
<Card accent="purple" className="mt-6" title="Selected (preview payload)">
|
||||||
@@ -104,7 +123,7 @@ export function MitrePage() {
|
|||||||
|
|
||||||
{selected.length === 0 && (
|
{selected.length === 0 && (
|
||||||
<p className="mt-3 font-mono text-2xs text-text-dim">
|
<p className="mt-3 font-mono text-2xs text-text-dim">
|
||||||
Pick a tactic on the left, then a technique, then optionally a sub-technique. Selections accumulate.
|
Click any cell to toggle. Use <span className="text-purple">▸</span> to reveal sub-techniques inline. Hover a cell for its <code className="text-purple">external_id</code>. Selections accumulate.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,29 @@ 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-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:
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,14 @@ Remplace l'aller-retour Excel actuel par une UI partagée temps réel, multi-rô
|
|||||||
|
|
||||||
## 5. Exigences fonctionnelles
|
## 5. Exigences fonctionnelles
|
||||||
- **F1** — Gestion users/groupes/invitations par admin avec permissions atomiques (familles listées en §4).
|
- **F1** — Gestion users/groupes/invitations par admin avec permissions atomiques (familles listées en §4).
|
||||||
- **F2** — CRUD tests unitaires templates avec MITRE ATT&CK (Tactic+Technique+Sub-technique multi), procédure markdown/code, prérequis, résultat attendu red, détection attendue blue, niveau OPSEC (low/med/high), tags libres, IOCs attendus.
|
- **F2** — CRUD tests unitaires templates avec MITRE ATT&CK (Tactic+Technique+Sub-technique multi), procédure markdown/code, prérequis, résultat attendu red, détection attendue blue, niveau OPSEC (low/med/high), tags libres, IOCs attendus. **Représentation UI du picker MITRE** : matrice flat fidèle à `attack.mitre.org/#` —
|
||||||
|
- **Full-bleed** : le picker s'étend sur toute la largeur du viewport (s'échappe du `max-w-page` global du layout) pour exposer un maximum de cellules sans scroll.
|
||||||
|
- **15 colonnes** equal-width via `grid-template-columns: repeat(N, minmax(7rem, 1fr))` ; scroll horizontal seulement en dernier recours sur viewport étroit (<≈1680px).
|
||||||
|
- **Wrap word-only** : `overflow-wrap: normal` + `hyphens: none` — les noms cassent uniquement sur les espaces, jamais au milieu d'un mot.
|
||||||
|
- **Headers** = nom de la tactic seul + compteur de techniques en 10px ; l'`external_id` `TA00xx` n'apparaît qu'au hover (title) et dans les chips de sélection.
|
||||||
|
- **Cellules** = nom de la technique seul (idem pour `T1xxx` au hover) ; chevron `▸ N / ▾ N` qui déplie inline les sub-techniques dans la colonne.
|
||||||
|
- **Police** sans-serif uniforme `text-xs` (12px) pour cells + headers, `10px` pour les sub-counts.
|
||||||
|
- **Click** sur une cellule = (dé)sélection ; selection multi-niveaux (tactic / technique / sub-technique) cumulative ; chips de sélection en haut avec `external_id · name` cliquables pour retirer.
|
||||||
- **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.
|
||||||
@@ -89,7 +96,7 @@ Remplace l'aller-retour Excel actuel par une UI partagée temps réel, multi-rô
|
|||||||
- **F9** — Export mission : JSON complet (API + UI), CSV agrégé.
|
- **F9** — Export mission : JSON complet (API + UI), CSV agrégé.
|
||||||
- **F10** — Soft delete + purge admin.
|
- **F10** — Soft delete + purge admin.
|
||||||
- **F11** — Switch i18n FR/EN par utilisateur (préférence persistée).
|
- **F11** — Switch i18n FR/EN par utilisateur (préférence persistée).
|
||||||
- **F12** — Sync MITRE ATT&CK Enterprise : dataset STIX embarqué (seed) + job admin manuel pour re-puller depuis github.com/mitre/cti.
|
- **F12** — Sync MITRE ATT&CK Enterprise : dataset STIX embarqué (seed) + job admin manuel pour re-puller depuis github.com/mitre/cti. Le picker (cf. F2) se base sur un endpoint `GET /mitre/matrix` qui retourne la grille complète (tactics → techniques → sub-techniques) en un seul appel.
|
||||||
|
|
||||||
## 6. Exigences non fonctionnelles
|
## 6. Exigences non fonctionnelles
|
||||||
- **NF-perf** : UI fluide, pagination côté API au-delà de 50 éléments par liste, lazy-loading des fichiers de preuves.
|
- **NF-perf** : UI fluide, pagination côté API au-delà de 50 éléments par liste, lazy-loading des fichiers de preuves.
|
||||||
|
|||||||
@@ -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 # 51 tests pytest dont 12 nouveaux MITRE (parser + endpoints)
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -38,18 +38,22 @@ Le rapport HTML est dans `e2e/playwright-report/`, le JUnit dans `e2e/playwright
|
|||||||
2. Cliquer **MITRE** dans la nav → page chargée.
|
2. Cliquer **MITRE** dans la nav → page chargée.
|
||||||
3. Carte **Source** : vérifier `version 19.0` + URL pinnée + `Last sync` non vide.
|
3. Carte **Source** : vérifier `version 19.0` + URL pinnée + `Last sync` non vide.
|
||||||
4. Carte **Sync** (admin uniquement) : cliquer **Trigger MITRE sync** → bannière verte avec counts (15 tactics / 222 techniques / 475 subtechniques).
|
4. Carte **Sync** (admin uniquement) : cliquer **Trigger MITRE sync** → bannière verte avec counts (15 tactics / 222 techniques / 475 subtechniques).
|
||||||
5. Picker :
|
5. **Picker — matrice flat type attack.mitre.org** :
|
||||||
- Cliquer **TA0006 — Credential Access** dans la colonne gauche.
|
- La matrice tient sur la largeur de la page sans scroll horizontal (15 colonnes de largeur égale, partagent l'espace dispo).
|
||||||
- La colonne du milieu liste **T1003 OS Credential Dumping** et 16 autres techniques.
|
- Chaque header de colonne montre **seulement le nom de la tactic** (ex. `Credential Access`) + `17 techniques` en petit dessous. L'`external_id` (TA0006) apparaît au hover (title).
|
||||||
- Cliquer **T1003** → la colonne droite liste **T1003.001** à **T1003.008**.
|
- Click sur le header **Credential Access** → toute la colonne est sélectionnée (chip cyan en haut, header en cyan filled).
|
||||||
- Cocher la case en face de **T1003.001 PowerShell**.
|
- Re-click pour désélectionner.
|
||||||
- Un chip cyan/orange/purple apparaît en haut + la carte « Selected (preview payload) » montre le JSON.
|
- Les cellules affichent **uniquement le nom de la technique** (ex. `OS Credential Dumping`). L'`external_id` (T1003) apparaît au hover (title) et dans le chip de sélection.
|
||||||
- Cliquer le chip → désélection.
|
- Cliquer la cellule **OS Credential Dumping** → cellule en orange filled, chip `T1003 · OS Credential Dumping` en haut.
|
||||||
|
- Cliquer le chevron `▸ 8` à droite de la cellule → la liste des sub-techniques se déploie inline dans la même colonne, chevron passe à `▾ 8`.
|
||||||
|
- Cliquer **LSASS Memory** (sub-technique) → cell purple filled, chip `T1003.001 · LSASS Memory`.
|
||||||
|
- Click le chip pour le retirer.
|
||||||
|
- La carte « Selected (preview payload) » sous la matrice montre le JSON cumulatif avec les `external_id`.
|
||||||
|
|
||||||
### 3.2 Filtres / recherche
|
### 3.2 Filtre
|
||||||
1. Dans la colonne **Tactic search**, taper `cred` → seule TA0006 reste.
|
1. Taper `dump` dans le champ **Filter** → seules T1003 + sub-techniques restent visibles, les autres techniques sont cachées (mais leurs colonnes restent visibles pour préserver la grille).
|
||||||
2. Sélectionner TA0006, dans **Technique search** taper `dump` → T1003 apparaît.
|
2. Taper `TA0006` → idem mais filtre par `external_id`.
|
||||||
3. Vider les recherches, sélectionner T1059 → vérifier T1059.001 à T1059.012 dans les sub-techniques.
|
3. Vider le filtre → toutes les cellules réapparaissent.
|
||||||
|
|
||||||
### 3.3 Non-admin
|
### 3.3 Non-admin
|
||||||
1. Inviter un user sans perms via Admin > Invitations.
|
1. Inviter un user sans perms via Admin > Invitations.
|
||||||
@@ -107,5 +111,6 @@ curl -sX POST http://localhost:8080/api/v1/mitre/sync \
|
|||||||
- [x] `/mitre/sync` exige la perm `mitre.sync` (admin via bypass `is_admin`).
|
- [x] `/mitre/sync` exige la perm `mitre.sync` (admin via bypass `is_admin`).
|
||||||
- [x] Sha256 mismatch sur la pinned URL → 502 `checksum_mismatch`, DB intacte.
|
- [x] Sha256 mismatch sur la pinned URL → 502 `checksum_mismatch`, DB intacte.
|
||||||
- [x] Bundle local (`--source <path>`) bypasse la vérif checksum.
|
- [x] Bundle local (`--source <path>`) bypasse la vérif checksum.
|
||||||
- [x] Picker SPA : tactic → technique → subtechnique, multi-select, déselection via chip cliquable.
|
- [x] Picker SPA : matrice flat attack.mitre.org-style — 15 colonnes equal-width sans scroll horizontal, cellules avec **name only** (external_id au hover + dans chips), chevron `▸ N / ▾ N` → sub-techniques inline, chips multi-niveaux en haut.
|
||||||
|
- [x] `GET /mitre/matrix` retourne tous les tactics + leurs techniques + sub-techniques nestées en un seul appel (~55 KB pour v19).
|
||||||
- [x] Non-admin : voit la page `/mitre` mais pas la carte Sync ; `POST /mitre/sync` → 403.
|
- [x] Non-admin : voit la page `/mitre` mais pas la carte Sync ; `POST /mitre/sync` → 403.
|
||||||
|
|||||||
131
tasks/testing-m5.md
Normal file
131
tasks/testing-m5.md
Normal 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
124
tasks/testing-m6.md
Normal 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).
|
||||||
@@ -111,13 +111,13 @@ spec: tasks/spec.md
|
|||||||
- ☐ Endpoint `POST /mitre/sync` (perm `mitre.sync`) qui re-pull depuis l'URL configurée (setting `mitre_source_url`).
|
- ☐ Endpoint `POST /mitre/sync` (perm `mitre.sync`) qui re-pull depuis l'URL configurée (setting `mitre_source_url`).
|
||||||
- ☐ Persister `mitre_last_sync` dans `settings`.
|
- ☐ Persister `mitre_last_sync` dans `settings`.
|
||||||
- ☐ Endpoint `GET /mitre/tactics`, `/mitre/techniques?tactic=…`, `/mitre/subtechniques?technique=…` (pagination + recherche full-text simple sur `name`).
|
- ☐ Endpoint `GET /mitre/tactics`, `/mitre/techniques?tactic=…`, `/mitre/subtechniques?technique=…` (pagination + recherche full-text simple sur `name`).
|
||||||
- ☐ Front : composant `<MitreTagPicker>` (autocomplete combiné Tactic > Technique > Sub-technique, multi-select).
|
- ☐ Front : composant `<MitreTagPicker>` — matrice flat type `attack.mitre.org/#` (colonnes = tactics, cellules = techniques, chevron `+N` qui déplie les sub-techniques inline). Click = (dé)sélection, multi-niveaux cumulatif, chips en haut, recherche par `external_id` ou `name`. Alimenté par `GET /mitre/matrix` (one-shot, ~55 KB).
|
||||||
|
|
||||||
**DoD** : après `make seed-mitre`, `GET /mitre/tactics` retourne 14 tactics Enterprise ; le picker permet de tagger un test avec « TA0002 / T1059.001 » et l'enregistrement est persistant.
|
**DoD** : après `make seed-mitre`, `GET /mitre/tactics` retourne 14 tactics Enterprise ; le picker permet de tagger un test avec « TA0002 / T1059.001 » et l'enregistrement est persistant.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user