Skip to content

Commit 97248cc

Browse files
Add incident timeline notes
1 parent 4c203b7 commit 97248cc

6 files changed

Lines changed: 374 additions & 7 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ Das Backend stellt serverseitige Weboberflaechen und APIs fuer die migrierten Pr
9595
- `/cves/`
9696
- `/admin/users/`
9797

98-
Incidents werden als Rust-Fallakten unter `/incidents/` gefuehrt. Detailseiten unter `/incidents/{id}` erlauben die Bearbeitung von Typ, Runbook, Status, Severity, Meldezeitpunkten und Behoerdenreferenz; Statuswechsel, Anlage und incidentbezogene Evidence-Uploads werden als Timeline-/Audit-Events in der Fallakte dokumentiert. Tenantbezogene Runbook-Vorlagen stehen ueber `/api/v1/incidents/runbook-templates` und im Incident-Formular bereit. Verknuepfte Evidence wird direkt in der Fallakte angezeigt und kann dort hochgeladen werden. Das NIS2-Meldepaket inklusive Audit-Timeline kann als Markdown, HTML oder PDF ueber `/incidents/{id}/nis2-export`, `/incidents/{id}/nis2-export.html`, `/incidents/{id}/nis2-export.pdf` sowie die entsprechenden `/api/v1/incidents/{id}/...` Endpunkte exportiert werden.
98+
Incidents werden als Rust-Fallakten unter `/incidents/` gefuehrt. Detailseiten unter `/incidents/{id}` erlauben die Bearbeitung von Typ, Runbook, Status, Severity, Meldezeitpunkten und Behoerdenreferenz; Statuswechsel, Anlage, manuelle Timeline-Notizen und incidentbezogene Evidence-Uploads werden als Timeline-/Audit-Events in der Fallakte dokumentiert. Tenantbezogene Runbook-Vorlagen stehen ueber `/api/v1/incidents/runbook-templates` und im Incident-Formular bereit. Verknuepfte Evidence wird direkt in der Fallakte angezeigt und kann dort hochgeladen werden. Das NIS2-Meldepaket inklusive Audit-Timeline kann als Markdown, HTML oder PDF ueber `/incidents/{id}/nis2-export`, `/incidents/{id}/nis2-export.html`, `/incidents/{id}/nis2-export.pdf` sowie die entsprechenden `/api/v1/incidents/{id}/...` Endpunkte exportiert werden.
9999

100100
## Zero-Trust Agent
101101

docs/ISCY_Handbuch.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -561,7 +561,7 @@ Wenn ein Incident als NIS2-meldepflichtig markiert wird, berechnet ISCY die rele
561561

562562
Die Uebersicht zeigt offene Faelle, NIS2-relevante Faelle und ueberfaellige Meldeschritte. Gesendete Meldungen koennen ueber die API als Zeitstempel gepflegt werden.
563563

564-
Die Detailseite `/incidents/{id}` dient als operative Fallakte. Dort koennen berechtigte Rollen Typ, Runbook, Status, Severity, Behoerdenreferenz, Zeitlinie und Meldezeitpunkte pflegen; die Runbook-Bibliothek wird als Referenz direkt neben der Fallakte angezeigt. ISCY dokumentiert die Anlage der Fallakte, Statuswechsel und incidentbezogene Evidence-Uploads als Timeline-/Audit-Events mit Actor, Zeitpunkt, Ereignisart und Detailtext. Evidence-Uploads koennen ueber `incident_id` direkt an einen Incident gekoppelt werden und erscheinen in der Fallakte; fuer berechtigte Rollen steht der Upload direkt auf der Incident-Detailseite bereit. Das Meldepaket unter `/incidents/{id}/nis2-export` buendelt Fallakte, Runbook, verknuepfte Evidence, Audit-Timeline, betroffene Bezuege, 24h-/72h-/30-Tage-Fristen, Stakeholder-Zusammenfassung und Lessons Learned; zusaetzlich stehen HTML und PDF ueber `/incidents/{id}/nis2-export.html` und `/incidents/{id}/nis2-export.pdf` bereit.
564+
Die Detailseite `/incidents/{id}` dient als operative Fallakte. Dort koennen berechtigte Rollen Typ, Runbook, Status, Severity, Behoerdenreferenz, Zeitlinie und Meldezeitpunkte pflegen; die Runbook-Bibliothek wird als Referenz direkt neben der Fallakte angezeigt. ISCY dokumentiert die Anlage der Fallakte, Statuswechsel, manuelle Timeline-Notizen und incidentbezogene Evidence-Uploads als Timeline-/Audit-Events mit Actor, Zeitpunkt, Ereignisart und Detailtext. Manuelle Notizen koennen auch ueber `POST /api/v1/incidents/{id}/timeline-notes` automatisiert erfasst werden. Evidence-Uploads koennen ueber `incident_id` direkt an einen Incident gekoppelt werden und erscheinen in der Fallakte; fuer berechtigte Rollen steht der Upload direkt auf der Incident-Detailseite bereit. Das Meldepaket unter `/incidents/{id}/nis2-export` buendelt Fallakte, Runbook, verknuepfte Evidence, Audit-Timeline, betroffene Bezuege, 24h-/72h-/30-Tage-Fristen, Stakeholder-Zusammenfassung und Lessons Learned; zusaetzlich stehen HTML und PDF ueber `/incidents/{id}/nis2-export.html` und `/incidents/{id}/nis2-export.pdf` bereit.
565565

566566
### 6.6 SOC-Playbook fuer Phishing- und aehnliche Incident-Faelle
567567

docs/RUST_CUTOVER_STATUS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ ISCY startet produktiv und lokal ueber den Rust-Axum-Service in `rust/iscy-backe
2424
- Assessments
2525
- Organizations
2626
- Risks
27-
- Incidents inklusive NIS2-Meldefristen, Fallakten, Detailbearbeitung, tenantbezogener Runbook-Template-Bibliothek, Timeline-/Audit-Events und Markdown-/HTML-/PDF-Meldepaket mit Audit-Timeline
27+
- Incidents inklusive NIS2-Meldefristen, Fallakten, Detailbearbeitung, tenantbezogener Runbook-Template-Bibliothek, manueller Timeline-Notizen, Timeline-/Audit-Events und Markdown-/HTML-/PDF-Meldepaket mit Audit-Timeline
2828
- Evidence inklusive Upload, Incident-Verknuepfung, direktem Fallakten-Upload und automatischem Timeline-Ereignis
2929
- Reports
3030
- Roadmap

rust/iscy-backend/src/incident_store.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,30 @@ impl IncidentEventWriteRequest {
193193
evidence_item_id: Some(evidence_item_id),
194194
}
195195
}
196+
197+
pub fn timeline_note(summary: Option<&str>, detail: &str) -> Self {
198+
let detail = detail.trim();
199+
let summary = summary
200+
.map(str::trim)
201+
.filter(|value| !value.is_empty())
202+
.map(str::to_string)
203+
.unwrap_or_else(|| {
204+
let preview = limit_chars(detail, 120);
205+
if preview.is_empty() {
206+
"Notiz zur Fallakte dokumentiert.".to_string()
207+
} else {
208+
format!("Notiz: {preview}")
209+
}
210+
});
211+
Self {
212+
event_type: "TIMELINE_NOTE".to_string(),
213+
summary,
214+
detail: detail.to_string(),
215+
from_status: None,
216+
to_status: None,
217+
evidence_item_id: None,
218+
}
219+
}
196220
}
197221

198222
#[derive(Debug, Clone, Copy)]

rust/iscy-backend/src/lib.rs

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,12 @@ struct WebIncidentForm {
522522
lessons_learned: Option<String>,
523523
}
524524

525+
#[derive(Debug, Deserialize)]
526+
struct WebIncidentTimelineNoteForm {
527+
summary: Option<String>,
528+
detail: String,
529+
}
530+
525531
fn deserialize_optional_form_list<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
526532
where
527533
D: serde::Deserializer<'de>,
@@ -700,13 +706,25 @@ pub struct IncidentRunbookTemplateListResponse {
700706
pub templates: Vec<incident_store::IncidentRunbookTemplateSummary>,
701707
}
702708

709+
#[derive(Debug, Deserialize)]
710+
pub struct IncidentTimelineNoteRequest {
711+
pub summary: Option<String>,
712+
pub detail: String,
713+
}
714+
703715
#[derive(Debug, Serialize)]
704716
pub struct IncidentDetailResponse {
705717
pub api_version: &'static str,
706718
pub incident: incident_store::IncidentSummary,
707719
pub events: Vec<incident_store::IncidentEventSummary>,
708720
}
709721

722+
#[derive(Debug, Serialize)]
723+
pub struct IncidentEventWriteResponse {
724+
pub api_version: &'static str,
725+
pub event: incident_store::IncidentEventSummary,
726+
}
727+
710728
#[derive(Debug, Serialize)]
711729
pub struct IncidentWriteResponse {
712730
pub api_version: &'static str,
@@ -3848,6 +3866,111 @@ async fn incident_update(
38483866
}
38493867
}
38503868

3869+
async fn incident_timeline_note_create(
3870+
Path(incident_id): Path<i64>,
3871+
State(state): State<AppState>,
3872+
headers: HeaderMap,
3873+
Json(payload): Json<IncidentTimelineNoteRequest>,
3874+
) -> Response {
3875+
let context = match authenticated_tenant_context(&state, &headers).await {
3876+
Ok(context) => context,
3877+
Err(err) => {
3878+
return (
3879+
err.status_code(),
3880+
Json(ApiErrorResponse {
3881+
accepted: false,
3882+
api_version: "v1",
3883+
error_code: err.error_code(),
3884+
message: err.message().to_string(),
3885+
}),
3886+
)
3887+
.into_response();
3888+
}
3889+
};
3890+
if let Some(response) = write_permission_error(&context) {
3891+
return response;
3892+
}
3893+
let payload = match incident_timeline_note_payload(payload.summary, payload.detail) {
3894+
Ok(payload) => payload,
3895+
Err(message) => {
3896+
return (
3897+
StatusCode::BAD_REQUEST,
3898+
Json(ApiErrorResponse {
3899+
accepted: false,
3900+
api_version: "v1",
3901+
error_code: "invalid_timeline_note",
3902+
message,
3903+
}),
3904+
)
3905+
.into_response();
3906+
}
3907+
};
3908+
3909+
let Some(store) = state.incident_store.clone() else {
3910+
return (
3911+
StatusCode::SERVICE_UNAVAILABLE,
3912+
Json(ApiErrorResponse {
3913+
accepted: false,
3914+
api_version: "v1",
3915+
error_code: "database_not_configured",
3916+
message: "Rust-Incident-Store ist nicht konfiguriert.".to_string(),
3917+
}),
3918+
)
3919+
.into_response();
3920+
};
3921+
3922+
match store.incident_detail(context.tenant_id, incident_id).await {
3923+
Ok(Some(_)) => match store
3924+
.append_incident_event(
3925+
context.tenant_id,
3926+
incident_id,
3927+
Some(context.user_id),
3928+
payload,
3929+
)
3930+
.await
3931+
{
3932+
Ok(event) => (
3933+
StatusCode::CREATED,
3934+
Json(IncidentEventWriteResponse {
3935+
api_version: "v1",
3936+
event,
3937+
}),
3938+
)
3939+
.into_response(),
3940+
Err(err) => (
3941+
StatusCode::INTERNAL_SERVER_ERROR,
3942+
Json(ApiErrorResponse {
3943+
accepted: false,
3944+
api_version: "v1",
3945+
error_code: "database_error",
3946+
message: format!("Timeline-Notiz konnte nicht gespeichert werden: {err}"),
3947+
}),
3948+
)
3949+
.into_response(),
3950+
},
3951+
Ok(None) => (
3952+
StatusCode::NOT_FOUND,
3953+
Json(ApiErrorResponse {
3954+
accepted: false,
3955+
api_version: "v1",
3956+
error_code: "incident_not_found",
3957+
message: format!("Incident {} wurde nicht gefunden.", incident_id),
3958+
}),
3959+
)
3960+
.into_response(),
3961+
Err(err) => (
3962+
StatusCode::INTERNAL_SERVER_ERROR,
3963+
Json(ApiErrorResponse {
3964+
accepted: false,
3965+
api_version: "v1",
3966+
error_code: "database_error",
3967+
message: format!("Incidentdetail konnte nicht gelesen werden: {err}"),
3968+
}),
3969+
)
3970+
.into_response(),
3971+
}
3972+
}
3973+
38513974
async fn incident_nis2_export(
38523975
Path(incident_id): Path<i64>,
38533976
State(state): State<AppState>,
@@ -5761,6 +5884,8 @@ async fn web_incident_detail(
57615884
.await
57625885
.unwrap_or_default();
57635886
let timeline_rows = incident_event_rows(&timeline_events);
5887+
let timeline_note_panel =
5888+
incident_timeline_note_panel(&context, incident.id, can_write);
57645889
let runbook_templates = store
57655890
.list_runbook_templates(context.tenant_id, 25)
57665891
.await
@@ -5837,6 +5962,7 @@ async fn web_incident_detail(
58375962
<thead><tr><th>Zeitpunkt</th><th>Ereignis</th><th>Zusammenfassung</th><th>Actor</th><th>Detail</th></tr></thead>
58385963
<tbody>{}</tbody>
58395964
</table>
5965+
{}
58405966
</article>
58415967
<article class="panel wide">
58425968
<h2>Evidence</h2>
@@ -5882,6 +6008,7 @@ async fn web_incident_detail(
58826008
html_escape(&incident.runbook_template),
58836009
runbook_template_rows,
58846010
timeline_rows,
6011+
timeline_note_panel,
58856012
evidence_rows,
58866013
evidence_upload_panel,
58876014
html_escape(&markdown_export_href),
@@ -5962,6 +6089,59 @@ async fn web_incident_detail_submit(
59626089
}
59636090
}
59646091

6092+
async fn web_incident_timeline_note_submit(
6093+
Path(incident_id): Path<i64>,
6094+
State(state): State<AppState>,
6095+
headers: HeaderMap,
6096+
Form(form): Form<WebIncidentTimelineNoteForm>,
6097+
) -> Response {
6098+
let auth_context = match authenticated_tenant_context(&state, &headers).await {
6099+
Ok(context) => context,
6100+
Err(_) => return web_missing_context("Incidents", "/incidents/").into_response(),
6101+
};
6102+
let context = WebContext {
6103+
tenant_id: auth_context.tenant_id,
6104+
user_id: auth_context.user_id,
6105+
user_email: auth_context.user_email.clone(),
6106+
};
6107+
if !auth_context.can_write() {
6108+
return web_error_page(
6109+
"Incidents",
6110+
"/incidents/",
6111+
&context,
6112+
"Diese Rust-Webroute benoetigt eine schreibende ISCY-Rolle.",
6113+
)
6114+
.into_response();
6115+
}
6116+
let payload = match incident_timeline_note_payload(form.summary, form.detail) {
6117+
Ok(payload) => payload,
6118+
Err(message) => {
6119+
return web_error_page("Incidents", "/incidents/", &context, &message).into_response()
6120+
}
6121+
};
6122+
let Some(store) = state.incident_store else {
6123+
return web_store_missing("Incidents", "/incidents/", &context, "Incident").into_response();
6124+
};
6125+
match store
6126+
.append_incident_event(
6127+
auth_context.tenant_id,
6128+
incident_id,
6129+
Some(auth_context.user_id),
6130+
payload,
6131+
)
6132+
.await
6133+
{
6134+
Ok(_) => Redirect::to(&web_path_with_context(
6135+
&format!("/incidents/{}", incident_id),
6136+
Some(&context),
6137+
))
6138+
.into_response(),
6139+
Err(err) => {
6140+
web_error_page("Incidents", "/incidents/", &context, &err.to_string()).into_response()
6141+
}
6142+
}
6143+
}
6144+
59656145
async fn web_incident_nis2_export(
59666146
Path(incident_id): Path<i64>,
59676147
State(state): State<AppState>,
@@ -7561,6 +7741,23 @@ fn web_incident_form_request(
75617741
})
75627742
}
75637743

7744+
fn incident_timeline_note_payload(
7745+
summary: Option<String>,
7746+
detail: String,
7747+
) -> Result<incident_store::IncidentEventWriteRequest, String> {
7748+
let detail = detail.trim().to_string();
7749+
if detail.is_empty() {
7750+
return Err("Timeline-Notiz darf nicht leer sein.".to_string());
7751+
}
7752+
let summary = summary
7753+
.map(|value| value.trim().to_string())
7754+
.filter(|value| !value.is_empty());
7755+
Ok(incident_store::IncidentEventWriteRequest::timeline_note(
7756+
summary.as_deref(),
7757+
&detail,
7758+
))
7759+
}
7760+
75647761
fn optional_form_text_for_write(value: Option<String>) -> Option<Option<String>> {
75657762
value
75667763
.map(|value| value.trim().to_string())
@@ -8243,6 +8440,28 @@ fn incident_event_rows(events: &[incident_store::IncidentEventSummary]) -> Strin
82438440
.join("")
82448441
}
82458442

8443+
fn incident_timeline_note_panel(context: &WebContext, incident_id: i64, can_write: bool) -> String {
8444+
if !can_write {
8445+
return "<p>Fuer Timeline-Notizen ist eine schreibende ISCY-Rolle notwendig.</p>"
8446+
.to_string();
8447+
}
8448+
let action = web_path_with_context(
8449+
&format!("/incidents/{incident_id}/timeline-notes"),
8450+
Some(context),
8451+
);
8452+
format!(
8453+
r#"
8454+
<form method="post" action="{}">
8455+
<h3>Timeline-Notiz</h3>
8456+
<label>Kurzfassung<input name="summary" type="text" maxlength="255"></label>
8457+
<label>Notiz<textarea name="detail" rows="4" required></textarea></label>
8458+
<button type="submit">Notiz speichern</button>
8459+
</form>
8460+
"#,
8461+
html_escape(&action),
8462+
)
8463+
}
8464+
82468465
fn incident_event_markdown_rows(events: &[incident_store::IncidentEventSummary]) -> String {
82478466
if events.is_empty() {
82488467
return "| - | - | - | - | - |".to_string();
@@ -11577,6 +11796,10 @@ pub fn app_router_with_state(state: AppState) -> Router {
1157711796
"/api/v1/incidents/{incident_id}",
1157811797
get(incident_detail).patch(incident_update),
1157911798
)
11799+
.route(
11800+
"/api/v1/incidents/{incident_id}/timeline-notes",
11801+
post(incident_timeline_note_create),
11802+
)
1158011803
.route(
1158111804
"/api/v1/incidents/{incident_id}/nis2-export",
1158211805
get(incident_nis2_export),
@@ -11641,6 +11864,10 @@ pub fn app_router_with_state(state: AppState) -> Router {
1164111864
"/incidents/{incident_id}",
1164211865
get(web_incident_detail).post(web_incident_detail_submit),
1164311866
)
11867+
.route(
11868+
"/incidents/{incident_id}/timeline-notes",
11869+
post(web_incident_timeline_note_submit),
11870+
)
1164411871
.route(
1164511872
"/incidents/{incident_id}/nis2-export",
1164611873
get(web_incident_nis2_export),

0 commit comments

Comments
 (0)