feat(m7-amend2): implicit lifecycle — writes drive state, no workflow UI
User: «Enlève également le workflow d'un test, quand on saisit des
informations côtés redteam cela signifie qu'il a été exécuté et donc
en attente d'une review blueteam.»
Backend (update_mission_test_fields)
- At the end of every PUT, inspect the touched-field set:
- any red write on state in {pending, skipped, blocked} → state=executed
+ auto-stamp executed_at=now() if absent
- any blue write on state=executed → state=reviewed_by_blue
- /transition endpoint kept for back-fill/admin use, not called from UI.
Frontend MissionTestPage
- Removed the transition-buttons header block and the `transition`
mutation. State pill stays as a passive indicator.
- New labels: "Not started" / "Awaiting review" / "Reviewed" describe
the implicit lifecycle, no longer exposing the state-machine concept.
E2E
- The SPA test that clicked `transition-executed` now verifies the
implicit promotion: typing red fields and saving flips the pill from
"Not started" → "Awaiting review", no button click required.
Spec
- §4 reword: "Cycle de vie implicite, piloté par les écritures" replaces
the old "Workflow par test instance" bullet.
Tests
- 3 new pytest: red_command-alone implicit execute + auto-stamp,
blue write promotes executed→reviewed, blue write on pending no-op.
- 142 pytest + 49 Playwright green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
31
CHANGELOG.md
31
CHANGELOG.md
@@ -4,6 +4,37 @@ All notable changes to this project will be documented here. Format: [Keep a Cha
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed (amendement 2026-05-15 bis) — explicit test workflow removed, lifecycle now driven by writes
|
||||||
|
|
||||||
|
User feedback: «Enlève également le workflow d'un test, quand on saisit
|
||||||
|
des informations côtés redteam cela signifie qu'il a été exécuté et donc
|
||||||
|
en attente d'une review blueteam.»
|
||||||
|
|
||||||
|
- **Backend `update_mission_test_fields`**: at the end of every PUT, the
|
||||||
|
service inspects the touched field set. Any red-side write on a non-
|
||||||
|
executed test (`pending` / `skipped` / `blocked`) promotes the state to
|
||||||
|
`executed`; if no `executed_at` was supplied, it auto-stamps `now()`.
|
||||||
|
Any blue-side write on an `executed` test promotes to `reviewed_by_blue`.
|
||||||
|
The `/transition` endpoint stays operational for back-fill / admin use
|
||||||
|
but is no longer the primary path.
|
||||||
|
- **`MISSION_TEST_STATE_LABEL`** rephrased to describe the implicit
|
||||||
|
lifecycle instead of the workflow: `Pending → Not started`, `Executed
|
||||||
|
→ Awaiting review`, `Reviewed_by_blue → Reviewed`, `Skipped`, `Blocked`.
|
||||||
|
- **`MissionTestPage.tsx`**: transition buttons in the header are gone.
|
||||||
|
The state pill remains as a passive indicator. The `transition`
|
||||||
|
mutation and its imports are dropped; `useMutation` is still used for
|
||||||
|
the red / blue field saves.
|
||||||
|
- **E2E**: the SPA test that previously clicked `transition-executed`
|
||||||
|
now exercises the implicit promotion — it just types in the red fields
|
||||||
|
and asserts that the state-pill flips from `Not started` to `Awaiting
|
||||||
|
review` on save.
|
||||||
|
- **Tests**: 3 new pytest cases — `test_red_writing_any_red_field_implicitly_executes_and_stamps`
|
||||||
|
(red_command alone bumps state + auto-stamps executed_at),
|
||||||
|
`test_blue_writing_any_blue_field_promotes_executed_to_reviewed`,
|
||||||
|
`test_blue_write_on_pending_does_not_auto_execute` (blue-on-pending is
|
||||||
|
a no-op — only red drives execution per the user's mental model).
|
||||||
|
- Total: **142 pytest** + 49 Playwright green.
|
||||||
|
|
||||||
### Fixed (post-amendement 2026-05-15) — stamping executed_at no longer needs a prior state transition
|
### Fixed (post-amendement 2026-05-15) — stamping executed_at no longer needs a prior state transition
|
||||||
|
|
||||||
User feedback: when a red user typed `executed_at` inline on a pending test
|
User feedback: when a red user typed `executed_at` inline on a pending test
|
||||||
|
|||||||
@@ -591,15 +591,9 @@ def update_mission_test_fields(
|
|||||||
|
|
||||||
if "executed_at_overridden" in touched or "executed_at" in touched:
|
if "executed_at_overridden" in touched or "executed_at" in touched:
|
||||||
# Editing executed_at is a red-only privilege (gated above via
|
# Editing executed_at is a red-only privilege (gated above via
|
||||||
# _RED_FIELDS). We no longer reject the write based on the
|
# _RED_FIELDS). State auto-promotion is handled by the general
|
||||||
# current state — the spec amendement 2026-05-15 lets the red
|
# block below; this branch just validates and applies the
|
||||||
# team record an execution timestamp inline, which would be
|
# timestamp + override flag.
|
||||||
# circular if they had to transition the state machine first.
|
|
||||||
# Instead, stamping a non-null timestamp implicitly bumps the
|
|
||||||
# state forward from any non-executed source so the persisted
|
|
||||||
# record stays internally consistent. The same `mission.
|
|
||||||
# write_red_fields` perm covers both moves, so this isn't a
|
|
||||||
# privilege escalation.
|
|
||||||
new_overridden = (
|
new_overridden = (
|
||||||
bool(executed_at_overridden)
|
bool(executed_at_overridden)
|
||||||
if "executed_at_overridden" in touched
|
if "executed_at_overridden" in touched
|
||||||
@@ -612,11 +606,25 @@ def update_mission_test_fields(
|
|||||||
)
|
)
|
||||||
if "executed_at" in touched and new_at is not None and not isinstance(new_at, datetime):
|
if "executed_at" in touched and new_at is not None and not isinstance(new_at, datetime):
|
||||||
raise InvalidTestPayload("executed_at must be an ISO datetime")
|
raise InvalidTestPayload("executed_at must be an ISO datetime")
|
||||||
if new_at is not None and test.state in {"pending", "skipped", "blocked"}:
|
|
||||||
test.state = "executed"
|
|
||||||
test.executed_at = new_at
|
test.executed_at = new_at
|
||||||
test.executed_at_overridden = new_overridden
|
test.executed_at_overridden = new_overridden
|
||||||
|
|
||||||
|
# Implicit lifecycle (post-amendement 2026-05-15 bis): the explicit
|
||||||
|
# workflow is gone from the UI. A write to ANY red field implies the
|
||||||
|
# test was executed (auto-stamp executed_at if the operator didn't
|
||||||
|
# supply one); a write to ANY blue field on an executed test implies
|
||||||
|
# the blue team reviewed it. The /transition endpoint stays for
|
||||||
|
# back-fill but is no longer the primary path.
|
||||||
|
red_touched = bool(touched & _RED_FIELDS)
|
||||||
|
blue_touched = bool(touched & _BLUE_FIELDS)
|
||||||
|
if red_touched and test.state in {"pending", "skipped", "blocked"}:
|
||||||
|
if test.executed_at is None:
|
||||||
|
test.executed_at = datetime.now(tz=timezone.utc)
|
||||||
|
test.executed_at_overridden = False
|
||||||
|
test.state = "executed"
|
||||||
|
if blue_touched and test.state == "executed":
|
||||||
|
test.state = "reviewed_by_blue"
|
||||||
|
|
||||||
_touch(test, viewer_id)
|
_touch(test, viewer_id)
|
||||||
s.flush()
|
s.flush()
|
||||||
s.refresh(test)
|
s.refresh(test)
|
||||||
|
|||||||
@@ -618,6 +618,82 @@ def test_red_setting_executed_at_on_pending_auto_transitions_to_executed(
|
|||||||
assert forbidden.status_code == 403
|
assert forbidden.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_red_writing_any_red_field_implicitly_executes_and_stamps(
|
||||||
|
client, admin_token, catalogue, red_user
|
||||||
|
):
|
||||||
|
"""Post-amendement 2026-05-15 bis: the explicit state-machine workflow
|
||||||
|
is gone. Writing ANY red field on a non-executed test promotes the
|
||||||
|
state to `executed`; if the operator didn't supply an `executed_at`,
|
||||||
|
the service auto-stamps `now()` so the persisted row is internally
|
||||||
|
consistent."""
|
||||||
|
mission = _make_mission(
|
||||||
|
client, admin_token, name="m7-implicit-execute",
|
||||||
|
scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"],
|
||||||
|
)
|
||||||
|
tid = _first_test_id(mission)
|
||||||
|
r = client.put(
|
||||||
|
f"/api/v1/missions/{mission['id']}/tests/{tid}",
|
||||||
|
headers=_bearer(red_user["token"]),
|
||||||
|
json={"red_command": "whoami /priv"}, # no executed_at!
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.get_data(as_text=True)
|
||||||
|
body = r.get_json()
|
||||||
|
assert body["state"] == "executed"
|
||||||
|
assert body["red_command"] == "whoami /priv"
|
||||||
|
assert body["executed_at"] is not None # auto-stamped
|
||||||
|
assert body["executed_at_overridden"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_blue_writing_any_blue_field_promotes_executed_to_reviewed(
|
||||||
|
client, admin_token, catalogue, red_user, blue_user
|
||||||
|
):
|
||||||
|
"""A blue write on an executed test implicitly promotes the state to
|
||||||
|
`reviewed_by_blue` — no explicit transition required from the UI."""
|
||||||
|
mission = _make_mission(
|
||||||
|
client, admin_token, name="m7-implicit-review",
|
||||||
|
scenario_id=catalogue["scenario"]["id"],
|
||||||
|
red_id=red_user["id"], blue_id=blue_user["id"],
|
||||||
|
)
|
||||||
|
tid = _first_test_id(mission)
|
||||||
|
# Red executes (also implicit).
|
||||||
|
client.put(
|
||||||
|
f"/api/v1/missions/{mission['id']}/tests/{tid}",
|
||||||
|
headers=_bearer(red_user["token"]),
|
||||||
|
json={"red_command": "powershell -enc Y2FsYw=="},
|
||||||
|
)
|
||||||
|
# Blue reviews by writing a comment — state should auto-promote.
|
||||||
|
r = client.put(
|
||||||
|
f"/api/v1/missions/{mission['id']}/tests/{tid}",
|
||||||
|
headers=_bearer(blue_user["token"]),
|
||||||
|
json={"blue_comment_md": "alert raised on EDR"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.get_data(as_text=True)
|
||||||
|
body = r.get_json()
|
||||||
|
assert body["state"] == "reviewed_by_blue"
|
||||||
|
|
||||||
|
|
||||||
|
def test_blue_write_on_pending_does_not_auto_execute(
|
||||||
|
client, admin_token, catalogue, blue_user
|
||||||
|
):
|
||||||
|
"""A blue write on a still-pending test does NOT auto-execute — only
|
||||||
|
red's writes imply execution (per user feedback: 'quand on saisit des
|
||||||
|
informations côtés redteam cela signifie qu'il a été exécuté')."""
|
||||||
|
mission = _make_mission(
|
||||||
|
client, admin_token, name="m7-blue-on-pending",
|
||||||
|
scenario_id=catalogue["scenario"]["id"], blue_id=blue_user["id"],
|
||||||
|
)
|
||||||
|
tid = _first_test_id(mission)
|
||||||
|
r = client.put(
|
||||||
|
f"/api/v1/missions/{mission['id']}/tests/{tid}",
|
||||||
|
headers=_bearer(blue_user["token"]),
|
||||||
|
json={"blue_comment_md": "pre-emptive note"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.get_json()
|
||||||
|
assert body["state"] == "pending" # unchanged
|
||||||
|
assert body["executed_at"] is None # not stamped
|
||||||
|
|
||||||
|
|
||||||
def test_red_setting_executed_at_from_skipped_state_auto_transitions(
|
def test_red_setting_executed_at_from_skipped_state_auto_transitions(
|
||||||
client, admin_token, catalogue, red_user
|
client, admin_token, catalogue, red_user
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -382,19 +382,16 @@ test.describe('M7 — Test execution', () => {
|
|||||||
|
|
||||||
await page.goto(`/missions/${m.id}/tests/${testId}`);
|
await page.goto(`/missions/${m.id}/tests/${testId}`);
|
||||||
await expect(page.getByTestId('mission-test-page')).toBeVisible();
|
await expect(page.getByTestId('mission-test-page')).toBeVisible();
|
||||||
await expect(page.getByTestId('state-pill')).toContainText(/Pending/);
|
await expect(page.getByTestId('state-pill')).toContainText(/Not started/);
|
||||||
|
|
||||||
// Fill the red command + comment, then save.
|
// Fill the red command + comment, then save. Post-amendement 2026-05-15
|
||||||
|
// bis: writing red fields implicitly promotes the state — no transition
|
||||||
|
// button click required.
|
||||||
await page.getByTestId('red-command').fill('whoami /priv');
|
await page.getByTestId('red-command').fill('whoami /priv');
|
||||||
await page.getByTestId('red-comment').fill('Verified locally');
|
await page.getByTestId('red-comment').fill('Verified locally');
|
||||||
await page.getByTestId('red-save').click();
|
await page.getByTestId('red-save').click();
|
||||||
|
|
||||||
// After save the state-pill stays Pending (only transitions change it).
|
await expect(page.getByTestId('state-pill')).toContainText(/Awaiting review/);
|
||||||
await expect(page.getByTestId('state-pill')).toContainText(/Pending/);
|
|
||||||
|
|
||||||
// Now transition to executed via the header button.
|
|
||||||
await page.getByTestId('transition-executed').click();
|
|
||||||
await expect(page.getByTestId('state-pill')).toContainText(/Executed/);
|
|
||||||
|
|
||||||
// The "last touched" line should now mention the red user.
|
// The "last touched" line should now mention the red user.
|
||||||
await expect(page.locator('text=/Last touched/')).toBeVisible();
|
await expect(page.locator('text=/Last touched/')).toBeVisible();
|
||||||
|
|||||||
@@ -315,9 +315,12 @@ export const EVIDENCE_ALLOWED_EXTENSIONS = [
|
|||||||
|
|
||||||
export const EVIDENCE_MAX_BYTES = 25 * 1024 * 1024;
|
export const EVIDENCE_MAX_BYTES = 25 * 1024 * 1024;
|
||||||
|
|
||||||
|
// Post-amendement 2026-05-15 bis: the explicit workflow is gone from the
|
||||||
|
// UI — the state column survives in the DB but the labels describe the
|
||||||
|
// implicit lifecycle (driven by which side has written data).
|
||||||
export const MISSION_TEST_STATE_LABEL: Record<MissionTestState, string> = {
|
export const MISSION_TEST_STATE_LABEL: Record<MissionTestState, string> = {
|
||||||
pending: 'Pending',
|
pending: 'Not started',
|
||||||
executed: 'Executed',
|
executed: 'Awaiting review',
|
||||||
reviewed_by_blue: 'Reviewed',
|
reviewed_by_blue: 'Reviewed',
|
||||||
skipped: 'Skipped',
|
skipped: 'Skipped',
|
||||||
blocked: 'Blocked',
|
blocked: 'Blocked',
|
||||||
|
|||||||
@@ -28,21 +28,13 @@ import { Card } from '@/components/ui/Card';
|
|||||||
import { SectionHeader } from '@/components/ui/SectionHeader';
|
import { SectionHeader } from '@/components/ui/SectionHeader';
|
||||||
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 {
|
import { ApiError, apiDelete, apiFetch, apiGet, apiPut } from '@/lib/api';
|
||||||
ApiError,
|
|
||||||
apiDelete,
|
|
||||||
apiFetch,
|
|
||||||
apiGet,
|
|
||||||
apiPost,
|
|
||||||
apiPut,
|
|
||||||
} from '@/lib/api';
|
|
||||||
import { useAuth } from '@/lib/auth';
|
import { useAuth } from '@/lib/auth';
|
||||||
import {
|
import {
|
||||||
EVIDENCE_ALLOWED_EXTENSIONS,
|
EVIDENCE_ALLOWED_EXTENSIONS,
|
||||||
EVIDENCE_MAX_BYTES,
|
EVIDENCE_MAX_BYTES,
|
||||||
MISSION_TEST_STATE_ACCENT,
|
MISSION_TEST_STATE_ACCENT,
|
||||||
MISSION_TEST_STATE_LABEL,
|
MISSION_TEST_STATE_LABEL,
|
||||||
VALID_TEST_TRANSITIONS,
|
|
||||||
missionKeys,
|
missionKeys,
|
||||||
missionTestKeys,
|
missionTestKeys,
|
||||||
type ActivityResponse,
|
type ActivityResponse,
|
||||||
@@ -50,7 +42,6 @@ import {
|
|||||||
type DetectionLevelList,
|
type DetectionLevelList,
|
||||||
type MissionTestDetail,
|
type MissionTestDetail,
|
||||||
type MissionTestEvidence,
|
type MissionTestEvidence,
|
||||||
type TestTransitionPayload,
|
|
||||||
type UpdateMissionTestPayload,
|
type UpdateMissionTestPayload,
|
||||||
} from '@/lib/missions';
|
} from '@/lib/missions';
|
||||||
|
|
||||||
@@ -624,18 +615,6 @@ export function MissionTestPage() {
|
|||||||
|
|
||||||
useActivityWatcher(missionId, testId, onActivityTouched);
|
useActivityWatcher(missionId, testId, onActivityTouched);
|
||||||
|
|
||||||
const transition = useMutation({
|
|
||||||
mutationFn: (body: TestTransitionPayload) =>
|
|
||||||
apiPost<MissionTestDetail>(
|
|
||||||
`/missions/${missionId}/tests/${testId}/transition`,
|
|
||||||
body,
|
|
||||||
),
|
|
||||||
onSuccess: (next) => {
|
|
||||||
qc.setQueryData(missionTestKeys.detail(missionId, testId), next);
|
|
||||||
qc.invalidateQueries({ queryKey: missionKeys.detail(missionId) });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const test = detail.data;
|
const test = detail.data;
|
||||||
|
|
||||||
const perms = useMemo(() => {
|
const perms = useMemo(() => {
|
||||||
@@ -667,10 +646,6 @@ export function MissionTestPage() {
|
|||||||
return <Alert accent="red">Failed to load mission test.</Alert>;
|
return <Alert accent="red">Failed to load mission test.</Alert>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowedTransitions = VALID_TEST_TRANSITIONS[test.state] ?? [];
|
|
||||||
const transitionErr =
|
|
||||||
transition.error instanceof ApiError ? transition.error : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4" data-testid="mission-test-page">
|
<div className="flex flex-col gap-4" data-testid="mission-test-page">
|
||||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
@@ -693,37 +668,18 @@ export function MissionTestPage() {
|
|||||||
: ''}
|
: ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Post-amendement 2026-05-15 bis: no transition buttons. The pill
|
||||||
|
is a passive indicator of where the implicit lifecycle stands
|
||||||
|
(driven by who has written data). */}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span data-testid="state-pill">
|
<span data-testid="state-pill">
|
||||||
<Tag accent={MISSION_TEST_STATE_ACCENT[test.state]}>
|
<Tag accent={MISSION_TEST_STATE_ACCENT[test.state]}>
|
||||||
{MISSION_TEST_STATE_LABEL[test.state]}
|
{MISSION_TEST_STATE_LABEL[test.state]}
|
||||||
</Tag>
|
</Tag>
|
||||||
</span>
|
</span>
|
||||||
{allowedTransitions.map((target) => (
|
|
||||||
<Button
|
|
||||||
key={target}
|
|
||||||
accent={MISSION_TEST_STATE_ACCENT[target]}
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => transition.mutate({ target_state: target })}
|
|
||||||
disabled={transition.isPending}
|
|
||||||
data-testid={`transition-${target}`}
|
|
||||||
>
|
|
||||||
→ {MISSION_TEST_STATE_LABEL[target]}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{transitionErr && (
|
|
||||||
<Alert accent="red">
|
|
||||||
{transitionErr.payload &&
|
|
||||||
typeof transitionErr.payload === 'object' &&
|
|
||||||
'message' in (transitionErr.payload as Record<string, unknown>)
|
|
||||||
? `${transitionErr.message} — ${(transitionErr.payload as { message?: string }).message}`
|
|
||||||
: transitionErr.message}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card className="flex flex-col gap-2">
|
<Card className="flex flex-col gap-2">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{test.mitre_tags.map((tag) => (
|
{test.mitre_tags.map((tag) => (
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ Remplace l'aller-retour Excel actuel par une UI partagée temps réel, multi-rô
|
|||||||
- Saisie des résultats red (texte uniquement : commande, output, commentaires) avec horodatage auto au clic « Marquer exécuté » + override manuel.
|
- Saisie des résultats red (texte uniquement : commande, output, commentaires) avec horodatage auto au clic « Marquer exécuté » + override manuel.
|
||||||
- Saisie des preuves blue : multi-fichiers (PNG/JPG/PDF/TXT/LOG/JSON/CSV/EVTX/ZIP, max 25 Mo/fichier, SHA256 stocké) + commentaires markdown + niveau de détection (taxonomie custom paramétrable par admin, seed par défaut : `detected_blocked / detected_alert / logged_only / not_detected`).
|
- Saisie des preuves blue : multi-fichiers (PNG/JPG/PDF/TXT/LOG/JSON/CSV/EVTX/ZIP, max 25 Mo/fichier, SHA256 stocké) + commentaires markdown + niveau de détection (taxonomie custom paramétrable par admin, seed par défaut : `detected_blocked / detected_alert / logged_only / not_detected`).
|
||||||
- **Saisie côté blue — fiche de review étendue (amendement 2026-05-15)** : en plus du commentaire et du niveau de détection, la fiche de review d'un test capture les 5 champs additionnels que la blue maintenait en Excel — **`log_source`** (texte court : Firewall / NDR / Proxy / AV / EDR / …), **`siem_logs`** (texte long, extrait brut de logs collectés au SIEM), **sous-record cyber-incident** `(incident_at: timestamptz, incident_number: texte court, incident_recipient_email: email)` qui matérialise l'alerte envoyée à l'équipe SOC. Tous ces champs sont blue-side et gated par `mission.write_blue_fields`.
|
- **Saisie côté blue — fiche de review étendue (amendement 2026-05-15)** : en plus du commentaire et du niveau de détection, la fiche de review d'un test capture les 5 champs additionnels que la blue maintenait en Excel — **`log_source`** (texte court : Firewall / NDR / Proxy / AV / EDR / …), **`siem_logs`** (texte long, extrait brut de logs collectés au SIEM), **sous-record cyber-incident** `(incident_at: timestamptz, incident_number: texte court, incident_recipient_email: email)` qui matérialise l'alerte envoyée à l'équipe SOC. Tous ces champs sont blue-side et gated par `mission.write_blue_fields`.
|
||||||
- Workflow par test instance : `pending → executed → reviewed_by_blue` + voies `skipped / blocked`.
|
- **Cycle de vie d'un test instance — implicite, piloté par les écritures (amendement 2026-05-15 bis)** : aucun bouton de transition exposé en UI. La colonne `state` du DB reste (`pending / executed / reviewed_by_blue / skipped / blocked`) mais elle est **auto-promue** par le service à chaque PUT : (1) toute écriture côté red sur un test `pending|skipped|blocked` fait passer à `executed` et auto-stamp `executed_at = now()` si absent ; (2) toute écriture côté blue sur un test `executed` fait passer à `reviewed_by_blue`. Une écriture blue sur un test `pending` n'auto-promote pas (seule la red implique l'exécution). L'endpoint `/transition` est conservé pour back-fill admin mais n'est plus appelé par l'UI.
|
||||||
- Visibilité mission : whitebox totale pour la blue team dès la création (pas de masquage des procédures).
|
- Visibilité mission : whitebox totale pour la blue team dès la création (pas de masquage des procédures).
|
||||||
- Édition concurrente : last-write-wins + indicateur « modifié par X il y a Ns » via polling léger. Conflits red/blue impossibles par construction (champs disjoints).
|
- Édition concurrente : last-write-wins + indicateur « modifié par X il y a Ns » via polling léger. Conflits red/blue impossibles par construction (champs disjoints).
|
||||||
- Notifications in-app uniquement (badge + liste), pas de SMTP.
|
- Notifications in-app uniquement (badge + liste), pas de SMTP.
|
||||||
|
|||||||
Reference in New Issue
Block a user