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>
This commit is contained in:
162
frontend/src/lib/missions.ts
Normal file
162
frontend/src/lib/missions.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 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 = {
|
||||
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',
|
||||
};
|
||||
Reference in New Issue
Block a user