feat(m6): missions + snapshot CRUD, membership visibility, status state machine
Adds the mission layer that materialises template snapshots, plus the SPA
list / 3-step wizard / detail page.
Backend:
- app/services/missions.py — create_mission snapshots scenarios, tests, MITRE
tags in a 4-query write; list/get apply a non-admin membership filter that
collapses to 404 (no existence leak); status state machine enforces
draft → in_progress → completed → archived with archived as a sink; the
non-admin creator is auto-added as role_hint='red' to retain visibility.
- app/api/missions.py — 8 endpoints (list, get, create, update, add
scenarios, set members, transition, soft-delete) with strict pydantic
schemas. The transition endpoint splits the perm gate manually so
archive requires mission.archive while other targets use mission.update.
- app/api/users.py — new GET /users/roster returning (id, email,
display_name) only, gated by user.read OR mission.create OR
mission.update — lets non-admin wizard users see assignable peers
without exposing the admin /users payload.
- app/api/diag.py — /diag/reset truncates the mission_* tables before the
template tables because the source_*_template_id FKs are ON DELETE SET
NULL, which is cheaper to short-circuit by removing the children first.
Frontend:
- lib/missions.ts — typed client, queryKey factory, status accent map.
- pages/MissionsListPage.tsx — list cards with status accent + filters
(q, client, status).
- pages/MissionsCreatePage.tsx — 3-step wizard (meta → scenarios → members)
with member roster fed by /users/roster.
- pages/MissionDetailPage.tsx — header + transition buttons (legal next
states only) + Tests/Members/Synthesis/Export tabs.
- Routes + nav entry (visible to anyone with mission.read or admin).
Tests:
- backend/tests/test_missions.py — 22 pytest covering snapshot fidelity,
MITRE propagation, membership visibility, transition state machine,
perm gating, member set replace, append scenarios, soft-delete, partial
update, inverted-date rejection.
- e2e/tests/m6-missions.spec.ts — 5 Playwright (snapshot freezing, non-admin
visibility, status transitions + 409, SPA wizard end-to-end, list filter).
Docs:
- CHANGELOG, tasks/testing-m6.md, tasks/lessons.md (snapshot tradeoffs,
membership=404 pattern, /diag/reset order, auto-creator add).
- README + tasks/todo.md updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:07:32 +02:00
|
|
|
/**
|
|
|
|
|
* 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 = {
|
2026-05-13 15:14:57 +02:00
|
|
|
/** 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,
|
feat(m6): missions + snapshot CRUD, membership visibility, status state machine
Adds the mission layer that materialises template snapshots, plus the SPA
list / 3-step wizard / detail page.
Backend:
- app/services/missions.py — create_mission snapshots scenarios, tests, MITRE
tags in a 4-query write; list/get apply a non-admin membership filter that
collapses to 404 (no existence leak); status state machine enforces
draft → in_progress → completed → archived with archived as a sink; the
non-admin creator is auto-added as role_hint='red' to retain visibility.
- app/api/missions.py — 8 endpoints (list, get, create, update, add
scenarios, set members, transition, soft-delete) with strict pydantic
schemas. The transition endpoint splits the perm gate manually so
archive requires mission.archive while other targets use mission.update.
- app/api/users.py — new GET /users/roster returning (id, email,
display_name) only, gated by user.read OR mission.create OR
mission.update — lets non-admin wizard users see assignable peers
without exposing the admin /users payload.
- app/api/diag.py — /diag/reset truncates the mission_* tables before the
template tables because the source_*_template_id FKs are ON DELETE SET
NULL, which is cheaper to short-circuit by removing the children first.
Frontend:
- lib/missions.ts — typed client, queryKey factory, status accent map.
- pages/MissionsListPage.tsx — list cards with status accent + filters
(q, client, status).
- pages/MissionsCreatePage.tsx — 3-step wizard (meta → scenarios → members)
with member roster fed by /users/roster.
- pages/MissionDetailPage.tsx — header + transition buttons (legal next
states only) + Tests/Members/Synthesis/Export tabs.
- Routes + nav entry (visible to anyone with mission.read or admin).
Tests:
- backend/tests/test_missions.py — 22 pytest covering snapshot fidelity,
MITRE propagation, membership visibility, transition state machine,
perm gating, member set replace, append scenarios, soft-delete, partial
update, inverted-date rejection.
- e2e/tests/m6-missions.spec.ts — 5 Playwright (snapshot freezing, non-admin
visibility, status transitions + 409, SPA wizard end-to-end, list filter).
Docs:
- CHANGELOG, tasks/testing-m6.md, tasks/lessons.md (snapshot tradeoffs,
membership=404 pattern, /diag/reset order, auto-creator add).
- README + tasks/todo.md updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:07:32 +02:00
|
|
|
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',
|
|
|
|
|
};
|