This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/ai-compliance-sdk/internal/api/handlers/incidents_handlers.go
BreakPilot Dev 557305db5d
Some checks failed
ci/woodpecker/push/integration Pipeline failed
ci/woodpecker/push/main Pipeline failed
CI/CD Pipeline / Go Tests (push) Has been cancelled
CI/CD Pipeline / Python Tests (push) Has been cancelled
CI/CD Pipeline / Website Tests (push) Has been cancelled
CI/CD Pipeline / Linting (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Docker Build & Push (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / CI Summary (push) Has been cancelled
Security Scanning / Secret Scanning (push) Has been cancelled
Security Scanning / Dependency Vulnerability Scan (push) Has been cancelled
Security Scanning / Go Security Scan (push) Has been cancelled
Security Scanning / Python Security Scan (push) Has been cancelled
Security Scanning / Node.js Security Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
Tests / Go Tests (push) Has been cancelled
Tests / Python Tests (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / Go Lint (push) Has been cancelled
Tests / Python Lint (push) Has been cancelled
Tests / Security Scan (push) Has been cancelled
Tests / All Checks Passed (push) Has been cancelled
feat: Add Academy, Whistleblower, Incidents SDK modules, pitch-deck, blog and CI/CD config
- Academy, Whistleblower, Incidents frontend pages with API proxies and types
- Vendor compliance API proxy route
- Go backend handlers and models for all new SDK modules
- Investor pitch-deck app with interactive slides
- Blog section with DSGVO, AI Act, NIS2, glossary articles
- MkDocs documentation site
- CI/CD pipelines (Woodpecker, GitHub Actions), security scanning config
- Planning and implementation documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 21:12:16 +01:00

669 lines
20 KiB
Go

package handlers
import (
"fmt"
"net/http"
"time"
"github.com/breakpilot/ai-compliance-sdk/internal/incidents"
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// IncidentHandlers handles incident/breach management HTTP requests
type IncidentHandlers struct {
store *incidents.Store
}
// NewIncidentHandlers creates new incident handlers
func NewIncidentHandlers(store *incidents.Store) *IncidentHandlers {
return &IncidentHandlers{store: store}
}
// ============================================================================
// Incident CRUD
// ============================================================================
// CreateIncident creates a new incident
// POST /sdk/v1/incidents
func (h *IncidentHandlers) CreateIncident(c *gin.Context) {
var req incidents.CreateIncidentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantID := rbac.GetTenantID(c)
userID := rbac.GetUserID(c)
detectedAt := time.Now().UTC()
if req.DetectedAt != nil {
detectedAt = *req.DetectedAt
}
// Auto-calculate 72h deadline per DSGVO Art. 33
deadline := incidents.Calculate72hDeadline(detectedAt)
incident := &incidents.Incident{
TenantID: tenantID,
Title: req.Title,
Description: req.Description,
Category: req.Category,
Status: incidents.IncidentStatusDetected,
Severity: req.Severity,
DetectedAt: detectedAt,
ReportedBy: userID,
AffectedDataCategories: req.AffectedDataCategories,
AffectedDataSubjectCount: req.AffectedDataSubjectCount,
AffectedSystems: req.AffectedSystems,
AuthorityNotification: &incidents.AuthorityNotification{
Status: incidents.NotificationStatusPending,
Deadline: deadline,
},
DataSubjectNotification: &incidents.DataSubjectNotification{
Required: false,
Status: incidents.NotificationStatusNotRequired,
},
Timeline: []incidents.TimelineEntry{
{
Timestamp: time.Now().UTC(),
Action: "incident_created",
UserID: userID,
Details: "Incident detected and reported",
},
},
}
if err := h.store.CreateIncident(c.Request.Context(), incident); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"incident": incident,
"authority_deadline": deadline,
"hours_until_deadline": time.Until(deadline).Hours(),
})
}
// GetIncident retrieves an incident by ID
// GET /sdk/v1/incidents/:id
func (h *IncidentHandlers) GetIncident(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"})
return
}
incident, err := h.store.GetIncident(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if incident == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "incident not found"})
return
}
// Get measures
measures, _ := h.store.ListMeasures(c.Request.Context(), id)
// Calculate deadline info if authority notification exists
var deadlineInfo gin.H
if incident.AuthorityNotification != nil {
hoursRemaining := time.Until(incident.AuthorityNotification.Deadline).Hours()
deadlineInfo = gin.H{
"deadline": incident.AuthorityNotification.Deadline,
"hours_remaining": hoursRemaining,
"overdue": hoursRemaining < 0,
}
}
c.JSON(http.StatusOK, gin.H{
"incident": incident,
"measures": measures,
"deadline_info": deadlineInfo,
})
}
// ListIncidents lists incidents for a tenant
// GET /sdk/v1/incidents
func (h *IncidentHandlers) ListIncidents(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
filters := &incidents.IncidentFilters{
Limit: 50,
}
if status := c.Query("status"); status != "" {
filters.Status = incidents.IncidentStatus(status)
}
if severity := c.Query("severity"); severity != "" {
filters.Severity = incidents.IncidentSeverity(severity)
}
if category := c.Query("category"); category != "" {
filters.Category = incidents.IncidentCategory(category)
}
incidentList, total, err := h.store.ListIncidents(c.Request.Context(), tenantID, filters)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, incidents.IncidentListResponse{
Incidents: incidentList,
Total: total,
})
}
// UpdateIncident updates an incident
// PUT /sdk/v1/incidents/:id
func (h *IncidentHandlers) UpdateIncident(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"})
return
}
incident, err := h.store.GetIncident(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if incident == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "incident not found"})
return
}
var req incidents.UpdateIncidentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Title != "" {
incident.Title = req.Title
}
if req.Description != "" {
incident.Description = req.Description
}
if req.Category != "" {
incident.Category = req.Category
}
if req.Status != "" {
incident.Status = req.Status
}
if req.Severity != "" {
incident.Severity = req.Severity
}
if req.AffectedDataCategories != nil {
incident.AffectedDataCategories = req.AffectedDataCategories
}
if req.AffectedDataSubjectCount != nil {
incident.AffectedDataSubjectCount = *req.AffectedDataSubjectCount
}
if req.AffectedSystems != nil {
incident.AffectedSystems = req.AffectedSystems
}
if err := h.store.UpdateIncident(c.Request.Context(), incident); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"incident": incident})
}
// DeleteIncident deletes an incident
// DELETE /sdk/v1/incidents/:id
func (h *IncidentHandlers) DeleteIncident(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"})
return
}
if err := h.store.DeleteIncident(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "incident deleted"})
}
// ============================================================================
// Risk Assessment
// ============================================================================
// AssessRisk performs a risk assessment for an incident
// POST /sdk/v1/incidents/:id/risk-assessment
func (h *IncidentHandlers) AssessRisk(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"})
return
}
incident, err := h.store.GetIncident(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if incident == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "incident not found"})
return
}
var req incidents.RiskAssessmentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := rbac.GetUserID(c)
// Auto-calculate risk level
riskLevel := incidents.CalculateRiskLevel(req.Likelihood, req.Impact)
notificationRequired := incidents.IsNotificationRequired(riskLevel)
assessment := &incidents.RiskAssessment{
Likelihood: req.Likelihood,
Impact: req.Impact,
RiskLevel: riskLevel,
AssessedAt: time.Now().UTC(),
AssessedBy: userID,
Notes: req.Notes,
}
if err := h.store.UpdateRiskAssessment(c.Request.Context(), id, assessment); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Update status to assessment
incident.Status = incidents.IncidentStatusAssessment
h.store.UpdateIncident(c.Request.Context(), incident)
// Add timeline entry
h.store.AddTimelineEntry(c.Request.Context(), id, incidents.TimelineEntry{
Timestamp: time.Now().UTC(),
Action: "risk_assessed",
UserID: userID,
Details: fmt.Sprintf("Risk level: %s (likelihood=%d, impact=%d)", riskLevel, req.Likelihood, req.Impact),
})
// If notification is required, update authority notification status
if notificationRequired && incident.AuthorityNotification != nil {
incident.AuthorityNotification.Status = incidents.NotificationStatusPending
h.store.UpdateAuthorityNotification(c.Request.Context(), id, incident.AuthorityNotification)
// Update status to notification_required
incident.Status = incidents.IncidentStatusNotificationRequired
h.store.UpdateIncident(c.Request.Context(), incident)
}
c.JSON(http.StatusOK, gin.H{
"risk_assessment": assessment,
"notification_required": notificationRequired,
"incident_status": incident.Status,
})
}
// ============================================================================
// Authority Notification (Art. 33)
// ============================================================================
// SubmitAuthorityNotification submits the supervisory authority notification
// POST /sdk/v1/incidents/:id/authority-notification
func (h *IncidentHandlers) SubmitAuthorityNotification(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"})
return
}
incident, err := h.store.GetIncident(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if incident == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "incident not found"})
return
}
var req incidents.SubmitAuthorityNotificationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := rbac.GetUserID(c)
now := time.Now().UTC()
// Preserve existing deadline
deadline := incidents.Calculate72hDeadline(incident.DetectedAt)
if incident.AuthorityNotification != nil {
deadline = incident.AuthorityNotification.Deadline
}
notification := &incidents.AuthorityNotification{
Status: incidents.NotificationStatusSent,
Deadline: deadline,
SubmittedAt: &now,
AuthorityName: req.AuthorityName,
ReferenceNumber: req.ReferenceNumber,
ContactPerson: req.ContactPerson,
Notes: req.Notes,
}
if err := h.store.UpdateAuthorityNotification(c.Request.Context(), id, notification); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Update incident status
incident.Status = incidents.IncidentStatusNotificationSent
h.store.UpdateIncident(c.Request.Context(), incident)
// Add timeline entry
h.store.AddTimelineEntry(c.Request.Context(), id, incidents.TimelineEntry{
Timestamp: now,
Action: "authority_notified",
UserID: userID,
Details: "Authority notification submitted to " + req.AuthorityName,
})
c.JSON(http.StatusOK, gin.H{
"authority_notification": notification,
"submitted_within_72h": now.Before(deadline),
})
}
// ============================================================================
// Data Subject Notification (Art. 34)
// ============================================================================
// NotifyDataSubjects submits the data subject notification
// POST /sdk/v1/incidents/:id/data-subject-notification
func (h *IncidentHandlers) NotifyDataSubjects(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"})
return
}
incident, err := h.store.GetIncident(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if incident == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "incident not found"})
return
}
var req incidents.NotifyDataSubjectsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := rbac.GetUserID(c)
now := time.Now().UTC()
affectedCount := req.AffectedCount
if affectedCount == 0 {
affectedCount = incident.AffectedDataSubjectCount
}
notification := &incidents.DataSubjectNotification{
Required: true,
Status: incidents.NotificationStatusSent,
SentAt: &now,
AffectedCount: affectedCount,
NotificationText: req.NotificationText,
Channel: req.Channel,
}
if err := h.store.UpdateDataSubjectNotification(c.Request.Context(), id, notification); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Add timeline entry
h.store.AddTimelineEntry(c.Request.Context(), id, incidents.TimelineEntry{
Timestamp: now,
Action: "data_subjects_notified",
UserID: userID,
Details: "Data subjects notified via " + req.Channel + " (" + fmt.Sprintf("%d", affectedCount) + " affected)",
})
c.JSON(http.StatusOK, gin.H{
"data_subject_notification": notification,
})
}
// ============================================================================
// Measures
// ============================================================================
// AddMeasure adds a corrective measure to an incident
// POST /sdk/v1/incidents/:id/measures
func (h *IncidentHandlers) AddMeasure(c *gin.Context) {
incidentID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"})
return
}
// Verify incident exists
incident, err := h.store.GetIncident(c.Request.Context(), incidentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if incident == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "incident not found"})
return
}
var req incidents.AddMeasureRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := rbac.GetUserID(c)
measure := &incidents.IncidentMeasure{
IncidentID: incidentID,
Title: req.Title,
Description: req.Description,
MeasureType: req.MeasureType,
Status: incidents.MeasureStatusPlanned,
Responsible: req.Responsible,
DueDate: req.DueDate,
}
if err := h.store.AddMeasure(c.Request.Context(), measure); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Add timeline entry
h.store.AddTimelineEntry(c.Request.Context(), incidentID, incidents.TimelineEntry{
Timestamp: time.Now().UTC(),
Action: "measure_added",
UserID: userID,
Details: "Measure added: " + req.Title + " (" + string(req.MeasureType) + ")",
})
c.JSON(http.StatusCreated, gin.H{"measure": measure})
}
// UpdateMeasure updates a measure
// PUT /sdk/v1/incidents/measures/:measureId
func (h *IncidentHandlers) UpdateMeasure(c *gin.Context) {
measureID, err := uuid.Parse(c.Param("measureId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid measure ID"})
return
}
var req struct {
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
MeasureType incidents.MeasureType `json:"measure_type,omitempty"`
Status incidents.MeasureStatus `json:"status,omitempty"`
Responsible string `json:"responsible,omitempty"`
DueDate *time.Time `json:"due_date,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
measure := &incidents.IncidentMeasure{
ID: measureID,
Title: req.Title,
Description: req.Description,
MeasureType: req.MeasureType,
Status: req.Status,
Responsible: req.Responsible,
DueDate: req.DueDate,
}
if err := h.store.UpdateMeasure(c.Request.Context(), measure); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"measure": measure})
}
// CompleteMeasure marks a measure as completed
// POST /sdk/v1/incidents/measures/:measureId/complete
func (h *IncidentHandlers) CompleteMeasure(c *gin.Context) {
measureID, err := uuid.Parse(c.Param("measureId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid measure ID"})
return
}
if err := h.store.CompleteMeasure(c.Request.Context(), measureID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "measure completed"})
}
// ============================================================================
// Timeline
// ============================================================================
// AddTimelineEntry adds a timeline entry to an incident
// POST /sdk/v1/incidents/:id/timeline
func (h *IncidentHandlers) AddTimelineEntry(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"})
return
}
var req incidents.AddTimelineEntryRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := rbac.GetUserID(c)
entry := incidents.TimelineEntry{
Timestamp: time.Now().UTC(),
Action: req.Action,
UserID: userID,
Details: req.Details,
}
if err := h.store.AddTimelineEntry(c.Request.Context(), id, entry); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"timeline_entry": entry})
}
// ============================================================================
// Close Incident
// ============================================================================
// CloseIncident closes an incident with root cause analysis
// POST /sdk/v1/incidents/:id/close
func (h *IncidentHandlers) CloseIncident(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"})
return
}
incident, err := h.store.GetIncident(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if incident == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "incident not found"})
return
}
var req incidents.CloseIncidentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := rbac.GetUserID(c)
if err := h.store.CloseIncident(c.Request.Context(), id, req.RootCause, req.LessonsLearned); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Add timeline entry
h.store.AddTimelineEntry(c.Request.Context(), id, incidents.TimelineEntry{
Timestamp: time.Now().UTC(),
Action: "incident_closed",
UserID: userID,
Details: "Incident closed. Root cause: " + req.RootCause,
})
c.JSON(http.StatusOK, gin.H{
"message": "incident closed",
"root_cause": req.RootCause,
"lessons_learned": req.LessonsLearned,
})
}
// ============================================================================
// Statistics
// ============================================================================
// GetStatistics returns aggregated incident statistics
// GET /sdk/v1/incidents/statistics
func (h *IncidentHandlers) GetStatistics(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
stats, err := h.store.GetStatistics(c.Request.Context(), tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}