diff --git a/ai-compliance-sdk/internal/api/handlers/obligations_export_handlers.go b/ai-compliance-sdk/internal/api/handlers/obligations_export_handlers.go new file mode 100644 index 0000000..1ebfed8 --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/obligations_export_handlers.go @@ -0,0 +1,216 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/breakpilot/ai-compliance-sdk/internal/rbac" + "github.com/breakpilot/ai-compliance-sdk/internal/ucca" +) + +// ExportMemo exports the obligations overview as a C-Level memo +// POST /sdk/v1/ucca/obligations/export/memo +func (h *ObligationsHandlers) ExportMemo(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + + var req ucca.ExportMemoRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + if h.store == nil { + c.JSON(http.StatusNotImplemented, gin.H{"error": "Persistence not configured"}) + return + } + + id, err := uuid.Parse(req.AssessmentID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assessment ID"}) + return + } + + assessment, err := h.store.GetAssessment(c.Request.Context(), tenantID, id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Assessment not found"}) + return + } + + exporter := ucca.NewPDFExporter(req.Language) + + var response *ucca.ExportMemoResponse + switch req.Format { + case "pdf": + response, err = exporter.ExportManagementMemo(assessment.Overview) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate PDF", "details": err.Error()}) + return + } + case "markdown", "": + response, err = exporter.ExportMarkdown(assessment.Overview) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate Markdown", "details": err.Error()}) + return + } + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid format. Use 'markdown' or 'pdf'"}) + return + } + + c.JSON(http.StatusOK, response) +} + +// ExportMemoFromOverview exports an overview directly (without persistence) +// POST /sdk/v1/ucca/obligations/export/direct +func (h *ObligationsHandlers) ExportMemoFromOverview(c *gin.Context) { + var req struct { + Overview *ucca.ManagementObligationsOverview `json:"overview"` + Format string `json:"format"` // "markdown" or "pdf" + Language string `json:"language,omitempty"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + if req.Overview == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Overview is required"}) + return + } + + exporter := ucca.NewPDFExporter(req.Language) + + var response *ucca.ExportMemoResponse + var err error + switch req.Format { + case "pdf": + response, err = exporter.ExportManagementMemo(req.Overview) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate PDF", "details": err.Error()}) + return + } + case "markdown", "": + response, err = exporter.ExportMarkdown(req.Overview) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate Markdown", "details": err.Error()}) + return + } + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid format. Use 'markdown' or 'pdf'"}) + return + } + + c.JSON(http.StatusOK, response) +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +func generateMemoMarkdown(overview *ucca.ManagementObligationsOverview) string { + content := "# Pflichten-Übersicht für die Geschäftsführung\n\n" + content += "**Datum:** " + overview.AssessmentDate.Format("02.01.2006") + "\n" + if overview.OrganizationName != "" { + content += "**Organisation:** " + overview.OrganizationName + "\n" + } + content += "\n---\n\n" + + content += "## Executive Summary\n\n" + content += "| Kennzahl | Wert |\n" + content += "|----------|------|\n" + content += "| Anwendbare Regulierungen | " + itoa(overview.ExecutiveSummary.TotalRegulations) + " |\n" + content += "| Gesamtzahl Pflichten | " + itoa(overview.ExecutiveSummary.TotalObligations) + " |\n" + content += "| Kritische Pflichten | " + itoa(overview.ExecutiveSummary.CriticalObligations) + " |\n" + content += "| Überfällige Pflichten | " + itoa(overview.ExecutiveSummary.OverdueObligations) + " |\n" + content += "| Anstehende Fristen (30 Tage) | " + itoa(overview.ExecutiveSummary.UpcomingDeadlines) + " |\n" + content += "\n" + + if len(overview.ExecutiveSummary.KeyRisks) > 0 { + content += "### Hauptrisiken\n\n" + for _, risk := range overview.ExecutiveSummary.KeyRisks { + content += "- ⚠️ " + risk + "\n" + } + content += "\n" + } + + if len(overview.ExecutiveSummary.RecommendedActions) > 0 { + content += "### Empfohlene Maßnahmen\n\n" + for i, action := range overview.ExecutiveSummary.RecommendedActions { + content += itoa(i+1) + ". " + action + "\n" + } + content += "\n" + } + + content += "## Anwendbare Regulierungen\n\n" + for _, reg := range overview.ApplicableRegulations { + content += "### " + reg.Name + "\n\n" + content += "- **Klassifizierung:** " + reg.Classification + "\n" + content += "- **Begründung:** " + reg.Reason + "\n" + content += "- **Anzahl Pflichten:** " + itoa(reg.ObligationCount) + "\n" + content += "\n" + } + + content += "## Sanktionsrisiken\n\n" + content += overview.SanctionsSummary.Summary + "\n\n" + if overview.SanctionsSummary.MaxFinancialRisk != "" { + content += "- **Maximales Bußgeld:** " + overview.SanctionsSummary.MaxFinancialRisk + "\n" + } + if overview.SanctionsSummary.PersonalLiabilityRisk { + content += "- **Persönliche Haftung:** Ja ⚠️\n" + } + content += "\n" + + content += "## Kritische Pflichten\n\n" + for _, obl := range overview.Obligations { + if obl.Priority == ucca.PriorityCritical { + content += "### " + obl.ID + ": " + obl.Title + "\n\n" + content += obl.Description + "\n\n" + content += "- **Verantwortlich:** " + string(obl.Responsible) + "\n" + if obl.Deadline != nil { + if obl.Deadline.Date != nil { + content += "- **Frist:** " + obl.Deadline.Date.Format("02.01.2006") + "\n" + } else if obl.Deadline.Duration != "" { + content += "- **Frist:** " + obl.Deadline.Duration + "\n" + } + } + if obl.Sanctions != nil && obl.Sanctions.MaxFine != "" { + content += "- **Sanktion:** " + obl.Sanctions.MaxFine + "\n" + } + content += "\n" + } + } + + if len(overview.IncidentDeadlines) > 0 { + content += "## Meldepflichten bei Sicherheitsvorfällen\n\n" + content += "| Phase | Frist | Empfänger |\n" + content += "|-------|-------|-----------|\n" + for _, deadline := range overview.IncidentDeadlines { + content += "| " + deadline.Phase + " | " + deadline.Deadline + " | " + deadline.Recipient + " |\n" + } + content += "\n" + } + + content += "---\n\n" + content += "*Dieses Dokument wurde automatisch generiert und ersetzt keine Rechtsberatung.*\n" + + return content +} + +func isEUCountry(country string) bool { + euCountries := map[string]bool{ + "DE": true, "AT": true, "BE": true, "BG": true, "HR": true, "CY": true, + "CZ": true, "DK": true, "EE": true, "FI": true, "FR": true, "GR": true, + "HU": true, "IE": true, "IT": true, "LV": true, "LT": true, "LU": true, + "MT": true, "NL": true, "PL": true, "PT": true, "RO": true, "SK": true, + "SI": true, "ES": true, "SE": true, + } + return euCountries[country] +} + +func itoa(i int) string { + return strconv.Itoa(i) +} diff --git a/ai-compliance-sdk/internal/api/handlers/obligations_handlers.go b/ai-compliance-sdk/internal/api/handlers/obligations_handlers.go index ca6e20c..7622c40 100644 --- a/ai-compliance-sdk/internal/api/handlers/obligations_handlers.go +++ b/ai-compliance-sdk/internal/api/handlers/obligations_handlers.go @@ -17,7 +17,6 @@ package handlers import ( "fmt" "net/http" - "strconv" "time" "github.com/gin-gonic/gin" @@ -29,10 +28,10 @@ import ( // ObligationsHandlers handles API requests for the generic obligations framework type ObligationsHandlers struct { - registry *ucca.ObligationsRegistry - store *ucca.ObligationsStore // Optional: for persisting assessments - tomIndex *ucca.TOMControlIndex - tomMapper *ucca.TOMObligationMapper + registry *ucca.ObligationsRegistry + store *ucca.ObligationsStore // Optional: for persisting assessments + tomIndex *ucca.TOMControlIndex + tomMapper *ucca.TOMObligationMapper gapAnalyzer *ucca.TOMGapAnalyzer } @@ -64,10 +63,8 @@ func (h *ObligationsHandlers) initTOM() { } h.tomIndex = tomIndex - // Try to load v2 TOM mapping mapping, err := ucca.LoadV2TOMMapping() if err != nil { - // Build mapping from v2 regulation files regs, err2 := ucca.LoadAllV2Regulations() if err2 == nil { var allObligations []ucca.V2Obligation @@ -89,30 +86,23 @@ func (h *ObligationsHandlers) initTOM() { func (h *ObligationsHandlers) RegisterRoutes(r *gin.RouterGroup) { obligations := r.Group("/obligations") { - // Assessment endpoints obligations.POST("/assess", h.AssessObligations) obligations.GET("/:assessmentId", h.GetAssessment) - // Grouping/filtering endpoints obligations.GET("/:assessmentId/by-regulation", h.GetByRegulation) obligations.GET("/:assessmentId/by-deadline", h.GetByDeadline) obligations.GET("/:assessmentId/by-responsible", h.GetByResponsible) - // Export endpoints obligations.POST("/export/memo", h.ExportMemo) obligations.POST("/export/direct", h.ExportMemoFromOverview) - // Metadata endpoints obligations.GET("/regulations", h.ListRegulations) obligations.GET("/regulations/:regulationId/decision-tree", h.GetDecisionTree) - // Quick check endpoint (no persistence) obligations.POST("/quick-check", h.QuickCheck) - // v2: Scope-based assessment obligations.POST("/assess-from-scope", h.AssessFromScope) - // v2: TOM Control endpoints obligations.GET("/tom-controls/for-obligation/:obligationId", h.GetTOMControlsForObligation) obligations.POST("/gap-analysis", h.GapAnalysis) obligations.GET("/tom-controls/:controlId/obligations", h.GetObligationsForControl) @@ -139,10 +129,8 @@ func (h *ObligationsHandlers) AssessObligations(c *gin.Context) { return } - // Evaluate all regulations against the facts overview := h.registry.EvaluateAll(tenantID, req.Facts, req.OrganizationName) - // Generate warnings if any var warnings []string if len(overview.ApplicableRegulations) == 0 { warnings = append(warnings, "Keine der konfigurierten Regulierungen scheint anwendbar zu sein. Bitte prüfen Sie die eingegebenen Daten.") @@ -151,7 +139,6 @@ func (h *ObligationsHandlers) AssessObligations(c *gin.Context) { warnings = append(warnings, "Es gibt überfällige Pflichten, die sofortige Aufmerksamkeit erfordern.") } - // Optionally persist the assessment if h.store != nil { assessment := &ucca.ObligationsAssessment{ ID: overview.ID, @@ -165,7 +152,6 @@ func (h *ObligationsHandlers) AssessObligations(c *gin.Context) { CreatedBy: rbac.GetUserID(c), } if err := h.store.CreateAssessment(c.Request.Context(), assessment); err != nil { - // Log but don't fail - assessment was still generated c.Set("store_error", err.Error()) } } @@ -202,235 +188,20 @@ func (h *ObligationsHandlers) GetAssessment(c *gin.Context) { c.JSON(http.StatusOK, assessment.Overview) } -// GetByRegulation returns obligations grouped by regulation -// GET /sdk/v1/ucca/obligations/:assessmentId/by-regulation -func (h *ObligationsHandlers) GetByRegulation(c *gin.Context) { - tenantID := rbac.GetTenantID(c) - assessmentID := c.Param("assessmentId") - - if h.store == nil { - c.JSON(http.StatusNotImplemented, gin.H{"error": "Persistence not configured"}) - return - } - - id, err := uuid.Parse(assessmentID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assessment ID"}) - return - } - - assessment, err := h.store.GetAssessment(c.Request.Context(), tenantID, id) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Assessment not found"}) - return - } - - grouped := h.registry.GroupByRegulation(assessment.Overview.Obligations) - - c.JSON(http.StatusOK, ucca.ObligationsByRegulationResponse{ - Regulations: grouped, - }) -} - -// GetByDeadline returns obligations grouped by deadline timeframe -// GET /sdk/v1/ucca/obligations/:assessmentId/by-deadline -func (h *ObligationsHandlers) GetByDeadline(c *gin.Context) { - tenantID := rbac.GetTenantID(c) - assessmentID := c.Param("assessmentId") - - if h.store == nil { - c.JSON(http.StatusNotImplemented, gin.H{"error": "Persistence not configured"}) - return - } - - id, err := uuid.Parse(assessmentID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assessment ID"}) - return - } - - assessment, err := h.store.GetAssessment(c.Request.Context(), tenantID, id) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Assessment not found"}) - return - } - - grouped := h.registry.GroupByDeadline(assessment.Overview.Obligations) - - c.JSON(http.StatusOK, grouped) -} - -// GetByResponsible returns obligations grouped by responsible role -// GET /sdk/v1/ucca/obligations/:assessmentId/by-responsible -func (h *ObligationsHandlers) GetByResponsible(c *gin.Context) { - tenantID := rbac.GetTenantID(c) - assessmentID := c.Param("assessmentId") - - if h.store == nil { - c.JSON(http.StatusNotImplemented, gin.H{"error": "Persistence not configured"}) - return - } - - id, err := uuid.Parse(assessmentID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assessment ID"}) - return - } - - assessment, err := h.store.GetAssessment(c.Request.Context(), tenantID, id) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Assessment not found"}) - return - } - - grouped := h.registry.GroupByResponsible(assessment.Overview.Obligations) - - c.JSON(http.StatusOK, ucca.ObligationsByResponsibleResponse{ - ByRole: grouped, - }) -} - -// ExportMemo exports the obligations overview as a C-Level memo -// POST /sdk/v1/ucca/obligations/export/memo -func (h *ObligationsHandlers) ExportMemo(c *gin.Context) { - tenantID := rbac.GetTenantID(c) - - var req ucca.ExportMemoRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - if h.store == nil { - c.JSON(http.StatusNotImplemented, gin.H{"error": "Persistence not configured"}) - return - } - - id, err := uuid.Parse(req.AssessmentID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assessment ID"}) - return - } - - assessment, err := h.store.GetAssessment(c.Request.Context(), tenantID, id) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Assessment not found"}) - return - } - - // Create exporter - exporter := ucca.NewPDFExporter(req.Language) - - // Generate export based on format - var response *ucca.ExportMemoResponse - switch req.Format { - case "pdf": - response, err = exporter.ExportManagementMemo(assessment.Overview) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate PDF", "details": err.Error()}) - return - } - case "markdown", "": - response, err = exporter.ExportMarkdown(assessment.Overview) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate Markdown", "details": err.Error()}) - return - } - default: - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid format. Use 'markdown' or 'pdf'"}) - return - } - - c.JSON(http.StatusOK, response) -} - -// ExportMemoFromOverview exports an overview directly (without persistence) -// POST /sdk/v1/ucca/obligations/export/direct -func (h *ObligationsHandlers) ExportMemoFromOverview(c *gin.Context) { - var req struct { - Overview *ucca.ManagementObligationsOverview `json:"overview"` - Format string `json:"format"` // "markdown" or "pdf" - Language string `json:"language,omitempty"` - } - - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - if req.Overview == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Overview is required"}) - return - } - - exporter := ucca.NewPDFExporter(req.Language) - - var response *ucca.ExportMemoResponse - var err error - switch req.Format { - case "pdf": - response, err = exporter.ExportManagementMemo(req.Overview) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate PDF", "details": err.Error()}) - return - } - case "markdown", "": - response, err = exporter.ExportMarkdown(req.Overview) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate Markdown", "details": err.Error()}) - return - } - default: - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid format. Use 'markdown' or 'pdf'"}) - return - } - - c.JSON(http.StatusOK, response) -} - -// ListRegulations returns all available regulation modules -// GET /sdk/v1/ucca/obligations/regulations -func (h *ObligationsHandlers) ListRegulations(c *gin.Context) { - modules := h.registry.ListModules() - - c.JSON(http.StatusOK, ucca.AvailableRegulationsResponse{ - Regulations: modules, - }) -} - -// GetDecisionTree returns the decision tree for a specific regulation -// GET /sdk/v1/ucca/obligations/regulations/:regulationId/decision-tree -func (h *ObligationsHandlers) GetDecisionTree(c *gin.Context) { - regulationID := c.Param("regulationId") - - tree, err := h.registry.GetDecisionTree(regulationID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, tree) -} - // QuickCheck performs a quick obligations check without persistence // POST /sdk/v1/ucca/obligations/quick-check func (h *ObligationsHandlers) QuickCheck(c *gin.Context) { var req struct { - // Organization basics - EmployeeCount int `json:"employee_count"` - AnnualRevenue float64 `json:"annual_revenue"` - BalanceSheetTotal float64 `json:"balance_sheet_total,omitempty"` - Country string `json:"country"` - - // Sector - PrimarySector string `json:"primary_sector"` - SpecialServices []string `json:"special_services,omitempty"` - IsKRITIS bool `json:"is_kritis,omitempty"` - - // Quick flags - ProcessesPersonalData bool `json:"processes_personal_data,omitempty"` - UsesAI bool `json:"uses_ai,omitempty"` - IsFinancialInstitution bool `json:"is_financial_institution,omitempty"` + EmployeeCount int `json:"employee_count"` + AnnualRevenue float64 `json:"annual_revenue"` + BalanceSheetTotal float64 `json:"balance_sheet_total,omitempty"` + Country string `json:"country"` + PrimarySector string `json:"primary_sector"` + SpecialServices []string `json:"special_services,omitempty"` + IsKRITIS bool `json:"is_kritis,omitempty"` + ProcessesPersonalData bool `json:"processes_personal_data,omitempty"` + UsesAI bool `json:"uses_ai,omitempty"` + IsFinancialInstitution bool `json:"is_financial_institution,omitempty"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -438,7 +209,6 @@ func (h *ObligationsHandlers) QuickCheck(c *gin.Context) { return } - // Build UnifiedFacts from quick check request facts := &ucca.UnifiedFacts{ Organization: ucca.OrganizationFacts{ EmployeeCount: req.EmployeeCount, @@ -465,15 +235,13 @@ func (h *ObligationsHandlers) QuickCheck(c *gin.Context) { }, } - // Quick evaluation tenantID := rbac.GetTenantID(c) if tenantID == uuid.Nil { - tenantID = uuid.New() // Generate temporary ID for quick check + tenantID = uuid.New() } overview := h.registry.EvaluateAll(tenantID, facts, "") - // Return simplified result c.JSON(http.StatusOK, gin.H{ "applicable_regulations": overview.ApplicableRegulations, "total_obligations": len(overview.Obligations), @@ -497,13 +265,9 @@ func (h *ObligationsHandlers) AssessFromScope(c *gin.Context) { return } - // Convert scope to facts facts := ucca.MapScopeToFacts(&scope) - - // Evaluate overview := h.registry.EvaluateAll(tenantID, facts, "") - // Enrich with TOM control requirements if available if h.tomMapper != nil { overview.TOMControlRequirements = h.tomMapper.DeriveControlsFromObligations(overview.Obligations) } @@ -518,182 +282,3 @@ func (h *ObligationsHandlers) AssessFromScope(c *gin.Context) { Warnings: warnings, }) } - -// GetTOMControlsForObligation returns TOM controls linked to an obligation -// GET /sdk/v1/ucca/obligations/:id/tom-controls -func (h *ObligationsHandlers) GetTOMControlsForObligation(c *gin.Context) { - obligationID := c.Param("obligationId") - - if h.tomMapper == nil { - c.JSON(http.StatusNotImplemented, gin.H{"error": "TOM mapping not available"}) - return - } - - controls := h.tomMapper.GetControlsForObligation(obligationID) - controlIDs := h.tomMapper.GetControlIDsForObligation(obligationID) - - c.JSON(http.StatusOK, gin.H{ - "obligation_id": obligationID, - "control_ids": controlIDs, - "controls": controls, - "count": len(controls), - }) -} - -// GapAnalysis performs a TOM control gap analysis -// POST /sdk/v1/ucca/obligations/gap-analysis -func (h *ObligationsHandlers) GapAnalysis(c *gin.Context) { - if h.gapAnalyzer == nil { - c.JSON(http.StatusNotImplemented, gin.H{"error": "Gap analysis not available"}) - return - } - - var req ucca.GapAnalysisRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()}) - return - } - - result := h.gapAnalyzer.Analyze(&req) - c.JSON(http.StatusOK, result) -} - -// GetObligationsForControl returns obligations linked to a TOM control -// GET /sdk/v1/ucca/obligations/tom-controls/:controlId/obligations -func (h *ObligationsHandlers) GetObligationsForControl(c *gin.Context) { - controlID := c.Param("controlId") - - if h.tomMapper == nil { - c.JSON(http.StatusNotImplemented, gin.H{"error": "TOM mapping not available"}) - return - } - - obligationIDs := h.tomMapper.GetObligationsForControl(controlID) - - var control *ucca.TOMControl - if h.tomIndex != nil { - control, _ = h.tomIndex.GetControl(controlID) - } - - c.JSON(http.StatusOK, gin.H{ - "control_id": controlID, - "control": control, - "obligation_ids": obligationIDs, - "count": len(obligationIDs), - }) -} - -// ============================================================================ -// Helper Functions -// ============================================================================ - -func generateMemoMarkdown(overview *ucca.ManagementObligationsOverview) string { - content := "# Pflichten-Übersicht für die Geschäftsführung\n\n" - content += "**Datum:** " + overview.AssessmentDate.Format("02.01.2006") + "\n" - if overview.OrganizationName != "" { - content += "**Organisation:** " + overview.OrganizationName + "\n" - } - content += "\n---\n\n" - - // Executive Summary - content += "## Executive Summary\n\n" - content += "| Kennzahl | Wert |\n" - content += "|----------|------|\n" - content += "| Anwendbare Regulierungen | " + itoa(overview.ExecutiveSummary.TotalRegulations) + " |\n" - content += "| Gesamtzahl Pflichten | " + itoa(overview.ExecutiveSummary.TotalObligations) + " |\n" - content += "| Kritische Pflichten | " + itoa(overview.ExecutiveSummary.CriticalObligations) + " |\n" - content += "| Überfällige Pflichten | " + itoa(overview.ExecutiveSummary.OverdueObligations) + " |\n" - content += "| Anstehende Fristen (30 Tage) | " + itoa(overview.ExecutiveSummary.UpcomingDeadlines) + " |\n" - content += "\n" - - // Key Risks - if len(overview.ExecutiveSummary.KeyRisks) > 0 { - content += "### Hauptrisiken\n\n" - for _, risk := range overview.ExecutiveSummary.KeyRisks { - content += "- ⚠️ " + risk + "\n" - } - content += "\n" - } - - // Recommended Actions - if len(overview.ExecutiveSummary.RecommendedActions) > 0 { - content += "### Empfohlene Maßnahmen\n\n" - for i, action := range overview.ExecutiveSummary.RecommendedActions { - content += itoa(i+1) + ". " + action + "\n" - } - content += "\n" - } - - // Applicable Regulations - content += "## Anwendbare Regulierungen\n\n" - for _, reg := range overview.ApplicableRegulations { - content += "### " + reg.Name + "\n\n" - content += "- **Klassifizierung:** " + reg.Classification + "\n" - content += "- **Begründung:** " + reg.Reason + "\n" - content += "- **Anzahl Pflichten:** " + itoa(reg.ObligationCount) + "\n" - content += "\n" - } - - // Sanctions Summary - content += "## Sanktionsrisiken\n\n" - content += overview.SanctionsSummary.Summary + "\n\n" - if overview.SanctionsSummary.MaxFinancialRisk != "" { - content += "- **Maximales Bußgeld:** " + overview.SanctionsSummary.MaxFinancialRisk + "\n" - } - if overview.SanctionsSummary.PersonalLiabilityRisk { - content += "- **Persönliche Haftung:** Ja ⚠️\n" - } - content += "\n" - - // Critical Obligations - content += "## Kritische Pflichten\n\n" - for _, obl := range overview.Obligations { - if obl.Priority == ucca.PriorityCritical { - content += "### " + obl.ID + ": " + obl.Title + "\n\n" - content += obl.Description + "\n\n" - content += "- **Verantwortlich:** " + string(obl.Responsible) + "\n" - if obl.Deadline != nil { - if obl.Deadline.Date != nil { - content += "- **Frist:** " + obl.Deadline.Date.Format("02.01.2006") + "\n" - } else if obl.Deadline.Duration != "" { - content += "- **Frist:** " + obl.Deadline.Duration + "\n" - } - } - if obl.Sanctions != nil && obl.Sanctions.MaxFine != "" { - content += "- **Sanktion:** " + obl.Sanctions.MaxFine + "\n" - } - content += "\n" - } - } - - // Incident Deadlines - if len(overview.IncidentDeadlines) > 0 { - content += "## Meldepflichten bei Sicherheitsvorfällen\n\n" - content += "| Phase | Frist | Empfänger |\n" - content += "|-------|-------|-----------|\n" - for _, deadline := range overview.IncidentDeadlines { - content += "| " + deadline.Phase + " | " + deadline.Deadline + " | " + deadline.Recipient + " |\n" - } - content += "\n" - } - - content += "---\n\n" - content += "*Dieses Dokument wurde automatisch generiert und ersetzt keine Rechtsberatung.*\n" - - return content -} - -func isEUCountry(country string) bool { - euCountries := map[string]bool{ - "DE": true, "AT": true, "BE": true, "BG": true, "HR": true, "CY": true, - "CZ": true, "DK": true, "EE": true, "FI": true, "FR": true, "GR": true, - "HU": true, "IE": true, "IT": true, "LV": true, "LT": true, "LU": true, - "MT": true, "NL": true, "PL": true, "PT": true, "RO": true, "SK": true, - "SI": true, "ES": true, "SE": true, - } - return euCountries[country] -} - -func itoa(i int) string { - return strconv.Itoa(i) -} diff --git a/ai-compliance-sdk/internal/api/handlers/obligations_query_handlers.go b/ai-compliance-sdk/internal/api/handlers/obligations_query_handlers.go new file mode 100644 index 0000000..2b74e4d --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/obligations_query_handlers.go @@ -0,0 +1,187 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/breakpilot/ai-compliance-sdk/internal/rbac" + "github.com/breakpilot/ai-compliance-sdk/internal/ucca" +) + +// GetByRegulation returns obligations grouped by regulation +// GET /sdk/v1/ucca/obligations/:assessmentId/by-regulation +func (h *ObligationsHandlers) GetByRegulation(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + assessmentID := c.Param("assessmentId") + + if h.store == nil { + c.JSON(http.StatusNotImplemented, gin.H{"error": "Persistence not configured"}) + return + } + + id, err := uuid.Parse(assessmentID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assessment ID"}) + return + } + + assessment, err := h.store.GetAssessment(c.Request.Context(), tenantID, id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Assessment not found"}) + return + } + + grouped := h.registry.GroupByRegulation(assessment.Overview.Obligations) + + c.JSON(http.StatusOK, ucca.ObligationsByRegulationResponse{ + Regulations: grouped, + }) +} + +// GetByDeadline returns obligations grouped by deadline timeframe +// GET /sdk/v1/ucca/obligations/:assessmentId/by-deadline +func (h *ObligationsHandlers) GetByDeadline(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + assessmentID := c.Param("assessmentId") + + if h.store == nil { + c.JSON(http.StatusNotImplemented, gin.H{"error": "Persistence not configured"}) + return + } + + id, err := uuid.Parse(assessmentID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assessment ID"}) + return + } + + assessment, err := h.store.GetAssessment(c.Request.Context(), tenantID, id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Assessment not found"}) + return + } + + grouped := h.registry.GroupByDeadline(assessment.Overview.Obligations) + + c.JSON(http.StatusOK, grouped) +} + +// GetByResponsible returns obligations grouped by responsible role +// GET /sdk/v1/ucca/obligations/:assessmentId/by-responsible +func (h *ObligationsHandlers) GetByResponsible(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + assessmentID := c.Param("assessmentId") + + if h.store == nil { + c.JSON(http.StatusNotImplemented, gin.H{"error": "Persistence not configured"}) + return + } + + id, err := uuid.Parse(assessmentID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assessment ID"}) + return + } + + assessment, err := h.store.GetAssessment(c.Request.Context(), tenantID, id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Assessment not found"}) + return + } + + grouped := h.registry.GroupByResponsible(assessment.Overview.Obligations) + + c.JSON(http.StatusOK, ucca.ObligationsByResponsibleResponse{ + ByRole: grouped, + }) +} + +// ListRegulations returns all available regulation modules +// GET /sdk/v1/ucca/obligations/regulations +func (h *ObligationsHandlers) ListRegulations(c *gin.Context) { + modules := h.registry.ListModules() + + c.JSON(http.StatusOK, ucca.AvailableRegulationsResponse{ + Regulations: modules, + }) +} + +// GetDecisionTree returns the decision tree for a specific regulation +// GET /sdk/v1/ucca/obligations/regulations/:regulationId/decision-tree +func (h *ObligationsHandlers) GetDecisionTree(c *gin.Context) { + regulationID := c.Param("regulationId") + + tree, err := h.registry.GetDecisionTree(regulationID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, tree) +} + +// GetTOMControlsForObligation returns TOM controls linked to an obligation +// GET /sdk/v1/ucca/obligations/:id/tom-controls +func (h *ObligationsHandlers) GetTOMControlsForObligation(c *gin.Context) { + obligationID := c.Param("obligationId") + + if h.tomMapper == nil { + c.JSON(http.StatusNotImplemented, gin.H{"error": "TOM mapping not available"}) + return + } + + controls := h.tomMapper.GetControlsForObligation(obligationID) + controlIDs := h.tomMapper.GetControlIDsForObligation(obligationID) + + c.JSON(http.StatusOK, gin.H{ + "obligation_id": obligationID, + "control_ids": controlIDs, + "controls": controls, + "count": len(controls), + }) +} + +// GapAnalysis performs a TOM control gap analysis +// POST /sdk/v1/ucca/obligations/gap-analysis +func (h *ObligationsHandlers) GapAnalysis(c *gin.Context) { + if h.gapAnalyzer == nil { + c.JSON(http.StatusNotImplemented, gin.H{"error": "Gap analysis not available"}) + return + } + + var req ucca.GapAnalysisRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()}) + return + } + + result := h.gapAnalyzer.Analyze(&req) + c.JSON(http.StatusOK, result) +} + +// GetObligationsForControl returns obligations linked to a TOM control +// GET /sdk/v1/ucca/obligations/tom-controls/:controlId/obligations +func (h *ObligationsHandlers) GetObligationsForControl(c *gin.Context) { + controlID := c.Param("controlId") + + if h.tomMapper == nil { + c.JSON(http.StatusNotImplemented, gin.H{"error": "TOM mapping not available"}) + return + } + + obligationIDs := h.tomMapper.GetObligationsForControl(controlID) + + var control *ucca.TOMControl + if h.tomIndex != nil { + control, _ = h.tomIndex.GetControl(controlID) + } + + c.JSON(http.StatusOK, gin.H{ + "control_id": controlID, + "control": control, + "obligation_ids": obligationIDs, + "count": len(obligationIDs), + }) +} diff --git a/ai-compliance-sdk/internal/api/handlers/portfolio_handlers.go b/ai-compliance-sdk/internal/api/handlers/portfolio_handlers.go index bc34e69..a6040d7 100644 --- a/ai-compliance-sdk/internal/api/handlers/portfolio_handlers.go +++ b/ai-compliance-sdk/internal/api/handlers/portfolio_handlers.go @@ -49,7 +49,6 @@ func (h *PortfolioHandlers) CreatePortfolio(c *gin.Context) { CreatedBy: userID, } - // Set default settings if !p.Settings.AutoUpdateMetrics { p.Settings.AutoUpdateMetrics = true } @@ -125,7 +124,6 @@ func (h *PortfolioHandlers) GetPortfolio(c *gin.Context) { return } - // Get stats stats, _ := h.store.GetPortfolioStats(c.Request.Context(), id) c.JSON(http.StatusOK, gin.H{ @@ -211,415 +209,3 @@ func (h *PortfolioHandlers) DeletePortfolio(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "portfolio deleted"}) } - -// ============================================================================ -// Portfolio Items -// ============================================================================ - -// AddItem adds an item to a portfolio -// POST /sdk/v1/portfolios/:id/items -func (h *PortfolioHandlers) AddItem(c *gin.Context) { - portfolioID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) - return - } - - var req portfolio.AddItemRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - userID := rbac.GetUserID(c) - - item := &portfolio.PortfolioItem{ - PortfolioID: portfolioID, - ItemType: req.ItemType, - ItemID: req.ItemID, - Tags: req.Tags, - Notes: req.Notes, - AddedBy: userID, - } - - if err := h.store.AddItem(c.Request.Context(), item); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusCreated, gin.H{"item": item}) -} - -// ListItems lists items in a portfolio -// GET /sdk/v1/portfolios/:id/items -func (h *PortfolioHandlers) ListItems(c *gin.Context) { - portfolioID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) - return - } - - var itemType *portfolio.ItemType - if t := c.Query("type"); t != "" { - it := portfolio.ItemType(t) - itemType = &it - } - - items, err := h.store.ListItems(c.Request.Context(), portfolioID, itemType) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "items": items, - "total": len(items), - }) -} - -// BulkAddItems adds multiple items to a portfolio -// POST /sdk/v1/portfolios/:id/items/bulk -func (h *PortfolioHandlers) BulkAddItems(c *gin.Context) { - portfolioID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) - return - } - - var req portfolio.BulkAddItemsRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - userID := rbac.GetUserID(c) - - // Convert AddItemRequest to PortfolioItem - items := make([]portfolio.PortfolioItem, len(req.Items)) - for i, r := range req.Items { - items[i] = portfolio.PortfolioItem{ - ItemType: r.ItemType, - ItemID: r.ItemID, - Tags: r.Tags, - Notes: r.Notes, - } - } - - result, err := h.store.BulkAddItems(c.Request.Context(), portfolioID, items, userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, result) -} - -// RemoveItem removes an item from a portfolio -// DELETE /sdk/v1/portfolios/:id/items/:itemId -func (h *PortfolioHandlers) RemoveItem(c *gin.Context) { - itemID, err := uuid.Parse(c.Param("itemId")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid item ID"}) - return - } - - if err := h.store.RemoveItem(c.Request.Context(), itemID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "item removed"}) -} - -// ReorderItems updates the order of items -// PUT /sdk/v1/portfolios/:id/items/order -func (h *PortfolioHandlers) ReorderItems(c *gin.Context) { - portfolioID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) - return - } - - var req struct { - ItemIDs []uuid.UUID `json:"item_ids"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if err := h.store.UpdateItemOrder(c.Request.Context(), portfolioID, req.ItemIDs); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "items reordered"}) -} - -// ============================================================================ -// Merge Operations -// ============================================================================ - -// MergePortfolios merges two portfolios -// POST /sdk/v1/portfolios/merge -func (h *PortfolioHandlers) MergePortfolios(c *gin.Context) { - var req portfolio.MergeRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Validate portfolios exist - source, err := h.store.GetPortfolio(c.Request.Context(), req.SourcePortfolioID) - if err != nil || source == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "source portfolio not found"}) - return - } - - target, err := h.store.GetPortfolio(c.Request.Context(), req.TargetPortfolioID) - if err != nil || target == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "target portfolio not found"}) - return - } - - // Set defaults - if req.Strategy == "" { - req.Strategy = portfolio.MergeStrategyUnion - } - - userID := rbac.GetUserID(c) - - result, err := h.store.MergePortfolios(c.Request.Context(), &req, userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "message": "portfolios merged", - "result": result, - }) -} - -// ============================================================================ -// Statistics & Reports -// ============================================================================ - -// GetPortfolioStats returns statistics for a portfolio -// GET /sdk/v1/portfolios/:id/stats -func (h *PortfolioHandlers) GetPortfolioStats(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) - return - } - - stats, err := h.store.GetPortfolioStats(c.Request.Context(), id) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, stats) -} - -// GetPortfolioActivity returns recent activity for a portfolio -// GET /sdk/v1/portfolios/:id/activity -func (h *PortfolioHandlers) GetPortfolioActivity(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) - return - } - - limit := 20 - if l := c.Query("limit"); l != "" { - if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { - limit = parsed - } - } - - activities, err := h.store.GetRecentActivity(c.Request.Context(), id, limit) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "activities": activities, - "total": len(activities), - }) -} - -// ComparePortfolios compares multiple portfolios -// POST /sdk/v1/portfolios/compare -func (h *PortfolioHandlers) ComparePortfolios(c *gin.Context) { - var req portfolio.ComparePortfoliosRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if len(req.PortfolioIDs) < 2 { - c.JSON(http.StatusBadRequest, gin.H{"error": "at least 2 portfolios required for comparison"}) - return - } - if len(req.PortfolioIDs) > 5 { - c.JSON(http.StatusBadRequest, gin.H{"error": "maximum 5 portfolios can be compared"}) - return - } - - // Get all portfolios - var portfolios []portfolio.Portfolio - comparison := portfolio.PortfolioComparison{ - RiskScores: make(map[string]float64), - ComplianceScores: make(map[string]float64), - ItemCounts: make(map[string]int), - UniqueItems: make(map[string][]uuid.UUID), - } - - allItems := make(map[uuid.UUID][]string) // item_id -> portfolio_ids - - for _, id := range req.PortfolioIDs { - p, err := h.store.GetPortfolio(c.Request.Context(), id) - if err != nil || p == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "portfolio not found: " + id.String()}) - return - } - portfolios = append(portfolios, *p) - - idStr := id.String() - comparison.RiskScores[idStr] = p.AvgRiskScore - comparison.ComplianceScores[idStr] = p.ComplianceScore - comparison.ItemCounts[idStr] = p.TotalAssessments + p.TotalRoadmaps + p.TotalWorkshops - - // Get items for comparison - items, _ := h.store.ListItems(c.Request.Context(), id, nil) - for _, item := range items { - allItems[item.ItemID] = append(allItems[item.ItemID], idStr) - } - } - - // Find common and unique items - for itemID, portfolioIDs := range allItems { - if len(portfolioIDs) > 1 { - comparison.CommonItems = append(comparison.CommonItems, itemID) - } else { - pid := portfolioIDs[0] - comparison.UniqueItems[pid] = append(comparison.UniqueItems[pid], itemID) - } - } - - c.JSON(http.StatusOK, portfolio.ComparePortfoliosResponse{ - Portfolios: portfolios, - Comparison: comparison, - }) -} - -// RecalculateMetrics manually recalculates portfolio metrics -// POST /sdk/v1/portfolios/:id/recalculate -func (h *PortfolioHandlers) RecalculateMetrics(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) - return - } - - if err := h.store.RecalculateMetrics(c.Request.Context(), id); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - // Get updated portfolio - p, _ := h.store.GetPortfolio(c.Request.Context(), id) - - c.JSON(http.StatusOK, gin.H{ - "message": "metrics recalculated", - "portfolio": p, - }) -} - -// ============================================================================ -// Approval Workflow -// ============================================================================ - -// ApprovePortfolio approves a portfolio -// POST /sdk/v1/portfolios/:id/approve -func (h *PortfolioHandlers) ApprovePortfolio(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) - return - } - - p, err := h.store.GetPortfolio(c.Request.Context(), id) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if p == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "portfolio not found"}) - return - } - - if p.Status != portfolio.PortfolioStatusReview { - c.JSON(http.StatusBadRequest, gin.H{"error": "portfolio must be in REVIEW status to approve"}) - return - } - - userID := rbac.GetUserID(c) - now := c.Request.Context().Value("now") - if now == nil { - t := p.UpdatedAt - p.ApprovedAt = &t - } - p.ApprovedBy = &userID - p.Status = portfolio.PortfolioStatusApproved - - if err := h.store.UpdatePortfolio(c.Request.Context(), p); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "message": "portfolio approved", - "portfolio": p, - }) -} - -// SubmitForReview submits a portfolio for review -// POST /sdk/v1/portfolios/:id/submit-review -func (h *PortfolioHandlers) SubmitForReview(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) - return - } - - p, err := h.store.GetPortfolio(c.Request.Context(), id) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if p == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "portfolio not found"}) - return - } - - if p.Status != portfolio.PortfolioStatusDraft && p.Status != portfolio.PortfolioStatusActive { - c.JSON(http.StatusBadRequest, gin.H{"error": "portfolio must be in DRAFT or ACTIVE status to submit for review"}) - return - } - - p.Status = portfolio.PortfolioStatusReview - - if err := h.store.UpdatePortfolio(c.Request.Context(), p); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "message": "portfolio submitted for review", - "portfolio": p, - }) -} diff --git a/ai-compliance-sdk/internal/api/handlers/portfolio_items_handlers.go b/ai-compliance-sdk/internal/api/handlers/portfolio_items_handlers.go new file mode 100644 index 0000000..cd81d05 --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/portfolio_items_handlers.go @@ -0,0 +1,196 @@ +package handlers + +import ( + "net/http" + + "github.com/breakpilot/ai-compliance-sdk/internal/portfolio" + "github.com/breakpilot/ai-compliance-sdk/internal/rbac" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// ============================================================================ +// Portfolio Items +// ============================================================================ + +// AddItem adds an item to a portfolio +// POST /sdk/v1/portfolios/:id/items +func (h *PortfolioHandlers) AddItem(c *gin.Context) { + portfolioID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) + return + } + + var req portfolio.AddItemRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + userID := rbac.GetUserID(c) + + item := &portfolio.PortfolioItem{ + PortfolioID: portfolioID, + ItemType: req.ItemType, + ItemID: req.ItemID, + Tags: req.Tags, + Notes: req.Notes, + AddedBy: userID, + } + + if err := h.store.AddItem(c.Request.Context(), item); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{"item": item}) +} + +// ListItems lists items in a portfolio +// GET /sdk/v1/portfolios/:id/items +func (h *PortfolioHandlers) ListItems(c *gin.Context) { + portfolioID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) + return + } + + var itemType *portfolio.ItemType + if t := c.Query("type"); t != "" { + it := portfolio.ItemType(t) + itemType = &it + } + + items, err := h.store.ListItems(c.Request.Context(), portfolioID, itemType) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "items": items, + "total": len(items), + }) +} + +// BulkAddItems adds multiple items to a portfolio +// POST /sdk/v1/portfolios/:id/items/bulk +func (h *PortfolioHandlers) BulkAddItems(c *gin.Context) { + portfolioID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) + return + } + + var req portfolio.BulkAddItemsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + userID := rbac.GetUserID(c) + + items := make([]portfolio.PortfolioItem, len(req.Items)) + for i, r := range req.Items { + items[i] = portfolio.PortfolioItem{ + ItemType: r.ItemType, + ItemID: r.ItemID, + Tags: r.Tags, + Notes: r.Notes, + } + } + + result, err := h.store.BulkAddItems(c.Request.Context(), portfolioID, items, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, result) +} + +// RemoveItem removes an item from a portfolio +// DELETE /sdk/v1/portfolios/:id/items/:itemId +func (h *PortfolioHandlers) RemoveItem(c *gin.Context) { + itemID, err := uuid.Parse(c.Param("itemId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid item ID"}) + return + } + + if err := h.store.RemoveItem(c.Request.Context(), itemID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "item removed"}) +} + +// ReorderItems updates the order of items +// PUT /sdk/v1/portfolios/:id/items/order +func (h *PortfolioHandlers) ReorderItems(c *gin.Context) { + portfolioID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) + return + } + + var req struct { + ItemIDs []uuid.UUID `json:"item_ids"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.store.UpdateItemOrder(c.Request.Context(), portfolioID, req.ItemIDs); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "items reordered"}) +} + +// ============================================================================ +// Merge Operations +// ============================================================================ + +// MergePortfolios merges two portfolios +// POST /sdk/v1/portfolios/merge +func (h *PortfolioHandlers) MergePortfolios(c *gin.Context) { + var req portfolio.MergeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + source, err := h.store.GetPortfolio(c.Request.Context(), req.SourcePortfolioID) + if err != nil || source == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "source portfolio not found"}) + return + } + + target, err := h.store.GetPortfolio(c.Request.Context(), req.TargetPortfolioID) + if err != nil || target == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "target portfolio not found"}) + return + } + + if req.Strategy == "" { + req.Strategy = portfolio.MergeStrategyUnion + } + + userID := rbac.GetUserID(c) + + result, err := h.store.MergePortfolios(c.Request.Context(), &req, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "portfolios merged", + "result": result, + }) +} diff --git a/ai-compliance-sdk/internal/api/handlers/portfolio_stats_handlers.go b/ai-compliance-sdk/internal/api/handlers/portfolio_stats_handlers.go new file mode 100644 index 0000000..5c31b99 --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/portfolio_stats_handlers.go @@ -0,0 +1,230 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/breakpilot/ai-compliance-sdk/internal/portfolio" + "github.com/breakpilot/ai-compliance-sdk/internal/rbac" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// ============================================================================ +// Statistics & Reports +// ============================================================================ + +// GetPortfolioStats returns statistics for a portfolio +// GET /sdk/v1/portfolios/:id/stats +func (h *PortfolioHandlers) GetPortfolioStats(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) + return + } + + stats, err := h.store.GetPortfolioStats(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, stats) +} + +// GetPortfolioActivity returns recent activity for a portfolio +// GET /sdk/v1/portfolios/:id/activity +func (h *PortfolioHandlers) GetPortfolioActivity(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) + return + } + + limit := 20 + if l := c.Query("limit"); l != "" { + if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { + limit = parsed + } + } + + activities, err := h.store.GetRecentActivity(c.Request.Context(), id, limit) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "activities": activities, + "total": len(activities), + }) +} + +// ComparePortfolios compares multiple portfolios +// POST /sdk/v1/portfolios/compare +func (h *PortfolioHandlers) ComparePortfolios(c *gin.Context) { + var req portfolio.ComparePortfoliosRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if len(req.PortfolioIDs) < 2 { + c.JSON(http.StatusBadRequest, gin.H{"error": "at least 2 portfolios required for comparison"}) + return + } + if len(req.PortfolioIDs) > 5 { + c.JSON(http.StatusBadRequest, gin.H{"error": "maximum 5 portfolios can be compared"}) + return + } + + var portfolios []portfolio.Portfolio + comparison := portfolio.PortfolioComparison{ + RiskScores: make(map[string]float64), + ComplianceScores: make(map[string]float64), + ItemCounts: make(map[string]int), + UniqueItems: make(map[string][]uuid.UUID), + } + + allItems := make(map[uuid.UUID][]string) + + for _, id := range req.PortfolioIDs { + p, err := h.store.GetPortfolio(c.Request.Context(), id) + if err != nil || p == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "portfolio not found: " + id.String()}) + return + } + portfolios = append(portfolios, *p) + + idStr := id.String() + comparison.RiskScores[idStr] = p.AvgRiskScore + comparison.ComplianceScores[idStr] = p.ComplianceScore + comparison.ItemCounts[idStr] = p.TotalAssessments + p.TotalRoadmaps + p.TotalWorkshops + + items, _ := h.store.ListItems(c.Request.Context(), id, nil) + for _, item := range items { + allItems[item.ItemID] = append(allItems[item.ItemID], idStr) + } + } + + for itemID, portfolioIDs := range allItems { + if len(portfolioIDs) > 1 { + comparison.CommonItems = append(comparison.CommonItems, itemID) + } else { + pid := portfolioIDs[0] + comparison.UniqueItems[pid] = append(comparison.UniqueItems[pid], itemID) + } + } + + c.JSON(http.StatusOK, portfolio.ComparePortfoliosResponse{ + Portfolios: portfolios, + Comparison: comparison, + }) +} + +// RecalculateMetrics manually recalculates portfolio metrics +// POST /sdk/v1/portfolios/:id/recalculate +func (h *PortfolioHandlers) RecalculateMetrics(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) + return + } + + if err := h.store.RecalculateMetrics(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + p, _ := h.store.GetPortfolio(c.Request.Context(), id) + + c.JSON(http.StatusOK, gin.H{ + "message": "metrics recalculated", + "portfolio": p, + }) +} + +// ============================================================================ +// Approval Workflow +// ============================================================================ + +// ApprovePortfolio approves a portfolio +// POST /sdk/v1/portfolios/:id/approve +func (h *PortfolioHandlers) ApprovePortfolio(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) + return + } + + p, err := h.store.GetPortfolio(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if p == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "portfolio not found"}) + return + } + + if p.Status != portfolio.PortfolioStatusReview { + c.JSON(http.StatusBadRequest, gin.H{"error": "portfolio must be in REVIEW status to approve"}) + return + } + + userID := rbac.GetUserID(c) + now := c.Request.Context().Value("now") + if now == nil { + t := p.UpdatedAt + p.ApprovedAt = &t + } + p.ApprovedBy = &userID + p.Status = portfolio.PortfolioStatusApproved + + if err := h.store.UpdatePortfolio(c.Request.Context(), p); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "portfolio approved", + "portfolio": p, + }) +} + +// SubmitForReview submits a portfolio for review +// POST /sdk/v1/portfolios/:id/submit-review +func (h *PortfolioHandlers) SubmitForReview(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) + return + } + + p, err := h.store.GetPortfolio(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if p == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "portfolio not found"}) + return + } + + if p.Status != portfolio.PortfolioStatusDraft && p.Status != portfolio.PortfolioStatusActive { + c.JSON(http.StatusBadRequest, gin.H{"error": "portfolio must be in DRAFT or ACTIVE status to submit for review"}) + return + } + + p.Status = portfolio.PortfolioStatusReview + + if err := h.store.UpdatePortfolio(c.Request.Context(), p); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "portfolio submitted for review", + "portfolio": p, + }) +} diff --git a/ai-compliance-sdk/internal/api/handlers/rbac_handlers.go b/ai-compliance-sdk/internal/api/handlers/rbac_handlers.go index 4e70b77..21ecf73 100644 --- a/ai-compliance-sdk/internal/api/handlers/rbac_handlers.go +++ b/ai-compliance-sdk/internal/api/handlers/rbac_handlers.go @@ -163,386 +163,3 @@ func (h *RBACHandlers) CreateNamespace(c *gin.Context) { c.JSON(http.StatusCreated, namespace) } - -// ============================================================================ -// Role Endpoints -// ============================================================================ - -// ListRoles returns roles for a tenant (including system roles) -func (h *RBACHandlers) ListRoles(c *gin.Context) { - tenantID := rbac.GetTenantID(c) - var tenantIDPtr *uuid.UUID - if tenantID != uuid.Nil { - tenantIDPtr = &tenantID - } - - roles, err := h.store.ListRoles(c.Request.Context(), tenantIDPtr) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"roles": roles}) -} - -// ListSystemRoles returns all system roles -func (h *RBACHandlers) ListSystemRoles(c *gin.Context) { - roles, err := h.store.ListSystemRoles(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"roles": roles}) -} - -// GetRole returns a role by ID -func (h *RBACHandlers) GetRole(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid role ID"}) - return - } - - role, err := h.store.GetRole(c.Request.Context(), id) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "role not found"}) - return - } - - c.JSON(http.StatusOK, role) -} - -// CreateRole creates a new role -func (h *RBACHandlers) CreateRole(c *gin.Context) { - var role rbac.Role - if err := c.ShouldBindJSON(&role); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - tenantID := rbac.GetTenantID(c) - if tenantID != uuid.Nil { - role.TenantID = &tenantID - } - - if err := h.store.CreateRole(c.Request.Context(), &role); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusCreated, role) -} - -// ============================================================================ -// User Role Endpoints -// ============================================================================ - -// AssignRoleRequest represents a role assignment request -type AssignRoleRequest struct { - UserID string `json:"user_id" binding:"required"` - RoleID string `json:"role_id" binding:"required"` - NamespaceID *string `json:"namespace_id"` - ExpiresAt *string `json:"expires_at"` // RFC3339 format -} - -// AssignRole assigns a role to a user -func (h *RBACHandlers) AssignRole(c *gin.Context) { - var req AssignRoleRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - userID, err := uuid.Parse(req.UserID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user ID"}) - return - } - - roleID, err := uuid.Parse(req.RoleID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid role ID"}) - return - } - - tenantID := rbac.GetTenantID(c) - if tenantID == uuid.Nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) - return - } - - grantorID := rbac.GetUserID(c) - if grantorID == uuid.Nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) - return - } - - userRole := &rbac.UserRole{ - UserID: userID, - RoleID: roleID, - TenantID: tenantID, - } - - if req.NamespaceID != nil { - nsID, err := uuid.Parse(*req.NamespaceID) - if err == nil { - userRole.NamespaceID = &nsID - } - } - - if err := h.service.AssignRoleToUser(c.Request.Context(), userRole, grantorID); err != nil { - if err == rbac.ErrPermissionDenied { - c.JSON(http.StatusForbidden, gin.H{"error": "permission denied"}) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "role assigned successfully"}) -} - -// RevokeRole revokes a role from a user -func (h *RBACHandlers) RevokeRole(c *gin.Context) { - userIDStr := c.Param("userId") - roleIDStr := c.Param("roleId") - - userID, err := uuid.Parse(userIDStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user ID"}) - return - } - - roleID, err := uuid.Parse(roleIDStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid role ID"}) - return - } - - tenantID := rbac.GetTenantID(c) - if tenantID == uuid.Nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) - return - } - - revokerID := rbac.GetUserID(c) - if revokerID == uuid.Nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) - return - } - - var namespaceID *uuid.UUID - if nsIDStr := c.Query("namespace_id"); nsIDStr != "" { - if nsID, err := uuid.Parse(nsIDStr); err == nil { - namespaceID = &nsID - } - } - - if err := h.service.RevokeRoleFromUser(c.Request.Context(), userID, roleID, tenantID, namespaceID, revokerID); err != nil { - if err == rbac.ErrPermissionDenied { - c.JSON(http.StatusForbidden, gin.H{"error": "permission denied"}) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "role revoked successfully"}) -} - -// GetUserRoles returns all roles for a user -func (h *RBACHandlers) GetUserRoles(c *gin.Context) { - userIDStr := c.Param("userId") - userID, err := uuid.Parse(userIDStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user ID"}) - return - } - - tenantID := rbac.GetTenantID(c) - if tenantID == uuid.Nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) - return - } - - roles, err := h.store.GetUserRoles(c.Request.Context(), userID, tenantID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"roles": roles}) -} - -// ============================================================================ -// Permission Endpoints -// ============================================================================ - -// GetEffectivePermissions returns effective permissions for the current user -func (h *RBACHandlers) GetEffectivePermissions(c *gin.Context) { - userID := rbac.GetUserID(c) - tenantID := rbac.GetTenantID(c) - namespaceID := rbac.GetNamespaceID(c) - - if userID == uuid.Nil || tenantID == uuid.Nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) - return - } - - perms, err := h.service.GetEffectivePermissions(c.Request.Context(), userID, tenantID, namespaceID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, perms) -} - -// GetUserContext returns complete context for the current user -func (h *RBACHandlers) GetUserContext(c *gin.Context) { - userID := rbac.GetUserID(c) - tenantID := rbac.GetTenantID(c) - - if userID == uuid.Nil || tenantID == uuid.Nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) - return - } - - ctx, err := h.policyEngine.GetUserContext(c.Request.Context(), userID, tenantID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, ctx) -} - -// CheckPermission checks if user has a specific permission -func (h *RBACHandlers) CheckPermission(c *gin.Context) { - permission := c.Query("permission") - if permission == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "permission parameter required"}) - return - } - - userID := rbac.GetUserID(c) - tenantID := rbac.GetTenantID(c) - namespaceID := rbac.GetNamespaceID(c) - - if userID == uuid.Nil || tenantID == uuid.Nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) - return - } - - hasPermission, err := h.service.HasPermission(c.Request.Context(), userID, tenantID, namespaceID, permission) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "permission": permission, - "has_permission": hasPermission, - }) -} - -// ============================================================================ -// LLM Policy Endpoints -// ============================================================================ - -// ListLLMPolicies returns LLM policies for a tenant -func (h *RBACHandlers) ListLLMPolicies(c *gin.Context) { - tenantID := rbac.GetTenantID(c) - if tenantID == uuid.Nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) - return - } - - policies, err := h.store.ListLLMPolicies(c.Request.Context(), tenantID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"policies": policies}) -} - -// GetLLMPolicy returns an LLM policy by ID -func (h *RBACHandlers) GetLLMPolicy(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid policy ID"}) - return - } - - policy, err := h.store.GetLLMPolicy(c.Request.Context(), id) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "policy not found"}) - return - } - - c.JSON(http.StatusOK, policy) -} - -// CreateLLMPolicy creates a new LLM policy -func (h *RBACHandlers) CreateLLMPolicy(c *gin.Context) { - var policy rbac.LLMPolicy - if err := c.ShouldBindJSON(&policy); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - tenantID := rbac.GetTenantID(c) - if tenantID == uuid.Nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) - return - } - - policy.TenantID = tenantID - if err := h.store.CreateLLMPolicy(c.Request.Context(), &policy); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusCreated, policy) -} - -// UpdateLLMPolicy updates an LLM policy -func (h *RBACHandlers) UpdateLLMPolicy(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid policy ID"}) - return - } - - var policy rbac.LLMPolicy - if err := c.ShouldBindJSON(&policy); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - policy.ID = id - if err := h.store.UpdateLLMPolicy(c.Request.Context(), &policy); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, policy) -} - -// DeleteLLMPolicy deletes an LLM policy -func (h *RBACHandlers) DeleteLLMPolicy(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid policy ID"}) - return - } - - if err := h.store.DeleteLLMPolicy(c.Request.Context(), id); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "policy deleted"}) -} diff --git a/ai-compliance-sdk/internal/api/handlers/rbac_role_handlers.go b/ai-compliance-sdk/internal/api/handlers/rbac_role_handlers.go new file mode 100644 index 0000000..a5a9366 --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/rbac_role_handlers.go @@ -0,0 +1,392 @@ +package handlers + +import ( + "net/http" + + "github.com/breakpilot/ai-compliance-sdk/internal/rbac" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// ============================================================================ +// Role Endpoints +// ============================================================================ + +// ListRoles returns roles for a tenant (including system roles) +func (h *RBACHandlers) ListRoles(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + var tenantIDPtr *uuid.UUID + if tenantID != uuid.Nil { + tenantIDPtr = &tenantID + } + + roles, err := h.store.ListRoles(c.Request.Context(), tenantIDPtr) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"roles": roles}) +} + +// ListSystemRoles returns all system roles +func (h *RBACHandlers) ListSystemRoles(c *gin.Context) { + roles, err := h.store.ListSystemRoles(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"roles": roles}) +} + +// GetRole returns a role by ID +func (h *RBACHandlers) GetRole(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid role ID"}) + return + } + + role, err := h.store.GetRole(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "role not found"}) + return + } + + c.JSON(http.StatusOK, role) +} + +// CreateRole creates a new role +func (h *RBACHandlers) CreateRole(c *gin.Context) { + var role rbac.Role + if err := c.ShouldBindJSON(&role); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + tenantID := rbac.GetTenantID(c) + if tenantID != uuid.Nil { + role.TenantID = &tenantID + } + + if err := h.store.CreateRole(c.Request.Context(), &role); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, role) +} + +// ============================================================================ +// User Role Endpoints +// ============================================================================ + +// AssignRoleRequest represents a role assignment request +type AssignRoleRequest struct { + UserID string `json:"user_id" binding:"required"` + RoleID string `json:"role_id" binding:"required"` + NamespaceID *string `json:"namespace_id"` + ExpiresAt *string `json:"expires_at"` // RFC3339 format +} + +// AssignRole assigns a role to a user +func (h *RBACHandlers) AssignRole(c *gin.Context) { + var req AssignRoleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + userID, err := uuid.Parse(req.UserID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user ID"}) + return + } + + roleID, err := uuid.Parse(req.RoleID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid role ID"}) + return + } + + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + grantorID := rbac.GetUserID(c) + if grantorID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + userRole := &rbac.UserRole{ + UserID: userID, + RoleID: roleID, + TenantID: tenantID, + } + + if req.NamespaceID != nil { + nsID, err := uuid.Parse(*req.NamespaceID) + if err == nil { + userRole.NamespaceID = &nsID + } + } + + if err := h.service.AssignRoleToUser(c.Request.Context(), userRole, grantorID); err != nil { + if err == rbac.ErrPermissionDenied { + c.JSON(http.StatusForbidden, gin.H{"error": "permission denied"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "role assigned successfully"}) +} + +// RevokeRole revokes a role from a user +func (h *RBACHandlers) RevokeRole(c *gin.Context) { + userIDStr := c.Param("userId") + roleIDStr := c.Param("roleId") + + userID, err := uuid.Parse(userIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user ID"}) + return + } + + roleID, err := uuid.Parse(roleIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid role ID"}) + return + } + + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + revokerID := rbac.GetUserID(c) + if revokerID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + var namespaceID *uuid.UUID + if nsIDStr := c.Query("namespace_id"); nsIDStr != "" { + if nsID, err := uuid.Parse(nsIDStr); err == nil { + namespaceID = &nsID + } + } + + if err := h.service.RevokeRoleFromUser(c.Request.Context(), userID, roleID, tenantID, namespaceID, revokerID); err != nil { + if err == rbac.ErrPermissionDenied { + c.JSON(http.StatusForbidden, gin.H{"error": "permission denied"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "role revoked successfully"}) +} + +// GetUserRoles returns all roles for a user +func (h *RBACHandlers) GetUserRoles(c *gin.Context) { + userIDStr := c.Param("userId") + userID, err := uuid.Parse(userIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user ID"}) + return + } + + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + roles, err := h.store.GetUserRoles(c.Request.Context(), userID, tenantID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"roles": roles}) +} + +// ============================================================================ +// Permission Endpoints +// ============================================================================ + +// GetEffectivePermissions returns effective permissions for the current user +func (h *RBACHandlers) GetEffectivePermissions(c *gin.Context) { + userID := rbac.GetUserID(c) + tenantID := rbac.GetTenantID(c) + namespaceID := rbac.GetNamespaceID(c) + + if userID == uuid.Nil || tenantID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + perms, err := h.service.GetEffectivePermissions(c.Request.Context(), userID, tenantID, namespaceID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, perms) +} + +// GetUserContext returns complete context for the current user +func (h *RBACHandlers) GetUserContext(c *gin.Context) { + userID := rbac.GetUserID(c) + tenantID := rbac.GetTenantID(c) + + if userID == uuid.Nil || tenantID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + ctx, err := h.policyEngine.GetUserContext(c.Request.Context(), userID, tenantID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, ctx) +} + +// CheckPermission checks if user has a specific permission +func (h *RBACHandlers) CheckPermission(c *gin.Context) { + permission := c.Query("permission") + if permission == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "permission parameter required"}) + return + } + + userID := rbac.GetUserID(c) + tenantID := rbac.GetTenantID(c) + namespaceID := rbac.GetNamespaceID(c) + + if userID == uuid.Nil || tenantID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + hasPermission, err := h.service.HasPermission(c.Request.Context(), userID, tenantID, namespaceID, permission) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "permission": permission, + "has_permission": hasPermission, + }) +} + +// ============================================================================ +// LLM Policy Endpoints +// ============================================================================ + +// ListLLMPolicies returns LLM policies for a tenant +func (h *RBACHandlers) ListLLMPolicies(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + policies, err := h.store.ListLLMPolicies(c.Request.Context(), tenantID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"policies": policies}) +} + +// GetLLMPolicy returns an LLM policy by ID +func (h *RBACHandlers) GetLLMPolicy(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid policy ID"}) + return + } + + policy, err := h.store.GetLLMPolicy(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "policy not found"}) + return + } + + c.JSON(http.StatusOK, policy) +} + +// CreateLLMPolicy creates a new LLM policy +func (h *RBACHandlers) CreateLLMPolicy(c *gin.Context) { + var policy rbac.LLMPolicy + if err := c.ShouldBindJSON(&policy); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + policy.TenantID = tenantID + if err := h.store.CreateLLMPolicy(c.Request.Context(), &policy); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, policy) +} + +// UpdateLLMPolicy updates an LLM policy +func (h *RBACHandlers) UpdateLLMPolicy(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid policy ID"}) + return + } + + var policy rbac.LLMPolicy + if err := c.ShouldBindJSON(&policy); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + policy.ID = id + if err := h.store.UpdateLLMPolicy(c.Request.Context(), &policy); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, policy) +} + +// DeleteLLMPolicy deletes an LLM policy +func (h *RBACHandlers) DeleteLLMPolicy(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid policy ID"}) + return + } + + if err := h.store.DeleteLLMPolicy(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "policy deleted"}) +} diff --git a/ai-compliance-sdk/internal/api/handlers/whistleblower_handlers.go b/ai-compliance-sdk/internal/api/handlers/whistleblower_handlers.go index 3805686..f08ea95 100644 --- a/ai-compliance-sdk/internal/api/handlers/whistleblower_handlers.go +++ b/ai-compliance-sdk/internal/api/handlers/whistleblower_handlers.go @@ -33,7 +33,6 @@ func (h *WhistleblowerHandlers) SubmitReport(c *gin.Context) { return } - // Get tenant ID from header or query param (public endpoint still needs tenant context) tenantIDStr := c.GetHeader("X-Tenant-ID") if tenantIDStr == "" { tenantIDStr = c.Query("tenant_id") @@ -57,7 +56,6 @@ func (h *WhistleblowerHandlers) SubmitReport(c *gin.Context) { IsAnonymous: req.IsAnonymous, } - // Only set reporter info if not anonymous if !req.IsAnonymous { report.ReporterName = req.ReporterName report.ReporterEmail = req.ReporterEmail @@ -69,7 +67,6 @@ func (h *WhistleblowerHandlers) SubmitReport(c *gin.Context) { return } - // Return reference number and access key (access key only shown ONCE!) c.JSON(http.StatusCreated, whistleblower.PublicReportResponse{ ReferenceNumber: report.ReferenceNumber, AccessKey: report.AccessKey, @@ -95,7 +92,6 @@ func (h *WhistleblowerHandlers) GetReportByAccessKey(c *gin.Context) { return } - // Return limited fields for public access (no access_key, no internal details) c.JSON(http.StatusOK, gin.H{ "reference_number": report.ReferenceNumber, "category": report.Category, @@ -199,11 +195,9 @@ func (h *WhistleblowerHandlers) GetReport(c *gin.Context) { return } - // Get messages and measures for full view messages, _ := h.store.ListMessages(c.Request.Context(), id) measures, _ := h.store.ListMeasures(c.Request.Context(), id) - // Do not expose access key to admin either report.AccessKey = "" c.JSON(http.StatusOK, gin.H{ @@ -288,251 +282,3 @@ func (h *WhistleblowerHandlers) DeleteReport(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "report deleted"}) } - -// AcknowledgeReport acknowledges a report (within 7-day HinSchG deadline) -// POST /sdk/v1/whistleblower/reports/:id/acknowledge -func (h *WhistleblowerHandlers) AcknowledgeReport(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"}) - return - } - - report, err := h.store.GetReport(c.Request.Context(), id) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if report == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "report not found"}) - return - } - - if report.AcknowledgedAt != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "report already acknowledged"}) - return - } - - userID := rbac.GetUserID(c) - - if err := h.store.AcknowledgeReport(c.Request.Context(), id, userID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - // Optionally send acknowledgment message to reporter - var req whistleblower.AcknowledgeRequest - if err := c.ShouldBindJSON(&req); err == nil && req.Message != "" { - msg := &whistleblower.AnonymousMessage{ - ReportID: id, - Direction: whistleblower.MessageDirectionAdminToReporter, - Content: req.Message, - } - h.store.AddMessage(c.Request.Context(), msg) - } - - // Check if deadline was met - isOverdue := time.Now().UTC().After(report.DeadlineAcknowledgment) - - c.JSON(http.StatusOK, gin.H{ - "message": "report acknowledged", - "is_overdue": isOverdue, - }) -} - -// StartInvestigation changes the report status to investigation -// POST /sdk/v1/whistleblower/reports/:id/investigate -func (h *WhistleblowerHandlers) StartInvestigation(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"}) - return - } - - report, err := h.store.GetReport(c.Request.Context(), id) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if report == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "report not found"}) - return - } - - userID := rbac.GetUserID(c) - - report.Status = whistleblower.ReportStatusInvestigation - report.AuditTrail = append(report.AuditTrail, whistleblower.AuditEntry{ - Timestamp: time.Now().UTC(), - Action: "investigation_started", - UserID: userID.String(), - Details: "Investigation started", - }) - - if err := h.store.UpdateReport(c.Request.Context(), report); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "message": "investigation started", - "report": report, - }) -} - -// AddMeasure adds a corrective measure to a report -// POST /sdk/v1/whistleblower/reports/:id/measures -func (h *WhistleblowerHandlers) AddMeasure(c *gin.Context) { - reportID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"}) - return - } - - // Verify report exists - report, err := h.store.GetReport(c.Request.Context(), reportID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if report == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "report not found"}) - return - } - - var req whistleblower.AddMeasureRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - userID := rbac.GetUserID(c) - - measure := &whistleblower.Measure{ - ReportID: reportID, - Title: req.Title, - Description: req.Description, - 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 - } - - // Update report status to measures_taken if not already - if report.Status != whistleblower.ReportStatusMeasuresTaken && - report.Status != whistleblower.ReportStatusClosed { - report.Status = whistleblower.ReportStatusMeasuresTaken - report.AuditTrail = append(report.AuditTrail, whistleblower.AuditEntry{ - Timestamp: time.Now().UTC(), - Action: "measure_added", - UserID: userID.String(), - Details: "Corrective measure added: " + req.Title, - }) - h.store.UpdateReport(c.Request.Context(), report) - } - - c.JSON(http.StatusCreated, gin.H{"measure": measure}) -} - -// CloseReport closes a report with a resolution -// POST /sdk/v1/whistleblower/reports/:id/close -func (h *WhistleblowerHandlers) CloseReport(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"}) - return - } - - var req whistleblower.CloseReportRequest - 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.CloseReport(c.Request.Context(), id, userID, req.Resolution); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "report closed"}) -} - -// SendAdminMessage sends a message from admin to reporter -// POST /sdk/v1/whistleblower/reports/:id/messages -func (h *WhistleblowerHandlers) SendAdminMessage(c *gin.Context) { - reportID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"}) - return - } - - // Verify report exists - report, err := h.store.GetReport(c.Request.Context(), reportID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if report == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "report not found"}) - return - } - - var req whistleblower.SendMessageRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - msg := &whistleblower.AnonymousMessage{ - ReportID: reportID, - Direction: whistleblower.MessageDirectionAdminToReporter, - Content: req.Content, - } - - if err := h.store.AddMessage(c.Request.Context(), msg); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusCreated, gin.H{"message": msg}) -} - -// ListMessages lists messages for a report -// GET /sdk/v1/whistleblower/reports/:id/messages -func (h *WhistleblowerHandlers) ListMessages(c *gin.Context) { - reportID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"}) - return - } - - messages, err := h.store.ListMessages(c.Request.Context(), reportID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "messages": messages, - "total": len(messages), - }) -} - -// GetStatistics returns whistleblower statistics for the tenant -// GET /sdk/v1/whistleblower/statistics -func (h *WhistleblowerHandlers) 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) -} diff --git a/ai-compliance-sdk/internal/api/handlers/whistleblower_workflow_handlers.go b/ai-compliance-sdk/internal/api/handlers/whistleblower_workflow_handlers.go new file mode 100644 index 0000000..f866587 --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/whistleblower_workflow_handlers.go @@ -0,0 +1,254 @@ +package handlers + +import ( + "net/http" + "time" + + "github.com/breakpilot/ai-compliance-sdk/internal/rbac" + "github.com/breakpilot/ai-compliance-sdk/internal/whistleblower" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// AcknowledgeReport acknowledges a report (within 7-day HinSchG deadline) +// POST /sdk/v1/whistleblower/reports/:id/acknowledge +func (h *WhistleblowerHandlers) AcknowledgeReport(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"}) + return + } + + report, err := h.store.GetReport(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if report == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "report not found"}) + return + } + + if report.AcknowledgedAt != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "report already acknowledged"}) + return + } + + userID := rbac.GetUserID(c) + + if err := h.store.AcknowledgeReport(c.Request.Context(), id, userID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var req whistleblower.AcknowledgeRequest + if err := c.ShouldBindJSON(&req); err == nil && req.Message != "" { + msg := &whistleblower.AnonymousMessage{ + ReportID: id, + Direction: whistleblower.MessageDirectionAdminToReporter, + Content: req.Message, + } + h.store.AddMessage(c.Request.Context(), msg) + } + + isOverdue := time.Now().UTC().After(report.DeadlineAcknowledgment) + + c.JSON(http.StatusOK, gin.H{ + "message": "report acknowledged", + "is_overdue": isOverdue, + }) +} + +// StartInvestigation changes the report status to investigation +// POST /sdk/v1/whistleblower/reports/:id/investigate +func (h *WhistleblowerHandlers) StartInvestigation(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"}) + return + } + + report, err := h.store.GetReport(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if report == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "report not found"}) + return + } + + userID := rbac.GetUserID(c) + + report.Status = whistleblower.ReportStatusInvestigation + report.AuditTrail = append(report.AuditTrail, whistleblower.AuditEntry{ + Timestamp: time.Now().UTC(), + Action: "investigation_started", + UserID: userID.String(), + Details: "Investigation started", + }) + + if err := h.store.UpdateReport(c.Request.Context(), report); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "investigation started", + "report": report, + }) +} + +// AddMeasure adds a corrective measure to a report +// POST /sdk/v1/whistleblower/reports/:id/measures +func (h *WhistleblowerHandlers) AddMeasure(c *gin.Context) { + reportID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"}) + return + } + + report, err := h.store.GetReport(c.Request.Context(), reportID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if report == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "report not found"}) + return + } + + var req whistleblower.AddMeasureRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + userID := rbac.GetUserID(c) + + measure := &whistleblower.Measure{ + ReportID: reportID, + Title: req.Title, + Description: req.Description, + 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 + } + + if report.Status != whistleblower.ReportStatusMeasuresTaken && + report.Status != whistleblower.ReportStatusClosed { + report.Status = whistleblower.ReportStatusMeasuresTaken + report.AuditTrail = append(report.AuditTrail, whistleblower.AuditEntry{ + Timestamp: time.Now().UTC(), + Action: "measure_added", + UserID: userID.String(), + Details: "Corrective measure added: " + req.Title, + }) + h.store.UpdateReport(c.Request.Context(), report) + } + + c.JSON(http.StatusCreated, gin.H{"measure": measure}) +} + +// CloseReport closes a report with a resolution +// POST /sdk/v1/whistleblower/reports/:id/close +func (h *WhistleblowerHandlers) CloseReport(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"}) + return + } + + var req whistleblower.CloseReportRequest + 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.CloseReport(c.Request.Context(), id, userID, req.Resolution); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "report closed"}) +} + +// SendAdminMessage sends a message from admin to reporter +// POST /sdk/v1/whistleblower/reports/:id/messages +func (h *WhistleblowerHandlers) SendAdminMessage(c *gin.Context) { + reportID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"}) + return + } + + report, err := h.store.GetReport(c.Request.Context(), reportID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if report == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "report not found"}) + return + } + + var req whistleblower.SendMessageRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + msg := &whistleblower.AnonymousMessage{ + ReportID: reportID, + Direction: whistleblower.MessageDirectionAdminToReporter, + Content: req.Content, + } + + if err := h.store.AddMessage(c.Request.Context(), msg); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{"message": msg}) +} + +// ListMessages lists messages for a report +// GET /sdk/v1/whistleblower/reports/:id/messages +func (h *WhistleblowerHandlers) ListMessages(c *gin.Context) { + reportID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"}) + return + } + + messages, err := h.store.ListMessages(c.Request.Context(), reportID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "messages": messages, + "total": len(messages), + }) +} + +// GetStatistics returns whistleblower statistics for the tenant +// GET /sdk/v1/whistleblower/statistics +func (h *WhistleblowerHandlers) 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) +} diff --git a/ai-compliance-sdk/internal/rbac/store.go b/ai-compliance-sdk/internal/rbac/store.go index 0671f48..90346c3 100644 --- a/ai-compliance-sdk/internal/rbac/store.go +++ b/ai-compliance-sdk/internal/rbac/store.go @@ -7,7 +7,6 @@ import ( "time" "github.com/google/uuid" - "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) @@ -278,374 +277,3 @@ func (s *Store) ListNamespaces(ctx context.Context, tenantID uuid.UUID) ([]*Name return namespaces, nil } - -// ============================================================================ -// Role Operations -// ============================================================================ - -// CreateRole creates a new role -func (s *Store) CreateRole(ctx context.Context, role *Role) error { - role.ID = uuid.New() - role.CreatedAt = time.Now().UTC() - role.UpdatedAt = role.CreatedAt - - _, err := s.pool.Exec(ctx, ` - INSERT INTO compliance_roles (id, tenant_id, name, description, permissions, is_system_role, hierarchy_level, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - `, role.ID, role.TenantID, role.Name, role.Description, role.Permissions, role.IsSystemRole, role.HierarchyLevel, role.CreatedAt, role.UpdatedAt) - - return err -} - -// GetRole retrieves a role by ID -func (s *Store) GetRole(ctx context.Context, id uuid.UUID) (*Role, error) { - var role Role - - err := s.pool.QueryRow(ctx, ` - SELECT id, tenant_id, name, description, permissions, is_system_role, hierarchy_level, created_at, updated_at - FROM compliance_roles - WHERE id = $1 - `, id).Scan( - &role.ID, &role.TenantID, &role.Name, &role.Description, - &role.Permissions, &role.IsSystemRole, &role.HierarchyLevel, - &role.CreatedAt, &role.UpdatedAt, - ) - - return &role, err -} - -// GetRoleByName retrieves a role by tenant and name -func (s *Store) GetRoleByName(ctx context.Context, tenantID *uuid.UUID, name string) (*Role, error) { - var role Role - - query := ` - SELECT id, tenant_id, name, description, permissions, is_system_role, hierarchy_level, created_at, updated_at - FROM compliance_roles - WHERE name = $1 AND (tenant_id = $2 OR (tenant_id IS NULL AND is_system_role = TRUE)) - ` - - err := s.pool.QueryRow(ctx, query, name, tenantID).Scan( - &role.ID, &role.TenantID, &role.Name, &role.Description, - &role.Permissions, &role.IsSystemRole, &role.HierarchyLevel, - &role.CreatedAt, &role.UpdatedAt, - ) - - return &role, err -} - -// ListRoles lists roles for a tenant (including system roles) -func (s *Store) ListRoles(ctx context.Context, tenantID *uuid.UUID) ([]*Role, error) { - rows, err := s.pool.Query(ctx, ` - SELECT id, tenant_id, name, description, permissions, is_system_role, hierarchy_level, created_at, updated_at - FROM compliance_roles - WHERE tenant_id = $1 OR is_system_role = TRUE - ORDER BY hierarchy_level, name - `, tenantID) - if err != nil { - return nil, err - } - defer rows.Close() - - var roles []*Role - for rows.Next() { - var role Role - err := rows.Scan( - &role.ID, &role.TenantID, &role.Name, &role.Description, - &role.Permissions, &role.IsSystemRole, &role.HierarchyLevel, - &role.CreatedAt, &role.UpdatedAt, - ) - if err != nil { - continue - } - roles = append(roles, &role) - } - - return roles, nil -} - -// ListSystemRoles lists all system roles -func (s *Store) ListSystemRoles(ctx context.Context) ([]*Role, error) { - rows, err := s.pool.Query(ctx, ` - SELECT id, tenant_id, name, description, permissions, is_system_role, hierarchy_level, created_at, updated_at - FROM compliance_roles - WHERE is_system_role = TRUE - ORDER BY hierarchy_level, name - `) - if err != nil { - return nil, err - } - defer rows.Close() - - var roles []*Role - for rows.Next() { - var role Role - err := rows.Scan( - &role.ID, &role.TenantID, &role.Name, &role.Description, - &role.Permissions, &role.IsSystemRole, &role.HierarchyLevel, - &role.CreatedAt, &role.UpdatedAt, - ) - if err != nil { - continue - } - roles = append(roles, &role) - } - - return roles, nil -} - -// ============================================================================ -// User Role Operations -// ============================================================================ - -// AssignRole assigns a role to a user -func (s *Store) AssignRole(ctx context.Context, ur *UserRole) error { - ur.ID = uuid.New() - ur.CreatedAt = time.Now().UTC() - - _, err := s.pool.Exec(ctx, ` - INSERT INTO compliance_user_roles (id, user_id, role_id, tenant_id, namespace_id, granted_by, expires_at, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - ON CONFLICT (user_id, role_id, tenant_id, namespace_id) DO UPDATE SET - granted_by = EXCLUDED.granted_by, - expires_at = EXCLUDED.expires_at - `, ur.ID, ur.UserID, ur.RoleID, ur.TenantID, ur.NamespaceID, ur.GrantedBy, ur.ExpiresAt, ur.CreatedAt) - - return err -} - -// RevokeRole revokes a role from a user -func (s *Store) RevokeRole(ctx context.Context, userID, roleID, tenantID uuid.UUID, namespaceID *uuid.UUID) error { - _, err := s.pool.Exec(ctx, ` - DELETE FROM compliance_user_roles - WHERE user_id = $1 AND role_id = $2 AND tenant_id = $3 AND (namespace_id = $4 OR (namespace_id IS NULL AND $4 IS NULL)) - `, userID, roleID, tenantID, namespaceID) - - return err -} - -// GetUserRoles retrieves all roles for a user in a tenant -func (s *Store) GetUserRoles(ctx context.Context, userID, tenantID uuid.UUID) ([]*UserRole, error) { - rows, err := s.pool.Query(ctx, ` - SELECT ur.id, ur.user_id, ur.role_id, ur.tenant_id, ur.namespace_id, ur.granted_by, ur.expires_at, ur.created_at, - r.name as role_name, r.permissions as role_permissions, - n.name as namespace_name - FROM compliance_user_roles ur - JOIN compliance_roles r ON ur.role_id = r.id - LEFT JOIN compliance_namespaces n ON ur.namespace_id = n.id - WHERE ur.user_id = $1 AND ur.tenant_id = $2 - AND (ur.expires_at IS NULL OR ur.expires_at > NOW()) - ORDER BY r.hierarchy_level, r.name - `, userID, tenantID) - if err != nil { - return nil, err - } - defer rows.Close() - - var userRoles []*UserRole - for rows.Next() { - var ur UserRole - var namespaceName *string - - err := rows.Scan( - &ur.ID, &ur.UserID, &ur.RoleID, &ur.TenantID, &ur.NamespaceID, - &ur.GrantedBy, &ur.ExpiresAt, &ur.CreatedAt, - &ur.RoleName, &ur.RolePermissions, &namespaceName, - ) - if err != nil { - continue - } - - if namespaceName != nil { - ur.NamespaceName = *namespaceName - } - - userRoles = append(userRoles, &ur) - } - - return userRoles, nil -} - -// GetUserRolesForNamespace retrieves roles for a user in a specific namespace -func (s *Store) GetUserRolesForNamespace(ctx context.Context, userID, tenantID uuid.UUID, namespaceID *uuid.UUID) ([]*UserRole, error) { - rows, err := s.pool.Query(ctx, ` - SELECT ur.id, ur.user_id, ur.role_id, ur.tenant_id, ur.namespace_id, ur.granted_by, ur.expires_at, ur.created_at, - r.name as role_name, r.permissions as role_permissions - FROM compliance_user_roles ur - JOIN compliance_roles r ON ur.role_id = r.id - WHERE ur.user_id = $1 AND ur.tenant_id = $2 - AND (ur.namespace_id = $3 OR ur.namespace_id IS NULL) - AND (ur.expires_at IS NULL OR ur.expires_at > NOW()) - ORDER BY r.hierarchy_level, r.name - `, userID, tenantID, namespaceID) - if err != nil { - return nil, err - } - defer rows.Close() - - var userRoles []*UserRole - for rows.Next() { - var ur UserRole - err := rows.Scan( - &ur.ID, &ur.UserID, &ur.RoleID, &ur.TenantID, &ur.NamespaceID, - &ur.GrantedBy, &ur.ExpiresAt, &ur.CreatedAt, - &ur.RoleName, &ur.RolePermissions, - ) - if err != nil { - continue - } - userRoles = append(userRoles, &ur) - } - - return userRoles, nil -} - -// ============================================================================ -// LLM Policy Operations -// ============================================================================ - -// CreateLLMPolicy creates a new LLM policy -func (s *Store) CreateLLMPolicy(ctx context.Context, policy *LLMPolicy) error { - policy.ID = uuid.New() - policy.CreatedAt = time.Now().UTC() - policy.UpdatedAt = policy.CreatedAt - - _, err := s.pool.Exec(ctx, ` - INSERT INTO compliance_llm_policies ( - id, tenant_id, namespace_id, name, description, - allowed_data_categories, blocked_data_categories, - require_pii_redaction, pii_redaction_level, - allowed_models, max_tokens_per_request, max_requests_per_day, max_requests_per_hour, - is_active, priority, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) - `, - policy.ID, policy.TenantID, policy.NamespaceID, policy.Name, policy.Description, - policy.AllowedDataCategories, policy.BlockedDataCategories, - policy.RequirePIIRedaction, policy.PIIRedactionLevel, - policy.AllowedModels, policy.MaxTokensPerRequest, policy.MaxRequestsPerDay, policy.MaxRequestsPerHour, - policy.IsActive, policy.Priority, policy.CreatedAt, policy.UpdatedAt, - ) - - return err -} - -// GetLLMPolicy retrieves an LLM policy by ID -func (s *Store) GetLLMPolicy(ctx context.Context, id uuid.UUID) (*LLMPolicy, error) { - var policy LLMPolicy - - err := s.pool.QueryRow(ctx, ` - SELECT id, tenant_id, namespace_id, name, description, - allowed_data_categories, blocked_data_categories, - require_pii_redaction, pii_redaction_level, - allowed_models, max_tokens_per_request, max_requests_per_day, max_requests_per_hour, - is_active, priority, created_at, updated_at - FROM compliance_llm_policies - WHERE id = $1 - `, id).Scan( - &policy.ID, &policy.TenantID, &policy.NamespaceID, &policy.Name, &policy.Description, - &policy.AllowedDataCategories, &policy.BlockedDataCategories, - &policy.RequirePIIRedaction, &policy.PIIRedactionLevel, - &policy.AllowedModels, &policy.MaxTokensPerRequest, &policy.MaxRequestsPerDay, &policy.MaxRequestsPerHour, - &policy.IsActive, &policy.Priority, &policy.CreatedAt, &policy.UpdatedAt, - ) - - return &policy, err -} - -// GetEffectiveLLMPolicy retrieves the effective LLM policy for a namespace -func (s *Store) GetEffectiveLLMPolicy(ctx context.Context, tenantID uuid.UUID, namespaceID *uuid.UUID) (*LLMPolicy, error) { - var policy LLMPolicy - - // Get most specific active policy (namespace-specific or tenant-wide) - err := s.pool.QueryRow(ctx, ` - SELECT id, tenant_id, namespace_id, name, description, - allowed_data_categories, blocked_data_categories, - require_pii_redaction, pii_redaction_level, - allowed_models, max_tokens_per_request, max_requests_per_day, max_requests_per_hour, - is_active, priority, created_at, updated_at - FROM compliance_llm_policies - WHERE tenant_id = $1 - AND is_active = TRUE - AND (namespace_id = $2 OR namespace_id IS NULL) - ORDER BY - CASE WHEN namespace_id = $2 THEN 0 ELSE 1 END, - priority ASC - LIMIT 1 - `, tenantID, namespaceID).Scan( - &policy.ID, &policy.TenantID, &policy.NamespaceID, &policy.Name, &policy.Description, - &policy.AllowedDataCategories, &policy.BlockedDataCategories, - &policy.RequirePIIRedaction, &policy.PIIRedactionLevel, - &policy.AllowedModels, &policy.MaxTokensPerRequest, &policy.MaxRequestsPerDay, &policy.MaxRequestsPerHour, - &policy.IsActive, &policy.Priority, &policy.CreatedAt, &policy.UpdatedAt, - ) - - if err == pgx.ErrNoRows { - return nil, nil // No policy = allow all - } - - return &policy, err -} - -// ListLLMPolicies lists LLM policies for a tenant -func (s *Store) ListLLMPolicies(ctx context.Context, tenantID uuid.UUID) ([]*LLMPolicy, error) { - rows, err := s.pool.Query(ctx, ` - SELECT id, tenant_id, namespace_id, name, description, - allowed_data_categories, blocked_data_categories, - require_pii_redaction, pii_redaction_level, - allowed_models, max_tokens_per_request, max_requests_per_day, max_requests_per_hour, - is_active, priority, created_at, updated_at - FROM compliance_llm_policies - WHERE tenant_id = $1 - ORDER BY priority, name - `, tenantID) - if err != nil { - return nil, err - } - defer rows.Close() - - var policies []*LLMPolicy - for rows.Next() { - var policy LLMPolicy - err := rows.Scan( - &policy.ID, &policy.TenantID, &policy.NamespaceID, &policy.Name, &policy.Description, - &policy.AllowedDataCategories, &policy.BlockedDataCategories, - &policy.RequirePIIRedaction, &policy.PIIRedactionLevel, - &policy.AllowedModels, &policy.MaxTokensPerRequest, &policy.MaxRequestsPerDay, &policy.MaxRequestsPerHour, - &policy.IsActive, &policy.Priority, &policy.CreatedAt, &policy.UpdatedAt, - ) - if err != nil { - continue - } - policies = append(policies, &policy) - } - - return policies, nil -} - -// UpdateLLMPolicy updates an LLM policy -func (s *Store) UpdateLLMPolicy(ctx context.Context, policy *LLMPolicy) error { - policy.UpdatedAt = time.Now().UTC() - - _, err := s.pool.Exec(ctx, ` - UPDATE compliance_llm_policies SET - name = $2, description = $3, - allowed_data_categories = $4, blocked_data_categories = $5, - require_pii_redaction = $6, pii_redaction_level = $7, - allowed_models = $8, max_tokens_per_request = $9, max_requests_per_day = $10, max_requests_per_hour = $11, - is_active = $12, priority = $13, updated_at = $14 - WHERE id = $1 - `, - policy.ID, policy.Name, policy.Description, - policy.AllowedDataCategories, policy.BlockedDataCategories, - policy.RequirePIIRedaction, policy.PIIRedactionLevel, - policy.AllowedModels, policy.MaxTokensPerRequest, policy.MaxRequestsPerDay, policy.MaxRequestsPerHour, - policy.IsActive, policy.Priority, policy.UpdatedAt, - ) - - return err -} - -// DeleteLLMPolicy deletes an LLM policy -func (s *Store) DeleteLLMPolicy(ctx context.Context, id uuid.UUID) error { - _, err := s.pool.Exec(ctx, `DELETE FROM compliance_llm_policies WHERE id = $1`, id) - return err -} diff --git a/ai-compliance-sdk/internal/rbac/store_roles.go b/ai-compliance-sdk/internal/rbac/store_roles.go new file mode 100644 index 0000000..9bd224c --- /dev/null +++ b/ai-compliance-sdk/internal/rbac/store_roles.go @@ -0,0 +1,379 @@ +package rbac + +import ( + "context" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// ============================================================================ +// Role Operations +// ============================================================================ + +// CreateRole creates a new role +func (s *Store) CreateRole(ctx context.Context, role *Role) error { + role.ID = uuid.New() + role.CreatedAt = time.Now().UTC() + role.UpdatedAt = role.CreatedAt + + _, err := s.pool.Exec(ctx, ` + INSERT INTO compliance_roles (id, tenant_id, name, description, permissions, is_system_role, hierarchy_level, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `, role.ID, role.TenantID, role.Name, role.Description, role.Permissions, role.IsSystemRole, role.HierarchyLevel, role.CreatedAt, role.UpdatedAt) + + return err +} + +// GetRole retrieves a role by ID +func (s *Store) GetRole(ctx context.Context, id uuid.UUID) (*Role, error) { + var role Role + + err := s.pool.QueryRow(ctx, ` + SELECT id, tenant_id, name, description, permissions, is_system_role, hierarchy_level, created_at, updated_at + FROM compliance_roles + WHERE id = $1 + `, id).Scan( + &role.ID, &role.TenantID, &role.Name, &role.Description, + &role.Permissions, &role.IsSystemRole, &role.HierarchyLevel, + &role.CreatedAt, &role.UpdatedAt, + ) + + return &role, err +} + +// GetRoleByName retrieves a role by tenant and name +func (s *Store) GetRoleByName(ctx context.Context, tenantID *uuid.UUID, name string) (*Role, error) { + var role Role + + query := ` + SELECT id, tenant_id, name, description, permissions, is_system_role, hierarchy_level, created_at, updated_at + FROM compliance_roles + WHERE name = $1 AND (tenant_id = $2 OR (tenant_id IS NULL AND is_system_role = TRUE)) + ` + + err := s.pool.QueryRow(ctx, query, name, tenantID).Scan( + &role.ID, &role.TenantID, &role.Name, &role.Description, + &role.Permissions, &role.IsSystemRole, &role.HierarchyLevel, + &role.CreatedAt, &role.UpdatedAt, + ) + + return &role, err +} + +// ListRoles lists roles for a tenant (including system roles) +func (s *Store) ListRoles(ctx context.Context, tenantID *uuid.UUID) ([]*Role, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, tenant_id, name, description, permissions, is_system_role, hierarchy_level, created_at, updated_at + FROM compliance_roles + WHERE tenant_id = $1 OR is_system_role = TRUE + ORDER BY hierarchy_level, name + `, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + + var roles []*Role + for rows.Next() { + var role Role + err := rows.Scan( + &role.ID, &role.TenantID, &role.Name, &role.Description, + &role.Permissions, &role.IsSystemRole, &role.HierarchyLevel, + &role.CreatedAt, &role.UpdatedAt, + ) + if err != nil { + continue + } + roles = append(roles, &role) + } + + return roles, nil +} + +// ListSystemRoles lists all system roles +func (s *Store) ListSystemRoles(ctx context.Context) ([]*Role, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, tenant_id, name, description, permissions, is_system_role, hierarchy_level, created_at, updated_at + FROM compliance_roles + WHERE is_system_role = TRUE + ORDER BY hierarchy_level, name + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var roles []*Role + for rows.Next() { + var role Role + err := rows.Scan( + &role.ID, &role.TenantID, &role.Name, &role.Description, + &role.Permissions, &role.IsSystemRole, &role.HierarchyLevel, + &role.CreatedAt, &role.UpdatedAt, + ) + if err != nil { + continue + } + roles = append(roles, &role) + } + + return roles, nil +} + +// ============================================================================ +// User Role Operations +// ============================================================================ + +// AssignRole assigns a role to a user +func (s *Store) AssignRole(ctx context.Context, ur *UserRole) error { + ur.ID = uuid.New() + ur.CreatedAt = time.Now().UTC() + + _, err := s.pool.Exec(ctx, ` + INSERT INTO compliance_user_roles (id, user_id, role_id, tenant_id, namespace_id, granted_by, expires_at, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (user_id, role_id, tenant_id, namespace_id) DO UPDATE SET + granted_by = EXCLUDED.granted_by, + expires_at = EXCLUDED.expires_at + `, ur.ID, ur.UserID, ur.RoleID, ur.TenantID, ur.NamespaceID, ur.GrantedBy, ur.ExpiresAt, ur.CreatedAt) + + return err +} + +// RevokeRole revokes a role from a user +func (s *Store) RevokeRole(ctx context.Context, userID, roleID, tenantID uuid.UUID, namespaceID *uuid.UUID) error { + _, err := s.pool.Exec(ctx, ` + DELETE FROM compliance_user_roles + WHERE user_id = $1 AND role_id = $2 AND tenant_id = $3 AND (namespace_id = $4 OR (namespace_id IS NULL AND $4 IS NULL)) + `, userID, roleID, tenantID, namespaceID) + + return err +} + +// GetUserRoles retrieves all roles for a user in a tenant +func (s *Store) GetUserRoles(ctx context.Context, userID, tenantID uuid.UUID) ([]*UserRole, error) { + rows, err := s.pool.Query(ctx, ` + SELECT ur.id, ur.user_id, ur.role_id, ur.tenant_id, ur.namespace_id, ur.granted_by, ur.expires_at, ur.created_at, + r.name as role_name, r.permissions as role_permissions, + n.name as namespace_name + FROM compliance_user_roles ur + JOIN compliance_roles r ON ur.role_id = r.id + LEFT JOIN compliance_namespaces n ON ur.namespace_id = n.id + WHERE ur.user_id = $1 AND ur.tenant_id = $2 + AND (ur.expires_at IS NULL OR ur.expires_at > NOW()) + ORDER BY r.hierarchy_level, r.name + `, userID, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + + var userRoles []*UserRole + for rows.Next() { + var ur UserRole + var namespaceName *string + + err := rows.Scan( + &ur.ID, &ur.UserID, &ur.RoleID, &ur.TenantID, &ur.NamespaceID, + &ur.GrantedBy, &ur.ExpiresAt, &ur.CreatedAt, + &ur.RoleName, &ur.RolePermissions, &namespaceName, + ) + if err != nil { + continue + } + + if namespaceName != nil { + ur.NamespaceName = *namespaceName + } + + userRoles = append(userRoles, &ur) + } + + return userRoles, nil +} + +// GetUserRolesForNamespace retrieves roles for a user in a specific namespace +func (s *Store) GetUserRolesForNamespace(ctx context.Context, userID, tenantID uuid.UUID, namespaceID *uuid.UUID) ([]*UserRole, error) { + rows, err := s.pool.Query(ctx, ` + SELECT ur.id, ur.user_id, ur.role_id, ur.tenant_id, ur.namespace_id, ur.granted_by, ur.expires_at, ur.created_at, + r.name as role_name, r.permissions as role_permissions + FROM compliance_user_roles ur + JOIN compliance_roles r ON ur.role_id = r.id + WHERE ur.user_id = $1 AND ur.tenant_id = $2 + AND (ur.namespace_id = $3 OR ur.namespace_id IS NULL) + AND (ur.expires_at IS NULL OR ur.expires_at > NOW()) + ORDER BY r.hierarchy_level, r.name + `, userID, tenantID, namespaceID) + if err != nil { + return nil, err + } + defer rows.Close() + + var userRoles []*UserRole + for rows.Next() { + var ur UserRole + err := rows.Scan( + &ur.ID, &ur.UserID, &ur.RoleID, &ur.TenantID, &ur.NamespaceID, + &ur.GrantedBy, &ur.ExpiresAt, &ur.CreatedAt, + &ur.RoleName, &ur.RolePermissions, + ) + if err != nil { + continue + } + userRoles = append(userRoles, &ur) + } + + return userRoles, nil +} + +// ============================================================================ +// LLM Policy Operations +// ============================================================================ + +// CreateLLMPolicy creates a new LLM policy +func (s *Store) CreateLLMPolicy(ctx context.Context, policy *LLMPolicy) error { + policy.ID = uuid.New() + policy.CreatedAt = time.Now().UTC() + policy.UpdatedAt = policy.CreatedAt + + _, err := s.pool.Exec(ctx, ` + INSERT INTO compliance_llm_policies ( + id, tenant_id, namespace_id, name, description, + allowed_data_categories, blocked_data_categories, + require_pii_redaction, pii_redaction_level, + allowed_models, max_tokens_per_request, max_requests_per_day, max_requests_per_hour, + is_active, priority, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + `, + policy.ID, policy.TenantID, policy.NamespaceID, policy.Name, policy.Description, + policy.AllowedDataCategories, policy.BlockedDataCategories, + policy.RequirePIIRedaction, policy.PIIRedactionLevel, + policy.AllowedModels, policy.MaxTokensPerRequest, policy.MaxRequestsPerDay, policy.MaxRequestsPerHour, + policy.IsActive, policy.Priority, policy.CreatedAt, policy.UpdatedAt, + ) + + return err +} + +// GetLLMPolicy retrieves an LLM policy by ID +func (s *Store) GetLLMPolicy(ctx context.Context, id uuid.UUID) (*LLMPolicy, error) { + var policy LLMPolicy + + err := s.pool.QueryRow(ctx, ` + SELECT id, tenant_id, namespace_id, name, description, + allowed_data_categories, blocked_data_categories, + require_pii_redaction, pii_redaction_level, + allowed_models, max_tokens_per_request, max_requests_per_day, max_requests_per_hour, + is_active, priority, created_at, updated_at + FROM compliance_llm_policies + WHERE id = $1 + `, id).Scan( + &policy.ID, &policy.TenantID, &policy.NamespaceID, &policy.Name, &policy.Description, + &policy.AllowedDataCategories, &policy.BlockedDataCategories, + &policy.RequirePIIRedaction, &policy.PIIRedactionLevel, + &policy.AllowedModels, &policy.MaxTokensPerRequest, &policy.MaxRequestsPerDay, &policy.MaxRequestsPerHour, + &policy.IsActive, &policy.Priority, &policy.CreatedAt, &policy.UpdatedAt, + ) + + return &policy, err +} + +// GetEffectiveLLMPolicy retrieves the effective LLM policy for a namespace +func (s *Store) GetEffectiveLLMPolicy(ctx context.Context, tenantID uuid.UUID, namespaceID *uuid.UUID) (*LLMPolicy, error) { + var policy LLMPolicy + + err := s.pool.QueryRow(ctx, ` + SELECT id, tenant_id, namespace_id, name, description, + allowed_data_categories, blocked_data_categories, + require_pii_redaction, pii_redaction_level, + allowed_models, max_tokens_per_request, max_requests_per_day, max_requests_per_hour, + is_active, priority, created_at, updated_at + FROM compliance_llm_policies + WHERE tenant_id = $1 + AND is_active = TRUE + AND (namespace_id = $2 OR namespace_id IS NULL) + ORDER BY + CASE WHEN namespace_id = $2 THEN 0 ELSE 1 END, + priority ASC + LIMIT 1 + `, tenantID, namespaceID).Scan( + &policy.ID, &policy.TenantID, &policy.NamespaceID, &policy.Name, &policy.Description, + &policy.AllowedDataCategories, &policy.BlockedDataCategories, + &policy.RequirePIIRedaction, &policy.PIIRedactionLevel, + &policy.AllowedModels, &policy.MaxTokensPerRequest, &policy.MaxRequestsPerDay, &policy.MaxRequestsPerHour, + &policy.IsActive, &policy.Priority, &policy.CreatedAt, &policy.UpdatedAt, + ) + + if err == pgx.ErrNoRows { + return nil, nil + } + + return &policy, err +} + +// ListLLMPolicies lists LLM policies for a tenant +func (s *Store) ListLLMPolicies(ctx context.Context, tenantID uuid.UUID) ([]*LLMPolicy, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, tenant_id, namespace_id, name, description, + allowed_data_categories, blocked_data_categories, + require_pii_redaction, pii_redaction_level, + allowed_models, max_tokens_per_request, max_requests_per_day, max_requests_per_hour, + is_active, priority, created_at, updated_at + FROM compliance_llm_policies + WHERE tenant_id = $1 + ORDER BY priority, name + `, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + + var policies []*LLMPolicy + for rows.Next() { + var policy LLMPolicy + err := rows.Scan( + &policy.ID, &policy.TenantID, &policy.NamespaceID, &policy.Name, &policy.Description, + &policy.AllowedDataCategories, &policy.BlockedDataCategories, + &policy.RequirePIIRedaction, &policy.PIIRedactionLevel, + &policy.AllowedModels, &policy.MaxTokensPerRequest, &policy.MaxRequestsPerDay, &policy.MaxRequestsPerHour, + &policy.IsActive, &policy.Priority, &policy.CreatedAt, &policy.UpdatedAt, + ) + if err != nil { + continue + } + policies = append(policies, &policy) + } + + return policies, nil +} + +// UpdateLLMPolicy updates an LLM policy +func (s *Store) UpdateLLMPolicy(ctx context.Context, policy *LLMPolicy) error { + policy.UpdatedAt = time.Now().UTC() + + _, err := s.pool.Exec(ctx, ` + UPDATE compliance_llm_policies SET + name = $2, description = $3, + allowed_data_categories = $4, blocked_data_categories = $5, + require_pii_redaction = $6, pii_redaction_level = $7, + allowed_models = $8, max_tokens_per_request = $9, max_requests_per_day = $10, max_requests_per_hour = $11, + is_active = $12, priority = $13, updated_at = $14 + WHERE id = $1 + `, + policy.ID, policy.Name, policy.Description, + policy.AllowedDataCategories, policy.BlockedDataCategories, + policy.RequirePIIRedaction, policy.PIIRedactionLevel, + policy.AllowedModels, policy.MaxTokensPerRequest, policy.MaxRequestsPerDay, policy.MaxRequestsPerHour, + policy.IsActive, policy.Priority, policy.UpdatedAt, + ) + + return err +} + +// DeleteLLMPolicy deletes an LLM policy +func (s *Store) DeleteLLMPolicy(ctx context.Context, id uuid.UUID) error { + _, err := s.pool.Exec(ctx, `DELETE FROM compliance_llm_policies WHERE id = $1`, id) + return err +} diff --git a/ai-compliance-sdk/internal/roadmap/parser.go b/ai-compliance-sdk/internal/roadmap/parser.go index 4aca6fc..32cdf22 100644 --- a/ai-compliance-sdk/internal/roadmap/parser.go +++ b/ai-compliance-sdk/internal/roadmap/parser.go @@ -5,9 +5,7 @@ import ( "encoding/csv" "encoding/json" "fmt" - "strconv" "strings" - "time" "github.com/xuri/excelize/v2" ) @@ -40,21 +38,21 @@ var ColumnMapping = map[string][]string{ // DetectedColumn represents a detected column mapping type DetectedColumn struct { - Index int `json:"index"` - Header string `json:"header"` - MappedTo string `json:"mapped_to"` - Confidence float64 `json:"confidence"` + Index int `json:"index"` + Header string `json:"header"` + MappedTo string `json:"mapped_to"` + Confidence float64 `json:"confidence"` } // ParseResult contains the result of parsing a file type ParseResult struct { - Format ImportFormat `json:"format"` - TotalRows int `json:"total_rows"` - ValidRows int `json:"valid_rows"` - InvalidRows int `json:"invalid_rows"` - Columns []DetectedColumn `json:"columns"` - Items []ParsedItem `json:"items"` - Errors []string `json:"errors"` + Format ImportFormat `json:"format"` + TotalRows int `json:"total_rows"` + ValidRows int `json:"valid_rows"` + InvalidRows int `json:"invalid_rows"` + Columns []DetectedColumn `json:"columns"` + Items []ParsedItem `json:"items"` + Errors []string `json:"errors"` } // ParseFile detects format and parses the file @@ -87,7 +85,6 @@ func (p *Parser) detectFormat(filename string, contentType string) ImportFormat return ImportFormatJSON } - // Check content type switch contentType { case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.ms-excel": @@ -113,7 +110,6 @@ func (p *Parser) parseExcel(data []byte) (*ParseResult, error) { } defer f.Close() - // Get the first sheet sheets := f.GetSheetList() if len(sheets) == 0 { return nil, fmt.Errorf("no sheets found in Excel file") @@ -128,13 +124,11 @@ func (p *Parser) parseExcel(data []byte) (*ParseResult, error) { return nil, fmt.Errorf("file must have at least a header row and one data row") } - // Detect column mappings from header headers := rows[0] result.Columns = p.detectColumns(headers) - // Parse data rows for i, row := range rows[1:] { - rowNum := i + 2 // 1-based, skip header + rowNum := i + 2 item := p.parseRow(row, result.Columns, rowNum) result.Items = append(result.Items, item) result.TotalRows++ @@ -158,7 +152,6 @@ func (p *Parser) parseCSV(data []byte) (*ParseResult, error) { reader.LazyQuotes = true reader.TrimLeadingSpace = true - // Try different delimiters delimiters := []rune{',', ';', '\t'} var records [][]string var err error @@ -182,11 +175,9 @@ func (p *Parser) parseCSV(data []byte) (*ParseResult, error) { return nil, fmt.Errorf("file must have at least a header row and one data row") } - // Detect column mappings from header headers := records[0] result.Columns = p.detectColumns(headers) - // Parse data rows for i, row := range records[1:] { rowNum := i + 2 item := p.parseRow(row, result.Columns, rowNum) @@ -208,10 +199,8 @@ func (p *Parser) parseJSON(data []byte) (*ParseResult, error) { Format: ImportFormatJSON, } - // Try parsing as array of items var items []map[string]interface{} if err := json.Unmarshal(data, &items); err != nil { - // Try parsing as object with items array var wrapper struct { Items []map[string]interface{} `json:"items"` } @@ -225,18 +214,15 @@ func (p *Parser) parseJSON(data []byte) (*ParseResult, error) { return nil, fmt.Errorf("no items found in JSON file") } - // Detect columns from first item headers := make([]string, 0) for key := range items[0] { headers = append(headers, key) } result.Columns = p.detectColumns(headers) - // Parse items for i, itemMap := range items { rowNum := i + 1 - // Convert map to row slice row := make([]string, len(result.Columns)) for j, col := range result.Columns { if val, ok := itemMap[col.Header]; ok { @@ -270,7 +256,6 @@ func (p *Parser) detectColumns(headers []string) []DetectedColumn { headerLower := strings.ToLower(strings.TrimSpace(header)) - // Try to match against known column names for fieldName, variations := range ColumnMapping { for _, variation := range variations { if headerLower == variation || strings.Contains(headerLower, variation) { @@ -292,249 +277,3 @@ func (p *Parser) detectColumns(headers []string) []DetectedColumn { return columns } - -// parseRow parses a single row into a ParsedItem -func (p *Parser) parseRow(row []string, columns []DetectedColumn, rowNum int) ParsedItem { - item := ParsedItem{ - RowNumber: rowNum, - IsValid: true, - Data: RoadmapItemInput{}, - } - - // Build a map for easy access - values := make(map[string]string) - for i, col := range columns { - if i < len(row) && col.MappedTo != "" { - values[col.MappedTo] = strings.TrimSpace(row[i]) - } - } - - // Extract title (required) - if title, ok := values["title"]; ok && title != "" { - item.Data.Title = title - } else { - item.IsValid = false - item.Errors = append(item.Errors, "Titel/Title ist erforderlich") - } - - // Extract optional fields - if desc, ok := values["description"]; ok { - item.Data.Description = desc - } - - // Category - if cat, ok := values["category"]; ok && cat != "" { - item.Data.Category = p.parseCategory(cat) - if item.Data.Category == "" { - item.Warnings = append(item.Warnings, fmt.Sprintf("Unbekannte Kategorie: %s", cat)) - item.Data.Category = ItemCategoryTechnical - } - } - - // Priority - if prio, ok := values["priority"]; ok && prio != "" { - item.Data.Priority = p.parsePriority(prio) - if item.Data.Priority == "" { - item.Warnings = append(item.Warnings, fmt.Sprintf("Unbekannte Priorität: %s", prio)) - item.Data.Priority = ItemPriorityMedium - } - } - - // Status - if status, ok := values["status"]; ok && status != "" { - item.Data.Status = p.parseStatus(status) - if item.Data.Status == "" { - item.Warnings = append(item.Warnings, fmt.Sprintf("Unbekannter Status: %s", status)) - item.Data.Status = ItemStatusPlanned - } - } - - // Control ID - if ctrl, ok := values["control_id"]; ok { - item.Data.ControlID = ctrl - } - - // Regulation reference - if reg, ok := values["regulation_ref"]; ok { - item.Data.RegulationRef = reg - } - - // Gap ID - if gap, ok := values["gap_id"]; ok { - item.Data.GapID = gap - } - - // Effort - if effort, ok := values["effort_days"]; ok && effort != "" { - if days, err := strconv.Atoi(effort); err == nil { - item.Data.EffortDays = &days - } - } - - // Assignee - if assignee, ok := values["assignee"]; ok { - item.Data.AssigneeName = assignee - } - - // Department - if dept, ok := values["department"]; ok { - item.Data.Department = dept - } - - // Dates - if startStr, ok := values["planned_start"]; ok && startStr != "" { - if start := p.parseDate(startStr); start != nil { - item.Data.PlannedStart = start - } - } - - if endStr, ok := values["planned_end"]; ok && endStr != "" { - if end := p.parseDate(endStr); end != nil { - item.Data.PlannedEnd = end - } - } - - // Notes - if notes, ok := values["notes"]; ok { - item.Data.Notes = notes - } - - return item -} - -// parseCategory converts a string to ItemCategory -func (p *Parser) parseCategory(s string) ItemCategory { - s = strings.ToLower(strings.TrimSpace(s)) - - switch { - case strings.Contains(s, "tech"): - return ItemCategoryTechnical - case strings.Contains(s, "org"): - return ItemCategoryOrganizational - case strings.Contains(s, "proz") || strings.Contains(s, "process"): - return ItemCategoryProcessual - case strings.Contains(s, "dok") || strings.Contains(s, "doc"): - return ItemCategoryDocumentation - case strings.Contains(s, "train") || strings.Contains(s, "schul"): - return ItemCategoryTraining - default: - return "" - } -} - -// parsePriority converts a string to ItemPriority -func (p *Parser) parsePriority(s string) ItemPriority { - s = strings.ToLower(strings.TrimSpace(s)) - - switch { - case strings.Contains(s, "crit") || strings.Contains(s, "krit") || s == "1": - return ItemPriorityCritical - case strings.Contains(s, "high") || strings.Contains(s, "hoch") || s == "2": - return ItemPriorityHigh - case strings.Contains(s, "med") || strings.Contains(s, "mitt") || s == "3": - return ItemPriorityMedium - case strings.Contains(s, "low") || strings.Contains(s, "nied") || s == "4": - return ItemPriorityLow - default: - return "" - } -} - -// parseStatus converts a string to ItemStatus -func (p *Parser) parseStatus(s string) ItemStatus { - s = strings.ToLower(strings.TrimSpace(s)) - - switch { - case strings.Contains(s, "plan") || strings.Contains(s, "offen") || strings.Contains(s, "open"): - return ItemStatusPlanned - case strings.Contains(s, "progress") || strings.Contains(s, "lauf") || strings.Contains(s, "arbeit"): - return ItemStatusInProgress - case strings.Contains(s, "block") || strings.Contains(s, "wart"): - return ItemStatusBlocked - case strings.Contains(s, "complet") || strings.Contains(s, "done") || strings.Contains(s, "fertig") || strings.Contains(s, "erledigt"): - return ItemStatusCompleted - case strings.Contains(s, "defer") || strings.Contains(s, "zurück") || strings.Contains(s, "verschob"): - return ItemStatusDeferred - default: - return "" - } -} - -// parseDate attempts to parse various date formats -func (p *Parser) parseDate(s string) *time.Time { - s = strings.TrimSpace(s) - if s == "" { - return nil - } - - formats := []string{ - "2006-01-02", - "02.01.2006", - "2.1.2006", - "02/01/2006", - "2/1/2006", - "01/02/2006", - "1/2/2006", - "2006/01/02", - time.RFC3339, - } - - for _, format := range formats { - if t, err := time.Parse(format, s); err == nil { - return &t - } - } - - return nil -} - -// ValidateAndEnrich validates parsed items and enriches them with mappings -func (p *Parser) ValidateAndEnrich(items []ParsedItem, controls []string, regulations []string, gaps []string) []ParsedItem { - // Build lookup maps - controlSet := make(map[string]bool) - for _, c := range controls { - controlSet[strings.ToLower(c)] = true - } - - regSet := make(map[string]bool) - for _, r := range regulations { - regSet[strings.ToLower(r)] = true - } - - gapSet := make(map[string]bool) - for _, g := range gaps { - gapSet[strings.ToLower(g)] = true - } - - for i := range items { - item := &items[i] - - // Validate control ID - if item.Data.ControlID != "" { - if controlSet[strings.ToLower(item.Data.ControlID)] { - item.MatchedControl = item.Data.ControlID - item.MatchConfidence = 1.0 - } else { - item.Warnings = append(item.Warnings, fmt.Sprintf("Control '%s' nicht im Katalog gefunden", item.Data.ControlID)) - } - } - - // Validate regulation reference - if item.Data.RegulationRef != "" { - if regSet[strings.ToLower(item.Data.RegulationRef)] { - item.MatchedRegulation = item.Data.RegulationRef - } - } - - // Validate gap ID - if item.Data.GapID != "" { - if gapSet[strings.ToLower(item.Data.GapID)] { - item.MatchedGap = item.Data.GapID - } else { - item.Warnings = append(item.Warnings, fmt.Sprintf("Gap '%s' nicht im Mapping gefunden", item.Data.GapID)) - } - } - } - - return items -} diff --git a/ai-compliance-sdk/internal/roadmap/parser_row.go b/ai-compliance-sdk/internal/roadmap/parser_row.go new file mode 100644 index 0000000..22b6da5 --- /dev/null +++ b/ai-compliance-sdk/internal/roadmap/parser_row.go @@ -0,0 +1,236 @@ +package roadmap + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +// parseRow parses a single row into a ParsedItem +func (p *Parser) parseRow(row []string, columns []DetectedColumn, rowNum int) ParsedItem { + item := ParsedItem{ + RowNumber: rowNum, + IsValid: true, + Data: RoadmapItemInput{}, + } + + values := make(map[string]string) + for i, col := range columns { + if i < len(row) && col.MappedTo != "" { + values[col.MappedTo] = strings.TrimSpace(row[i]) + } + } + + if title, ok := values["title"]; ok && title != "" { + item.Data.Title = title + } else { + item.IsValid = false + item.Errors = append(item.Errors, "Titel/Title ist erforderlich") + } + + if desc, ok := values["description"]; ok { + item.Data.Description = desc + } + + if cat, ok := values["category"]; ok && cat != "" { + item.Data.Category = p.parseCategory(cat) + if item.Data.Category == "" { + item.Warnings = append(item.Warnings, fmt.Sprintf("Unbekannte Kategorie: %s", cat)) + item.Data.Category = ItemCategoryTechnical + } + } + + if prio, ok := values["priority"]; ok && prio != "" { + item.Data.Priority = p.parsePriority(prio) + if item.Data.Priority == "" { + item.Warnings = append(item.Warnings, fmt.Sprintf("Unbekannte Priorität: %s", prio)) + item.Data.Priority = ItemPriorityMedium + } + } + + if status, ok := values["status"]; ok && status != "" { + item.Data.Status = p.parseStatus(status) + if item.Data.Status == "" { + item.Warnings = append(item.Warnings, fmt.Sprintf("Unbekannter Status: %s", status)) + item.Data.Status = ItemStatusPlanned + } + } + + if ctrl, ok := values["control_id"]; ok { + item.Data.ControlID = ctrl + } + + if reg, ok := values["regulation_ref"]; ok { + item.Data.RegulationRef = reg + } + + if gap, ok := values["gap_id"]; ok { + item.Data.GapID = gap + } + + if effort, ok := values["effort_days"]; ok && effort != "" { + if days, err := strconv.Atoi(effort); err == nil { + item.Data.EffortDays = &days + } + } + + if assignee, ok := values["assignee"]; ok { + item.Data.AssigneeName = assignee + } + + if dept, ok := values["department"]; ok { + item.Data.Department = dept + } + + if startStr, ok := values["planned_start"]; ok && startStr != "" { + if start := p.parseDate(startStr); start != nil { + item.Data.PlannedStart = start + } + } + + if endStr, ok := values["planned_end"]; ok && endStr != "" { + if end := p.parseDate(endStr); end != nil { + item.Data.PlannedEnd = end + } + } + + if notes, ok := values["notes"]; ok { + item.Data.Notes = notes + } + + return item +} + +// parseCategory converts a string to ItemCategory +func (p *Parser) parseCategory(s string) ItemCategory { + s = strings.ToLower(strings.TrimSpace(s)) + + switch { + case strings.Contains(s, "tech"): + return ItemCategoryTechnical + case strings.Contains(s, "org"): + return ItemCategoryOrganizational + case strings.Contains(s, "proz") || strings.Contains(s, "process"): + return ItemCategoryProcessual + case strings.Contains(s, "dok") || strings.Contains(s, "doc"): + return ItemCategoryDocumentation + case strings.Contains(s, "train") || strings.Contains(s, "schul"): + return ItemCategoryTraining + default: + return "" + } +} + +// parsePriority converts a string to ItemPriority +func (p *Parser) parsePriority(s string) ItemPriority { + s = strings.ToLower(strings.TrimSpace(s)) + + switch { + case strings.Contains(s, "crit") || strings.Contains(s, "krit") || s == "1": + return ItemPriorityCritical + case strings.Contains(s, "high") || strings.Contains(s, "hoch") || s == "2": + return ItemPriorityHigh + case strings.Contains(s, "med") || strings.Contains(s, "mitt") || s == "3": + return ItemPriorityMedium + case strings.Contains(s, "low") || strings.Contains(s, "nied") || s == "4": + return ItemPriorityLow + default: + return "" + } +} + +// parseStatus converts a string to ItemStatus +func (p *Parser) parseStatus(s string) ItemStatus { + s = strings.ToLower(strings.TrimSpace(s)) + + switch { + case strings.Contains(s, "plan") || strings.Contains(s, "offen") || strings.Contains(s, "open"): + return ItemStatusPlanned + case strings.Contains(s, "progress") || strings.Contains(s, "lauf") || strings.Contains(s, "arbeit"): + return ItemStatusInProgress + case strings.Contains(s, "block") || strings.Contains(s, "wart"): + return ItemStatusBlocked + case strings.Contains(s, "complet") || strings.Contains(s, "done") || strings.Contains(s, "fertig") || strings.Contains(s, "erledigt"): + return ItemStatusCompleted + case strings.Contains(s, "defer") || strings.Contains(s, "zurück") || strings.Contains(s, "verschob"): + return ItemStatusDeferred + default: + return "" + } +} + +// parseDate attempts to parse various date formats +func (p *Parser) parseDate(s string) *time.Time { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + + formats := []string{ + "2006-01-02", + "02.01.2006", + "2.1.2006", + "02/01/2006", + "2/1/2006", + "01/02/2006", + "1/2/2006", + "2006/01/02", + time.RFC3339, + } + + for _, format := range formats { + if t, err := time.Parse(format, s); err == nil { + return &t + } + } + + return nil +} + +// ValidateAndEnrich validates parsed items and enriches them with mappings +func (p *Parser) ValidateAndEnrich(items []ParsedItem, controls []string, regulations []string, gaps []string) []ParsedItem { + controlSet := make(map[string]bool) + for _, c := range controls { + controlSet[strings.ToLower(c)] = true + } + + regSet := make(map[string]bool) + for _, r := range regulations { + regSet[strings.ToLower(r)] = true + } + + gapSet := make(map[string]bool) + for _, g := range gaps { + gapSet[strings.ToLower(g)] = true + } + + for i := range items { + item := &items[i] + + if item.Data.ControlID != "" { + if controlSet[strings.ToLower(item.Data.ControlID)] { + item.MatchedControl = item.Data.ControlID + item.MatchConfidence = 1.0 + } else { + item.Warnings = append(item.Warnings, fmt.Sprintf("Control '%s' nicht im Katalog gefunden", item.Data.ControlID)) + } + } + + if item.Data.RegulationRef != "" { + if regSet[strings.ToLower(item.Data.RegulationRef)] { + item.MatchedRegulation = item.Data.RegulationRef + } + } + + if item.Data.GapID != "" { + if gapSet[strings.ToLower(item.Data.GapID)] { + item.MatchedGap = item.Data.GapID + } else { + item.Warnings = append(item.Warnings, fmt.Sprintf("Gap '%s' nicht im Mapping gefunden", item.Data.GapID)) + } + } + } + + return items +} diff --git a/ai-compliance-sdk/internal/whistleblower/store.go b/ai-compliance-sdk/internal/whistleblower/store.go index 7efb6cb..1cea0b5 100644 --- a/ai-compliance-sdk/internal/whistleblower/store.go +++ b/ai-compliance-sdk/internal/whistleblower/store.go @@ -32,17 +32,15 @@ func (s *Store) CreateReport(ctx context.Context, report *Report) error { report.CreatedAt = now report.UpdatedAt = now report.ReceivedAt = now - report.DeadlineAcknowledgment = now.AddDate(0, 0, 7) // 7 days per HinSchG - report.DeadlineFeedback = now.AddDate(0, 3, 0) // 3 months per HinSchG + report.DeadlineAcknowledgment = now.AddDate(0, 0, 7) // 7 days per HinSchG + report.DeadlineFeedback = now.AddDate(0, 3, 0) // 3 months per HinSchG if report.Status == "" { report.Status = ReportStatusNew } - // Generate access key report.AccessKey = generateAccessKey() - // Generate reference number year := now.Year() seq, err := s.GetNextSequenceNumber(ctx, report.TenantID, year) if err != nil { @@ -50,7 +48,6 @@ func (s *Store) CreateReport(ctx context.Context, report *Report) error { } report.ReferenceNumber = generateReferenceNumber(year, seq) - // Initialize audit trail if report.AuditTrail == nil { report.AuditTrail = []AuditEntry{} } @@ -154,7 +151,6 @@ func (s *Store) GetReportByAccessKey(ctx context.Context, accessKey string) (*Re // ListReports lists reports for a tenant with optional filters func (s *Store) ListReports(ctx context.Context, tenantID uuid.UUID, filters *ReportFilters) ([]Report, int, error) { - // Count total countQuery := "SELECT COUNT(*) FROM whistleblower_reports WHERE tenant_id = $1" countArgs := []interface{}{tenantID} countArgIdx := 2 @@ -178,7 +174,6 @@ func (s *Store) ListReports(ctx context.Context, tenantID uuid.UUID, filters *Re return nil, 0, err } - // Build data query query := ` SELECT id, tenant_id, reference_number, access_key, @@ -249,9 +244,7 @@ func (s *Store) ListReports(ctx context.Context, tenantID uuid.UUID, filters *Re report.Status = ReportStatus(status) json.Unmarshal(auditTrailJSON, &report.AuditTrail) - // Do not expose access key in list responses report.AccessKey = "" - reports = append(reports, report) } @@ -362,230 +355,3 @@ func (s *Store) DeleteReport(ctx context.Context, id uuid.UUID) error { _, err = s.pool.Exec(ctx, "DELETE FROM whistleblower_reports WHERE id = $1", id) return err } - -// ============================================================================ -// Message Operations -// ============================================================================ - -// AddMessage adds an anonymous message to a report -func (s *Store) AddMessage(ctx context.Context, msg *AnonymousMessage) error { - msg.ID = uuid.New() - msg.SentAt = time.Now().UTC() - - _, err := s.pool.Exec(ctx, ` - INSERT INTO whistleblower_messages ( - id, report_id, direction, content, sent_at, read_at - ) VALUES ( - $1, $2, $3, $4, $5, $6 - ) - `, - msg.ID, msg.ReportID, string(msg.Direction), msg.Content, msg.SentAt, msg.ReadAt, - ) - - return err -} - -// ListMessages lists messages for a report -func (s *Store) ListMessages(ctx context.Context, reportID uuid.UUID) ([]AnonymousMessage, error) { - rows, err := s.pool.Query(ctx, ` - SELECT - id, report_id, direction, content, sent_at, read_at - FROM whistleblower_messages WHERE report_id = $1 - ORDER BY sent_at ASC - `, reportID) - if err != nil { - return nil, err - } - defer rows.Close() - - var messages []AnonymousMessage - for rows.Next() { - var msg AnonymousMessage - var direction string - - err := rows.Scan( - &msg.ID, &msg.ReportID, &direction, &msg.Content, &msg.SentAt, &msg.ReadAt, - ) - if err != nil { - return nil, err - } - - msg.Direction = MessageDirection(direction) - messages = append(messages, msg) - } - - return messages, nil -} - -// ============================================================================ -// Measure Operations -// ============================================================================ - -// AddMeasure adds a corrective measure to a report -func (s *Store) AddMeasure(ctx context.Context, measure *Measure) error { - measure.ID = uuid.New() - measure.CreatedAt = time.Now().UTC() - if measure.Status == "" { - measure.Status = MeasureStatusPlanned - } - - _, err := s.pool.Exec(ctx, ` - INSERT INTO whistleblower_measures ( - id, report_id, title, description, status, - responsible, due_date, completed_at, created_at - ) VALUES ( - $1, $2, $3, $4, $5, - $6, $7, $8, $9 - ) - `, - measure.ID, measure.ReportID, measure.Title, measure.Description, string(measure.Status), - measure.Responsible, measure.DueDate, measure.CompletedAt, measure.CreatedAt, - ) - - return err -} - -// ListMeasures lists measures for a report -func (s *Store) ListMeasures(ctx context.Context, reportID uuid.UUID) ([]Measure, error) { - rows, err := s.pool.Query(ctx, ` - SELECT - id, report_id, title, description, status, - responsible, due_date, completed_at, created_at - FROM whistleblower_measures WHERE report_id = $1 - ORDER BY created_at ASC - `, reportID) - if err != nil { - return nil, err - } - defer rows.Close() - - var measures []Measure - for rows.Next() { - var m Measure - var status string - - err := rows.Scan( - &m.ID, &m.ReportID, &m.Title, &m.Description, &status, - &m.Responsible, &m.DueDate, &m.CompletedAt, &m.CreatedAt, - ) - if err != nil { - return nil, err - } - - m.Status = MeasureStatus(status) - measures = append(measures, m) - } - - return measures, nil -} - -// UpdateMeasure updates a measure -func (s *Store) UpdateMeasure(ctx context.Context, measure *Measure) error { - _, err := s.pool.Exec(ctx, ` - UPDATE whistleblower_measures SET - title = $2, description = $3, status = $4, - responsible = $5, due_date = $6, completed_at = $7 - WHERE id = $1 - `, - measure.ID, - measure.Title, measure.Description, string(measure.Status), - measure.Responsible, measure.DueDate, measure.CompletedAt, - ) - - return err -} - -// ============================================================================ -// Statistics -// ============================================================================ - -// GetStatistics returns aggregated whistleblower statistics for a tenant -func (s *Store) GetStatistics(ctx context.Context, tenantID uuid.UUID) (*WhistleblowerStatistics, error) { - stats := &WhistleblowerStatistics{ - ByStatus: make(map[string]int), - ByCategory: make(map[string]int), - } - - // Total reports - s.pool.QueryRow(ctx, - "SELECT COUNT(*) FROM whistleblower_reports WHERE tenant_id = $1", - tenantID).Scan(&stats.TotalReports) - - // By status - rows, err := s.pool.Query(ctx, - "SELECT status, COUNT(*) FROM whistleblower_reports WHERE tenant_id = $1 GROUP BY status", - tenantID) - if err == nil { - defer rows.Close() - for rows.Next() { - var status string - var count int - rows.Scan(&status, &count) - stats.ByStatus[status] = count - } - } - - // By category - rows, err = s.pool.Query(ctx, - "SELECT category, COUNT(*) FROM whistleblower_reports WHERE tenant_id = $1 GROUP BY category", - tenantID) - if err == nil { - defer rows.Close() - for rows.Next() { - var category string - var count int - rows.Scan(&category, &count) - stats.ByCategory[category] = count - } - } - - // Overdue acknowledgments: reports past deadline_acknowledgment that haven't been acknowledged - s.pool.QueryRow(ctx, ` - SELECT COUNT(*) FROM whistleblower_reports - WHERE tenant_id = $1 - AND acknowledged_at IS NULL - AND status = 'new' - AND deadline_acknowledgment < NOW() - `, tenantID).Scan(&stats.OverdueAcknowledgments) - - // Overdue feedbacks: reports past deadline_feedback that are still open - s.pool.QueryRow(ctx, ` - SELECT COUNT(*) FROM whistleblower_reports - WHERE tenant_id = $1 - AND closed_at IS NULL - AND status NOT IN ('closed', 'rejected') - AND deadline_feedback < NOW() - `, tenantID).Scan(&stats.OverdueFeedbacks) - - // Average resolution days (for closed reports) - s.pool.QueryRow(ctx, ` - SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (closed_at - received_at)) / 86400), 0) - FROM whistleblower_reports - WHERE tenant_id = $1 AND closed_at IS NOT NULL - `, tenantID).Scan(&stats.AvgResolutionDays) - - return stats, nil -} - -// ============================================================================ -// Sequence Number -// ============================================================================ - -// GetNextSequenceNumber gets and increments the sequence number for reference number generation -func (s *Store) GetNextSequenceNumber(ctx context.Context, tenantID uuid.UUID, year int) (int, error) { - var seq int - - err := s.pool.QueryRow(ctx, ` - INSERT INTO whistleblower_sequences (tenant_id, year, last_sequence) - VALUES ($1, $2, 1) - ON CONFLICT (tenant_id, year) DO UPDATE SET - last_sequence = whistleblower_sequences.last_sequence + 1 - RETURNING last_sequence - `, tenantID, year).Scan(&seq) - - if err != nil { - return 0, err - } - - return seq, nil -} diff --git a/ai-compliance-sdk/internal/whistleblower/store_messages.go b/ai-compliance-sdk/internal/whistleblower/store_messages.go new file mode 100644 index 0000000..52092a4 --- /dev/null +++ b/ai-compliance-sdk/internal/whistleblower/store_messages.go @@ -0,0 +1,229 @@ +package whistleblower + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +// ============================================================================ +// Message Operations +// ============================================================================ + +// AddMessage adds an anonymous message to a report +func (s *Store) AddMessage(ctx context.Context, msg *AnonymousMessage) error { + msg.ID = uuid.New() + msg.SentAt = time.Now().UTC() + + _, err := s.pool.Exec(ctx, ` + INSERT INTO whistleblower_messages ( + id, report_id, direction, content, sent_at, read_at + ) VALUES ( + $1, $2, $3, $4, $5, $6 + ) + `, + msg.ID, msg.ReportID, string(msg.Direction), msg.Content, msg.SentAt, msg.ReadAt, + ) + + return err +} + +// ListMessages lists messages for a report +func (s *Store) ListMessages(ctx context.Context, reportID uuid.UUID) ([]AnonymousMessage, error) { + rows, err := s.pool.Query(ctx, ` + SELECT + id, report_id, direction, content, sent_at, read_at + FROM whistleblower_messages WHERE report_id = $1 + ORDER BY sent_at ASC + `, reportID) + if err != nil { + return nil, err + } + defer rows.Close() + + var messages []AnonymousMessage + for rows.Next() { + var msg AnonymousMessage + var direction string + + err := rows.Scan( + &msg.ID, &msg.ReportID, &direction, &msg.Content, &msg.SentAt, &msg.ReadAt, + ) + if err != nil { + return nil, err + } + + msg.Direction = MessageDirection(direction) + messages = append(messages, msg) + } + + return messages, nil +} + +// ============================================================================ +// Measure Operations +// ============================================================================ + +// AddMeasure adds a corrective measure to a report +func (s *Store) AddMeasure(ctx context.Context, measure *Measure) error { + measure.ID = uuid.New() + measure.CreatedAt = time.Now().UTC() + if measure.Status == "" { + measure.Status = MeasureStatusPlanned + } + + _, err := s.pool.Exec(ctx, ` + INSERT INTO whistleblower_measures ( + id, report_id, title, description, status, + responsible, due_date, completed_at, created_at + ) VALUES ( + $1, $2, $3, $4, $5, + $6, $7, $8, $9 + ) + `, + measure.ID, measure.ReportID, measure.Title, measure.Description, string(measure.Status), + measure.Responsible, measure.DueDate, measure.CompletedAt, measure.CreatedAt, + ) + + return err +} + +// ListMeasures lists measures for a report +func (s *Store) ListMeasures(ctx context.Context, reportID uuid.UUID) ([]Measure, error) { + rows, err := s.pool.Query(ctx, ` + SELECT + id, report_id, title, description, status, + responsible, due_date, completed_at, created_at + FROM whistleblower_measures WHERE report_id = $1 + ORDER BY created_at ASC + `, reportID) + if err != nil { + return nil, err + } + defer rows.Close() + + var measures []Measure + for rows.Next() { + var m Measure + var status string + + err := rows.Scan( + &m.ID, &m.ReportID, &m.Title, &m.Description, &status, + &m.Responsible, &m.DueDate, &m.CompletedAt, &m.CreatedAt, + ) + if err != nil { + return nil, err + } + + m.Status = MeasureStatus(status) + measures = append(measures, m) + } + + return measures, nil +} + +// UpdateMeasure updates a measure +func (s *Store) UpdateMeasure(ctx context.Context, measure *Measure) error { + _, err := s.pool.Exec(ctx, ` + UPDATE whistleblower_measures SET + title = $2, description = $3, status = $4, + responsible = $5, due_date = $6, completed_at = $7 + WHERE id = $1 + `, + measure.ID, + measure.Title, measure.Description, string(measure.Status), + measure.Responsible, measure.DueDate, measure.CompletedAt, + ) + + return err +} + +// ============================================================================ +// Statistics +// ============================================================================ + +// GetStatistics returns aggregated whistleblower statistics for a tenant +func (s *Store) GetStatistics(ctx context.Context, tenantID uuid.UUID) (*WhistleblowerStatistics, error) { + stats := &WhistleblowerStatistics{ + ByStatus: make(map[string]int), + ByCategory: make(map[string]int), + } + + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM whistleblower_reports WHERE tenant_id = $1", + tenantID).Scan(&stats.TotalReports) + + rows, err := s.pool.Query(ctx, + "SELECT status, COUNT(*) FROM whistleblower_reports WHERE tenant_id = $1 GROUP BY status", + tenantID) + if err == nil { + defer rows.Close() + for rows.Next() { + var status string + var count int + rows.Scan(&status, &count) + stats.ByStatus[status] = count + } + } + + rows, err = s.pool.Query(ctx, + "SELECT category, COUNT(*) FROM whistleblower_reports WHERE tenant_id = $1 GROUP BY category", + tenantID) + if err == nil { + defer rows.Close() + for rows.Next() { + var category string + var count int + rows.Scan(&category, &count) + stats.ByCategory[category] = count + } + } + + s.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM whistleblower_reports + WHERE tenant_id = $1 + AND acknowledged_at IS NULL + AND status = 'new' + AND deadline_acknowledgment < NOW() + `, tenantID).Scan(&stats.OverdueAcknowledgments) + + s.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM whistleblower_reports + WHERE tenant_id = $1 + AND closed_at IS NULL + AND status NOT IN ('closed', 'rejected') + AND deadline_feedback < NOW() + `, tenantID).Scan(&stats.OverdueFeedbacks) + + s.pool.QueryRow(ctx, ` + SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (closed_at - received_at)) / 86400), 0) + FROM whistleblower_reports + WHERE tenant_id = $1 AND closed_at IS NOT NULL + `, tenantID).Scan(&stats.AvgResolutionDays) + + return stats, nil +} + +// ============================================================================ +// Sequence Number +// ============================================================================ + +// GetNextSequenceNumber gets and increments the sequence number for reference number generation +func (s *Store) GetNextSequenceNumber(ctx context.Context, tenantID uuid.UUID, year int) (int, error) { + var seq int + + err := s.pool.QueryRow(ctx, ` + INSERT INTO whistleblower_sequences (tenant_id, year, last_sequence) + VALUES ($1, $2, 1) + ON CONFLICT (tenant_id, year) DO UPDATE SET + last_sequence = whistleblower_sequences.last_sequence + 1 + RETURNING last_sequence + `, tenantID, year).Scan(&seq) + + if err != nil { + return 0, err + } + + return seq, nil +}