Files
breakpilot-compliance/ai-compliance-sdk/internal/api/handlers/iace_handler_hazards.go
T
Benjamin Admin e7f2f98da3 feat: IACE CE-Compliance Module — Normen, Risikobewertung, Production Lines
Major features:
- 215 norms library with section references + Beuth URLs (A/B1/B2/C norms)
- 173 hazard patterns with detail fields (scenario, trigger, harm, zone)
- Deterministic pattern matching: Component × Lifecycle × Pattern cross-product
- SIL/PL auto-calculation from S×E×P risk graph
- Risk assessment table with editable S/E/P dropdowns
- Production Line Dashboard with animated station flow (Running Dots)
- IACE process flow + norms coverage on start page
- Non-blocking cookie banner, ProcessFlow SSR fix
- 104 Playwright E2E tests passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 10:53:26 +02:00

486 lines
15 KiB
Go

package handlers
import (
"encoding/json"
"net/http"
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// ============================================================================
// Hazard Library & Controls Library
// ============================================================================
// ListHazardLibrary handles GET /hazard-library
// Returns built-in hazard library entries merged with any custom DB entries,
// optionally filtered by ?category and ?componentType.
func (h *IACEHandler) ListHazardLibrary(c *gin.Context) {
category := c.Query("category")
componentType := c.Query("componentType")
// Start with built-in templates from Go code
builtinEntries := iace.GetBuiltinHazardLibrary()
// Apply filters to built-in entries
var entries []iace.HazardLibraryEntry
for _, entry := range builtinEntries {
if category != "" && entry.Category != category {
continue
}
if componentType != "" && !containsString(entry.ApplicableComponentTypes, componentType) {
continue
}
entries = append(entries, entry)
}
// Merge with custom DB entries (tenant-specific)
dbEntries, err := h.store.ListHazardLibrary(c.Request.Context(), category, componentType)
if err == nil && len(dbEntries) > 0 {
// Add DB entries that are not built-in (avoid duplicates)
builtinIDs := make(map[string]bool)
for _, e := range entries {
builtinIDs[e.ID.String()] = true
}
for _, dbEntry := range dbEntries {
if !builtinIDs[dbEntry.ID.String()] {
entries = append(entries, dbEntry)
}
}
}
if entries == nil {
entries = []iace.HazardLibraryEntry{}
}
c.JSON(http.StatusOK, gin.H{
"hazard_library": entries,
"total": len(entries),
})
}
// ListControlsLibrary handles GET /controls-library
// Returns the built-in controls library, optionally filtered by ?domain and ?category.
func (h *IACEHandler) ListControlsLibrary(c *gin.Context) {
domain := c.Query("domain")
category := c.Query("category")
all := iace.GetControlsLibrary()
var filtered []iace.ControlLibraryEntry
for _, entry := range all {
if domain != "" && entry.Domain != domain {
continue
}
if category != "" && !containsString(entry.MapsToHazardCategories, category) {
continue
}
filtered = append(filtered, entry)
}
if filtered == nil {
filtered = []iace.ControlLibraryEntry{}
}
c.JSON(http.StatusOK, gin.H{
"controls": filtered,
"total": len(filtered),
})
}
// ============================================================================
// Hazard CRUD
// ============================================================================
// CreateHazard handles POST /projects/:id/hazards
// Creates a new hazard within a project.
func (h *IACEHandler) CreateHazard(c *gin.Context) {
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
return
}
var req iace.CreateHazardRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Override project ID from URL path
req.ProjectID = projectID
hazard, err := h.store.CreateHazard(c.Request.Context(), req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Audit trail
userID := rbac.GetUserID(c)
newVals, _ := json.Marshal(hazard)
h.store.AddAuditEntry(
c.Request.Context(), projectID, "hazard", hazard.ID,
iace.AuditActionCreate, userID.String(), nil, newVals,
)
c.JSON(http.StatusCreated, gin.H{"hazard": hazard})
}
// ListHazards handles GET /projects/:id/hazards
// Lists all hazards for a project.
func (h *IACEHandler) ListHazards(c *gin.Context) {
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
return
}
hazards, err := h.store.ListHazards(c.Request.Context(), projectID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if hazards == nil {
hazards = []iace.Hazard{}
}
// Enrich hazards with latest risk assessment
type enrichedHazard struct {
iace.Hazard
RiskAssessment interface{} `json:"risk_assessment"`
}
enriched := make([]enrichedHazard, len(hazards))
for i, hz := range hazards {
enriched[i] = enrichedHazard{Hazard: hz}
// Get latest assessment for this hazard
assessments, err := h.store.ListAssessments(c.Request.Context(), hz.ID)
if err == nil && len(assessments) > 0 {
enriched[i].RiskAssessment = assessments[len(assessments)-1]
}
}
c.JSON(http.StatusOK, gin.H{
"hazards": enriched,
"total": len(enriched),
})
}
// UpdateHazard handles PUT /projects/:id/hazards/:hid
// Updates a hazard with the provided fields.
func (h *IACEHandler) UpdateHazard(c *gin.Context) {
_, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
return
}
hazardID, err := uuid.Parse(c.Param("hid"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid hazard ID"})
return
}
var updates map[string]interface{}
if err := c.ShouldBindJSON(&updates); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
hazard, err := h.store.UpdateHazard(c.Request.Context(), hazardID, updates)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if hazard == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "hazard not found"})
return
}
c.JSON(http.StatusOK, gin.H{"hazard": hazard})
}
// SuggestHazards handles POST /projects/:id/hazards/suggest
// Returns hazard library matches based on the project's components.
// TODO: Enhance with LLM-based suggestions for more intelligent matching.
func (h *IACEHandler) SuggestHazards(c *gin.Context) {
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
return
}
components, err := h.store.ListComponents(c.Request.Context(), projectID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Collect unique component types from the project
componentTypes := make(map[string]bool)
for _, comp := range components {
componentTypes[string(comp.ComponentType)] = true
}
// Match built-in hazard templates against project component types
var suggestions []iace.HazardLibraryEntry
seen := make(map[uuid.UUID]bool)
builtinEntries := iace.GetBuiltinHazardLibrary()
for _, entry := range builtinEntries {
for _, applicableType := range entry.ApplicableComponentTypes {
if componentTypes[applicableType] && !seen[entry.ID] {
seen[entry.ID] = true
suggestions = append(suggestions, entry)
break
}
}
}
// Also check DB for custom tenant-specific hazard templates
for compType := range componentTypes {
dbEntries, err := h.store.ListHazardLibrary(c.Request.Context(), "", compType)
if err != nil {
continue
}
for _, entry := range dbEntries {
if !seen[entry.ID] {
seen[entry.ID] = true
suggestions = append(suggestions, entry)
}
}
}
if suggestions == nil {
suggestions = []iace.HazardLibraryEntry{}
}
c.JSON(http.StatusOK, gin.H{
"suggestions": suggestions,
"total": len(suggestions),
"component_types": componentTypeKeys(componentTypes),
"_note": "TODO: LLM-based suggestion ranking not yet implemented",
})
}
// ============================================================================
// Risk Assessment
// ============================================================================
// AssessRisk handles POST /projects/:id/hazards/:hid/assess
// Performs a quantitative risk assessment for a hazard using the IACE risk engine.
func (h *IACEHandler) AssessRisk(c *gin.Context) {
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
return
}
hazardID, err := uuid.Parse(c.Param("hid"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid hazard ID"})
return
}
// Verify hazard exists
hazard, err := h.store.GetHazard(c.Request.Context(), hazardID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if hazard == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "hazard not found"})
return
}
var req iace.AssessRiskRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Override hazard ID from URL path
req.HazardID = hazardID
userID := rbac.GetUserID(c)
// Calculate risk using the engine
inherentRisk := h.engine.CalculateInherentRisk(req.Severity, req.Exposure, req.Probability, req.Avoidance)
controlEff := h.engine.CalculateControlEffectiveness(req.ControlMaturity, req.ControlCoverage, req.TestEvidenceStrength)
residualRisk := h.engine.CalculateResidualRisk(req.Severity, req.Exposure, req.Probability, controlEff)
// ISO 12100 mode: use ISO thresholds when avoidance is set
var riskLevel iace.RiskLevel
if req.Avoidance >= 1 {
riskLevel = h.engine.DetermineRiskLevelISO(inherentRisk)
} else {
riskLevel = h.engine.DetermineRiskLevel(residualRisk)
}
acceptable, acceptanceReason := h.engine.IsAcceptable(residualRisk, false, req.AcceptanceJustification != "")
// Determine version by checking existing assessments
existingAssessments, _ := h.store.ListAssessments(c.Request.Context(), hazardID)
version := len(existingAssessments) + 1
assessment := &iace.RiskAssessment{
HazardID: hazardID,
Version: version,
AssessmentType: iace.AssessmentTypeInitial,
Severity: req.Severity,
Exposure: req.Exposure,
Probability: req.Probability,
Avoidance: req.Avoidance,
InherentRisk: inherentRisk,
ControlMaturity: req.ControlMaturity,
ControlCoverage: req.ControlCoverage,
TestEvidenceStrength: req.TestEvidenceStrength,
CEff: controlEff,
ResidualRisk: residualRisk,
RiskLevel: riskLevel,
IsAcceptable: acceptable,
AcceptanceJustification: req.AcceptanceJustification,
AssessedBy: userID,
}
if err := h.store.CreateRiskAssessment(c.Request.Context(), assessment); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Update hazard status
h.store.UpdateHazard(c.Request.Context(), hazardID, map[string]interface{}{
"status": string(iace.HazardStatusAssessed),
})
// Audit trail
newVals, _ := json.Marshal(assessment)
h.store.AddAuditEntry(
c.Request.Context(), projectID, "risk_assessment", assessment.ID,
iace.AuditActionCreate, userID.String(), nil, newVals,
)
c.JSON(http.StatusCreated, gin.H{
"assessment": assessment,
"acceptable": acceptable,
"acceptance_reason": acceptanceReason,
})
}
// GetRiskSummary handles GET /projects/:id/risk-summary
// Returns an aggregated risk overview for a project.
func (h *IACEHandler) GetRiskSummary(c *gin.Context) {
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
return
}
summary, err := h.store.GetRiskSummary(c.Request.Context(), projectID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"risk_summary": summary})
}
// ReassessRisk handles POST /projects/:id/hazards/:hid/reassess
// Creates a post-mitigation risk reassessment for a hazard.
func (h *IACEHandler) ReassessRisk(c *gin.Context) {
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
return
}
hazardID, err := uuid.Parse(c.Param("hid"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid hazard ID"})
return
}
// Verify hazard exists
hazard, err := h.store.GetHazard(c.Request.Context(), hazardID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if hazard == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "hazard not found"})
return
}
var req iace.AssessRiskRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := rbac.GetUserID(c)
// Calculate risk using the engine
inherentRisk := h.engine.CalculateInherentRisk(req.Severity, req.Exposure, req.Probability, req.Avoidance)
controlEff := h.engine.CalculateControlEffectiveness(req.ControlMaturity, req.ControlCoverage, req.TestEvidenceStrength)
residualRisk := h.engine.CalculateResidualRisk(req.Severity, req.Exposure, req.Probability, controlEff)
riskLevel := h.engine.DetermineRiskLevel(residualRisk)
// For reassessment, check if all reduction steps have been applied
mitigations, _ := h.store.ListMitigations(c.Request.Context(), hazardID)
allReductionStepsApplied := len(mitigations) > 0
for _, m := range mitigations {
if m.Status != iace.MitigationStatusVerified {
allReductionStepsApplied = false
break
}
}
acceptable, acceptanceReason := h.engine.IsAcceptable(residualRisk, allReductionStepsApplied, req.AcceptanceJustification != "")
// Determine version
existingAssessments, _ := h.store.ListAssessments(c.Request.Context(), hazardID)
version := len(existingAssessments) + 1
assessment := &iace.RiskAssessment{
HazardID: hazardID,
Version: version,
AssessmentType: iace.AssessmentTypePostMitigation,
Severity: req.Severity,
Exposure: req.Exposure,
Probability: req.Probability,
Avoidance: req.Avoidance,
InherentRisk: inherentRisk,
ControlMaturity: req.ControlMaturity,
ControlCoverage: req.ControlCoverage,
TestEvidenceStrength: req.TestEvidenceStrength,
CEff: controlEff,
ResidualRisk: residualRisk,
RiskLevel: riskLevel,
IsAcceptable: acceptable,
AcceptanceJustification: req.AcceptanceJustification,
AssessedBy: userID,
}
if err := h.store.CreateRiskAssessment(c.Request.Context(), assessment); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Audit trail
newVals, _ := json.Marshal(assessment)
h.store.AddAuditEntry(
c.Request.Context(), projectID, "risk_assessment", assessment.ID,
iace.AuditActionCreate, userID.String(), nil, newVals,
)
c.JSON(http.StatusCreated, gin.H{
"assessment": assessment,
"acceptable": acceptable,
"acceptance_reason": acceptanceReason,
"all_reduction_steps_applied": allReductionStepsApplied,
})
}