fix(quality): Ruff/CVE/TS-Fixes, 104 neue Tests, Complexity-Refactoring
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Failing after 30s
CI / test-python-backend-compliance (push) Successful in 30s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 17s
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Failing after 30s
CI / test-python-backend-compliance (push) Successful in 30s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 17s
- Ruff: 144 auto-fixes (unused imports, == None → is None), F821/F811/F841 manuell - CVEs: python-multipart>=0.0.22, weasyprint>=68.0, pillow>=12.1.1, npm audit fix (0 vulns) - TS: 5 tote Drafting-Engine-Dateien entfernt, allowed-facts/sanitizer/StepHeader/context fixes - Tests: +104 (ISMS 58, Evidence 18, VVT 14, Generation 14) → 1449 passed - Refactoring: collect_ci_evidence (F→A), row_to_response (E→A), extract_requirements (E→A) - Dead Code: pca-platform, 7 Go-Handler, dsr_api.py, duplicate Schemas entfernt Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,451 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/dsb"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// DSBHandlers handles DSB-as-a-Service portal HTTP requests.
|
||||
type DSBHandlers struct {
|
||||
store *dsb.Store
|
||||
}
|
||||
|
||||
// NewDSBHandlers creates new DSB handlers.
|
||||
func NewDSBHandlers(store *dsb.Store) *DSBHandlers {
|
||||
return &DSBHandlers{store: store}
|
||||
}
|
||||
|
||||
// getDSBUserID extracts and parses the X-User-ID header as UUID.
|
||||
func getDSBUserID(c *gin.Context) (uuid.UUID, bool) {
|
||||
userIDStr := c.GetHeader("X-User-ID")
|
||||
if userIDStr == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "X-User-ID header is required"})
|
||||
return uuid.Nil, false
|
||||
}
|
||||
userID, err := uuid.Parse(userIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid X-User-ID header: must be a valid UUID"})
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return userID, true
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Dashboard
|
||||
// ============================================================================
|
||||
|
||||
// GetDashboard returns the aggregated DSB dashboard.
|
||||
// GET /sdk/v1/dsb/dashboard
|
||||
func (h *DSBHandlers) GetDashboard(c *gin.Context) {
|
||||
dsbUserID, ok := getDSBUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
dashboard, err := h.store.GetDashboard(c.Request.Context(), dsbUserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dashboard)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Assignments
|
||||
// ============================================================================
|
||||
|
||||
// CreateAssignment creates a new DSB-to-tenant assignment.
|
||||
// POST /sdk/v1/dsb/assignments
|
||||
func (h *DSBHandlers) CreateAssignment(c *gin.Context) {
|
||||
var req dsb.CreateAssignmentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
assignment := &dsb.Assignment{
|
||||
DSBUserID: req.DSBUserID,
|
||||
TenantID: req.TenantID,
|
||||
Status: req.Status,
|
||||
ContractStart: req.ContractStart,
|
||||
ContractEnd: req.ContractEnd,
|
||||
MonthlyHoursBudget: req.MonthlyHoursBudget,
|
||||
Notes: req.Notes,
|
||||
}
|
||||
|
||||
if err := h.store.CreateAssignment(c.Request.Context(), assignment); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"assignment": assignment})
|
||||
}
|
||||
|
||||
// ListAssignments returns all assignments for the authenticated DSB user.
|
||||
// GET /sdk/v1/dsb/assignments
|
||||
func (h *DSBHandlers) ListAssignments(c *gin.Context) {
|
||||
dsbUserID, ok := getDSBUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
assignments, err := h.store.ListAssignments(c.Request.Context(), dsbUserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"assignments": assignments,
|
||||
"total": len(assignments),
|
||||
})
|
||||
}
|
||||
|
||||
// GetAssignment retrieves a single assignment by ID.
|
||||
// GET /sdk/v1/dsb/assignments/:id
|
||||
func (h *DSBHandlers) GetAssignment(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
assignment, err := h.store.GetAssignment(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"assignment": assignment})
|
||||
}
|
||||
|
||||
// UpdateAssignment updates an existing assignment.
|
||||
// PUT /sdk/v1/dsb/assignments/:id
|
||||
func (h *DSBHandlers) UpdateAssignment(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
assignment, err := h.store.GetAssignment(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var req dsb.UpdateAssignmentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Apply non-nil fields
|
||||
if req.Status != nil {
|
||||
assignment.Status = *req.Status
|
||||
}
|
||||
if req.ContractEnd != nil {
|
||||
assignment.ContractEnd = req.ContractEnd
|
||||
}
|
||||
if req.MonthlyHoursBudget != nil {
|
||||
assignment.MonthlyHoursBudget = *req.MonthlyHoursBudget
|
||||
}
|
||||
if req.Notes != nil {
|
||||
assignment.Notes = *req.Notes
|
||||
}
|
||||
|
||||
if err := h.store.UpdateAssignment(c.Request.Context(), assignment); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"assignment": assignment})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hours
|
||||
// ============================================================================
|
||||
|
||||
// CreateHourEntry creates a new time tracking entry for an assignment.
|
||||
// POST /sdk/v1/dsb/assignments/:id/hours
|
||||
func (h *DSBHandlers) CreateHourEntry(c *gin.Context) {
|
||||
assignmentID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req dsb.CreateHourEntryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
billable := true
|
||||
if req.Billable != nil {
|
||||
billable = *req.Billable
|
||||
}
|
||||
|
||||
entry := &dsb.HourEntry{
|
||||
AssignmentID: assignmentID,
|
||||
Date: req.Date,
|
||||
Hours: req.Hours,
|
||||
Category: req.Category,
|
||||
Description: req.Description,
|
||||
Billable: billable,
|
||||
}
|
||||
|
||||
if err := h.store.CreateHourEntry(c.Request.Context(), entry); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"hour_entry": entry})
|
||||
}
|
||||
|
||||
// ListHours returns time entries for an assignment.
|
||||
// GET /sdk/v1/dsb/assignments/:id/hours?month=YYYY-MM
|
||||
func (h *DSBHandlers) ListHours(c *gin.Context) {
|
||||
assignmentID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
month := c.Query("month")
|
||||
|
||||
entries, err := h.store.ListHours(c.Request.Context(), assignmentID, month)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"hours": entries,
|
||||
"total": len(entries),
|
||||
})
|
||||
}
|
||||
|
||||
// GetHoursSummary returns aggregated hour statistics for an assignment.
|
||||
// GET /sdk/v1/dsb/assignments/:id/hours/summary?month=YYYY-MM
|
||||
func (h *DSBHandlers) GetHoursSummary(c *gin.Context) {
|
||||
assignmentID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
month := c.Query("month")
|
||||
|
||||
summary, err := h.store.GetHoursSummary(c.Request.Context(), assignmentID, month)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, summary)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tasks
|
||||
// ============================================================================
|
||||
|
||||
// CreateTask creates a new task for an assignment.
|
||||
// POST /sdk/v1/dsb/assignments/:id/tasks
|
||||
func (h *DSBHandlers) CreateTask(c *gin.Context) {
|
||||
assignmentID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req dsb.CreateTaskRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
task := &dsb.Task{
|
||||
AssignmentID: assignmentID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
Category: req.Category,
|
||||
Priority: req.Priority,
|
||||
DueDate: req.DueDate,
|
||||
}
|
||||
|
||||
if err := h.store.CreateTask(c.Request.Context(), task); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"task": task})
|
||||
}
|
||||
|
||||
// ListTasks returns tasks for an assignment.
|
||||
// GET /sdk/v1/dsb/assignments/:id/tasks?status=open
|
||||
func (h *DSBHandlers) ListTasks(c *gin.Context) {
|
||||
assignmentID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
status := c.Query("status")
|
||||
|
||||
tasks, err := h.store.ListTasks(c.Request.Context(), assignmentID, status)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"tasks": tasks,
|
||||
"total": len(tasks),
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateTask updates an existing task.
|
||||
// PUT /sdk/v1/dsb/tasks/:taskId
|
||||
func (h *DSBHandlers) UpdateTask(c *gin.Context) {
|
||||
taskID, err := uuid.Parse(c.Param("taskId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid task ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// We need to fetch the existing task first. Since tasks belong to assignments,
|
||||
// we query by task ID directly. For now, we do a lightweight approach: bind the
|
||||
// update request and apply changes via store.
|
||||
var req dsb.UpdateTaskRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch current task by querying all tasks and filtering. Since we don't have
|
||||
// a GetTask(taskID) method, we build the task from partial data and update.
|
||||
// The store UpdateTask uses the task ID to locate the row.
|
||||
task := &dsb.Task{ID: taskID}
|
||||
|
||||
// We need to get the current values to apply partial updates correctly.
|
||||
// Query the task directly.
|
||||
row := h.store.Pool().QueryRow(c.Request.Context(), `
|
||||
SELECT id, assignment_id, title, description, category, priority, status, due_date, completed_at, created_at, updated_at
|
||||
FROM dsb_tasks WHERE id = $1
|
||||
`, taskID)
|
||||
|
||||
if err := row.Scan(
|
||||
&task.ID, &task.AssignmentID, &task.Title, &task.Description,
|
||||
&task.Category, &task.Priority, &task.Status, &task.DueDate,
|
||||
&task.CompletedAt, &task.CreatedAt, &task.UpdatedAt,
|
||||
); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Apply non-nil fields
|
||||
if req.Title != nil {
|
||||
task.Title = *req.Title
|
||||
}
|
||||
if req.Description != nil {
|
||||
task.Description = *req.Description
|
||||
}
|
||||
if req.Category != nil {
|
||||
task.Category = *req.Category
|
||||
}
|
||||
if req.Priority != nil {
|
||||
task.Priority = *req.Priority
|
||||
}
|
||||
if req.Status != nil {
|
||||
task.Status = *req.Status
|
||||
}
|
||||
if req.DueDate != nil {
|
||||
task.DueDate = req.DueDate
|
||||
}
|
||||
|
||||
if err := h.store.UpdateTask(c.Request.Context(), task); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"task": task})
|
||||
}
|
||||
|
||||
// CompleteTask marks a task as completed.
|
||||
// POST /sdk/v1/dsb/tasks/:taskId/complete
|
||||
func (h *DSBHandlers) CompleteTask(c *gin.Context) {
|
||||
taskID, err := uuid.Parse(c.Param("taskId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid task ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.CompleteTask(c.Request.Context(), taskID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "task completed"})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Communications
|
||||
// ============================================================================
|
||||
|
||||
// CreateCommunication creates a new communication log entry.
|
||||
// POST /sdk/v1/dsb/assignments/:id/communications
|
||||
func (h *DSBHandlers) CreateCommunication(c *gin.Context) {
|
||||
assignmentID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req dsb.CreateCommunicationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
comm := &dsb.Communication{
|
||||
AssignmentID: assignmentID,
|
||||
Direction: req.Direction,
|
||||
Channel: req.Channel,
|
||||
Subject: req.Subject,
|
||||
Content: req.Content,
|
||||
Participants: req.Participants,
|
||||
}
|
||||
|
||||
if err := h.store.CreateCommunication(c.Request.Context(), comm); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"communication": comm})
|
||||
}
|
||||
|
||||
// ListCommunications returns all communications for an assignment.
|
||||
// GET /sdk/v1/dsb/assignments/:id/communications
|
||||
func (h *DSBHandlers) ListCommunications(c *gin.Context) {
|
||||
assignmentID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
comms, err := h.store.ListCommunications(c.Request.Context(), assignmentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"communications": comms,
|
||||
"total": len(comms),
|
||||
})
|
||||
}
|
||||
@@ -1,638 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/funding"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/llm"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// FundingHandlers handles funding application API endpoints
|
||||
type FundingHandlers struct {
|
||||
store funding.Store
|
||||
providerRegistry *llm.ProviderRegistry
|
||||
wizardSchema *WizardSchema
|
||||
bundeslandProfiles map[string]*BundeslandProfile
|
||||
}
|
||||
|
||||
// WizardSchema represents the loaded wizard schema
|
||||
type WizardSchema struct {
|
||||
Metadata struct {
|
||||
Version string `yaml:"version"`
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description"`
|
||||
TotalSteps int `yaml:"total_steps"`
|
||||
} `yaml:"metadata"`
|
||||
Steps []WizardStep `yaml:"steps"`
|
||||
FundingAssistant struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Model string `yaml:"model"`
|
||||
SystemPrompt string `yaml:"system_prompt"`
|
||||
StepContexts map[int]string `yaml:"step_contexts"`
|
||||
QuickPrompts []QuickPrompt `yaml:"quick_prompts"`
|
||||
} `yaml:"funding_assistant"`
|
||||
Presets map[string]Preset `yaml:"presets"`
|
||||
}
|
||||
|
||||
// WizardStep represents a step in the wizard
|
||||
type WizardStep struct {
|
||||
Number int `yaml:"number" json:"number"`
|
||||
ID string `yaml:"id" json:"id"`
|
||||
Title string `yaml:"title" json:"title"`
|
||||
Subtitle string `yaml:"subtitle" json:"subtitle"`
|
||||
Description string `yaml:"description" json:"description"`
|
||||
Icon string `yaml:"icon" json:"icon"`
|
||||
IsRequired bool `yaml:"is_required" json:"is_required"`
|
||||
Fields []WizardField `yaml:"fields" json:"fields"`
|
||||
AssistantContext string `yaml:"assistant_context" json:"assistant_context"`
|
||||
}
|
||||
|
||||
// WizardField represents a field in the wizard
|
||||
type WizardField struct {
|
||||
ID string `yaml:"id" json:"id"`
|
||||
Type string `yaml:"type" json:"type"`
|
||||
Label string `yaml:"label" json:"label"`
|
||||
Placeholder string `yaml:"placeholder,omitempty" json:"placeholder,omitempty"`
|
||||
Required bool `yaml:"required,omitempty" json:"required,omitempty"`
|
||||
Options []FieldOption `yaml:"options,omitempty" json:"options,omitempty"`
|
||||
HelpText string `yaml:"help_text,omitempty" json:"help_text,omitempty"`
|
||||
MaxLength int `yaml:"max_length,omitempty" json:"max_length,omitempty"`
|
||||
Min *int `yaml:"min,omitempty" json:"min,omitempty"`
|
||||
Max *int `yaml:"max,omitempty" json:"max,omitempty"`
|
||||
Default interface{} `yaml:"default,omitempty" json:"default,omitempty"`
|
||||
Conditional string `yaml:"conditional,omitempty" json:"conditional,omitempty"`
|
||||
}
|
||||
|
||||
// FieldOption represents an option for select fields
|
||||
type FieldOption struct {
|
||||
Value string `yaml:"value" json:"value"`
|
||||
Label string `yaml:"label" json:"label"`
|
||||
Description string `yaml:"description,omitempty" json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// QuickPrompt represents a quick prompt for the assistant
|
||||
type QuickPrompt struct {
|
||||
Label string `yaml:"label" json:"label"`
|
||||
Prompt string `yaml:"prompt" json:"prompt"`
|
||||
}
|
||||
|
||||
// Preset represents a BreakPilot preset
|
||||
type Preset struct {
|
||||
ID string `yaml:"id" json:"id"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Description string `yaml:"description" json:"description"`
|
||||
BudgetItems []funding.BudgetItem `yaml:"budget_items" json:"budget_items"`
|
||||
AutoFill map[string]interface{} `yaml:"auto_fill" json:"auto_fill"`
|
||||
}
|
||||
|
||||
// BundeslandProfile represents a federal state profile
|
||||
type BundeslandProfile struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Short string `yaml:"short" json:"short"`
|
||||
FundingPrograms []string `yaml:"funding_programs" json:"funding_programs"`
|
||||
DefaultFundingRate float64 `yaml:"default_funding_rate" json:"default_funding_rate"`
|
||||
RequiresMEP bool `yaml:"requires_mep" json:"requires_mep"`
|
||||
ContactAuthority ContactAuthority `yaml:"contact_authority" json:"contact_authority"`
|
||||
SpecialRequirements []string `yaml:"special_requirements" json:"special_requirements"`
|
||||
}
|
||||
|
||||
// ContactAuthority represents a contact authority
|
||||
type ContactAuthority struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Department string `yaml:"department,omitempty" json:"department,omitempty"`
|
||||
Website string `yaml:"website" json:"website"`
|
||||
Email string `yaml:"email,omitempty" json:"email,omitempty"`
|
||||
}
|
||||
|
||||
// NewFundingHandlers creates new funding handlers
|
||||
func NewFundingHandlers(store funding.Store, providerRegistry *llm.ProviderRegistry) *FundingHandlers {
|
||||
h := &FundingHandlers{
|
||||
store: store,
|
||||
providerRegistry: providerRegistry,
|
||||
}
|
||||
|
||||
// Load wizard schema
|
||||
if err := h.loadWizardSchema(); err != nil {
|
||||
fmt.Printf("Warning: Could not load wizard schema: %v\n", err)
|
||||
}
|
||||
|
||||
// Load bundesland profiles
|
||||
if err := h.loadBundeslandProfiles(); err != nil {
|
||||
fmt.Printf("Warning: Could not load bundesland profiles: %v\n", err)
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *FundingHandlers) loadWizardSchema() error {
|
||||
data, err := os.ReadFile("policies/funding/foerderantrag_wizard_v1.yaml")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
h.wizardSchema = &WizardSchema{}
|
||||
return yaml.Unmarshal(data, h.wizardSchema)
|
||||
}
|
||||
|
||||
func (h *FundingHandlers) loadBundeslandProfiles() error {
|
||||
data, err := os.ReadFile("policies/funding/bundesland_profiles.yaml")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var profiles struct {
|
||||
Bundeslaender map[string]*BundeslandProfile `yaml:"bundeslaender"`
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &profiles); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
h.bundeslandProfiles = profiles.Bundeslaender
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Application CRUD
|
||||
// ============================================================================
|
||||
|
||||
// CreateApplication creates a new funding application
|
||||
// POST /sdk/v1/funding/applications
|
||||
func (h *FundingHandlers) CreateApplication(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
userID := rbac.GetUserID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
var req funding.CreateApplicationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
app := &funding.FundingApplication{
|
||||
TenantID: tenantID,
|
||||
Title: req.Title,
|
||||
FundingProgram: req.FundingProgram,
|
||||
Status: funding.ApplicationStatusDraft,
|
||||
CurrentStep: 1,
|
||||
TotalSteps: 8,
|
||||
WizardData: make(map[string]interface{}),
|
||||
CreatedBy: userID,
|
||||
UpdatedBy: userID,
|
||||
}
|
||||
|
||||
// Initialize school profile with federal state
|
||||
app.SchoolProfile = &funding.SchoolProfile{
|
||||
FederalState: req.FederalState,
|
||||
}
|
||||
|
||||
// Apply preset if specified
|
||||
if req.PresetID != "" && h.wizardSchema != nil {
|
||||
if preset, ok := h.wizardSchema.Presets[req.PresetID]; ok {
|
||||
app.Budget = &funding.Budget{
|
||||
BudgetItems: preset.BudgetItems,
|
||||
}
|
||||
app.WizardData["preset_id"] = req.PresetID
|
||||
app.WizardData["preset_applied"] = true
|
||||
for k, v := range preset.AutoFill {
|
||||
app.WizardData[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.store.CreateApplication(c.Request.Context(), app); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Add history entry
|
||||
h.store.AddHistoryEntry(c.Request.Context(), &funding.ApplicationHistoryEntry{
|
||||
ApplicationID: app.ID,
|
||||
Action: "created",
|
||||
PerformedBy: userID,
|
||||
Notes: "Antrag erstellt",
|
||||
})
|
||||
|
||||
c.JSON(http.StatusCreated, app)
|
||||
}
|
||||
|
||||
// GetApplication retrieves a funding application
|
||||
// GET /sdk/v1/funding/applications/:id
|
||||
func (h *FundingHandlers) GetApplication(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid application ID"})
|
||||
return
|
||||
}
|
||||
|
||||
app, err := h.store.GetApplication(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, app)
|
||||
}
|
||||
|
||||
// ListApplications returns a list of funding applications
|
||||
// GET /sdk/v1/funding/applications
|
||||
func (h *FundingHandlers) ListApplications(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
filter := funding.ApplicationFilter{
|
||||
Page: 1,
|
||||
PageSize: 20,
|
||||
}
|
||||
|
||||
// Parse query parameters
|
||||
if status := c.Query("status"); status != "" {
|
||||
s := funding.ApplicationStatus(status)
|
||||
filter.Status = &s
|
||||
}
|
||||
if program := c.Query("program"); program != "" {
|
||||
p := funding.FundingProgram(program)
|
||||
filter.FundingProgram = &p
|
||||
}
|
||||
|
||||
result, err := h.store.ListApplications(c.Request.Context(), tenantID, filter)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// UpdateApplication updates a funding application
|
||||
// PUT /sdk/v1/funding/applications/:id
|
||||
func (h *FundingHandlers) UpdateApplication(c *gin.Context) {
|
||||
userID := rbac.GetUserID(c)
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid application ID"})
|
||||
return
|
||||
}
|
||||
|
||||
app, err := h.store.GetApplication(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var req funding.UpdateApplicationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Title != nil {
|
||||
app.Title = *req.Title
|
||||
}
|
||||
if req.WizardData != nil {
|
||||
for k, v := range req.WizardData {
|
||||
app.WizardData[k] = v
|
||||
}
|
||||
}
|
||||
if req.CurrentStep != nil {
|
||||
app.CurrentStep = *req.CurrentStep
|
||||
}
|
||||
app.UpdatedBy = userID
|
||||
|
||||
if err := h.store.UpdateApplication(c.Request.Context(), app); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, app)
|
||||
}
|
||||
|
||||
// DeleteApplication deletes a funding application
|
||||
// DELETE /sdk/v1/funding/applications/:id
|
||||
func (h *FundingHandlers) DeleteApplication(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid application ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.DeleteApplication(c.Request.Context(), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "application archived"})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Wizard Endpoints
|
||||
// ============================================================================
|
||||
|
||||
// GetWizardSchema returns the wizard schema
|
||||
// GET /sdk/v1/funding/wizard/schema
|
||||
func (h *FundingHandlers) GetWizardSchema(c *gin.Context) {
|
||||
if h.wizardSchema == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "wizard schema not loaded"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"metadata": h.wizardSchema.Metadata,
|
||||
"steps": h.wizardSchema.Steps,
|
||||
"presets": h.wizardSchema.Presets,
|
||||
"assistant": gin.H{
|
||||
"enabled": h.wizardSchema.FundingAssistant.Enabled,
|
||||
"quick_prompts": h.wizardSchema.FundingAssistant.QuickPrompts,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// SaveWizardStep saves wizard step data
|
||||
// POST /sdk/v1/funding/applications/:id/wizard
|
||||
func (h *FundingHandlers) SaveWizardStep(c *gin.Context) {
|
||||
userID := rbac.GetUserID(c)
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid application ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req funding.SaveWizardStepRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Save step data
|
||||
if err := h.store.SaveWizardStep(c.Request.Context(), id, req.Step, req.Data); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get updated progress
|
||||
progress, err := h.store.GetWizardProgress(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Add history entry
|
||||
h.store.AddHistoryEntry(c.Request.Context(), &funding.ApplicationHistoryEntry{
|
||||
ApplicationID: id,
|
||||
Action: "wizard_step_saved",
|
||||
PerformedBy: userID,
|
||||
Notes: fmt.Sprintf("Schritt %d gespeichert", req.Step),
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, progress)
|
||||
}
|
||||
|
||||
// AskAssistant handles LLM assistant queries
|
||||
// POST /sdk/v1/funding/wizard/ask
|
||||
func (h *FundingHandlers) AskAssistant(c *gin.Context) {
|
||||
var req funding.AssistantRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if h.wizardSchema == nil || !h.wizardSchema.FundingAssistant.Enabled {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "assistant not available"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build system prompt with step context
|
||||
systemPrompt := h.wizardSchema.FundingAssistant.SystemPrompt
|
||||
if stepContext, ok := h.wizardSchema.FundingAssistant.StepContexts[req.CurrentStep]; ok {
|
||||
systemPrompt += "\n\nKontext fuer diesen Schritt:\n" + stepContext
|
||||
}
|
||||
|
||||
// Build messages
|
||||
messages := []llm.Message{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
}
|
||||
for _, msg := range req.History {
|
||||
messages = append(messages, llm.Message{
|
||||
Role: msg.Role,
|
||||
Content: msg.Content,
|
||||
})
|
||||
}
|
||||
messages = append(messages, llm.Message{
|
||||
Role: "user",
|
||||
Content: req.Question,
|
||||
})
|
||||
|
||||
// Generate response using registry
|
||||
chatReq := &llm.ChatRequest{
|
||||
Messages: messages,
|
||||
Temperature: 0.3,
|
||||
MaxTokens: 1000,
|
||||
}
|
||||
|
||||
response, err := h.providerRegistry.Chat(c.Request.Context(), chatReq)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, funding.AssistantResponse{
|
||||
Answer: response.Message.Content,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Status Endpoints
|
||||
// ============================================================================
|
||||
|
||||
// SubmitApplication submits an application for review
|
||||
// POST /sdk/v1/funding/applications/:id/submit
|
||||
func (h *FundingHandlers) SubmitApplication(c *gin.Context) {
|
||||
userID := rbac.GetUserID(c)
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid application ID"})
|
||||
return
|
||||
}
|
||||
|
||||
app, err := h.store.GetApplication(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate that all required steps are completed
|
||||
progress, _ := h.store.GetWizardProgress(c.Request.Context(), id)
|
||||
if progress == nil || len(progress.CompletedSteps) < app.TotalSteps {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "not all required steps completed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update status
|
||||
app.Status = funding.ApplicationStatusSubmitted
|
||||
now := time.Now()
|
||||
app.SubmittedAt = &now
|
||||
app.UpdatedBy = userID
|
||||
|
||||
if err := h.store.UpdateApplication(c.Request.Context(), app); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Add history entry
|
||||
h.store.AddHistoryEntry(c.Request.Context(), &funding.ApplicationHistoryEntry{
|
||||
ApplicationID: id,
|
||||
Action: "submitted",
|
||||
PerformedBy: userID,
|
||||
Notes: "Antrag eingereicht",
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, app)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Export Endpoints
|
||||
// ============================================================================
|
||||
|
||||
// ExportApplication exports all documents as ZIP
|
||||
// GET /sdk/v1/funding/applications/:id/export
|
||||
func (h *FundingHandlers) ExportApplication(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid application ID"})
|
||||
return
|
||||
}
|
||||
|
||||
app, err := h.store.GetApplication(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate export (this will be implemented in export.go)
|
||||
// For now, return a placeholder response
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Export generation initiated",
|
||||
"application_id": app.ID,
|
||||
"status": "processing",
|
||||
})
|
||||
}
|
||||
|
||||
// PreviewApplication generates a PDF preview
|
||||
// GET /sdk/v1/funding/applications/:id/preview
|
||||
func (h *FundingHandlers) PreviewApplication(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid application ID"})
|
||||
return
|
||||
}
|
||||
|
||||
app, err := h.store.GetApplication(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate PDF preview (placeholder)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Preview generation initiated",
|
||||
"application_id": app.ID,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Bundesland Profile Endpoints
|
||||
// ============================================================================
|
||||
|
||||
// GetBundeslandProfiles returns all bundesland profiles
|
||||
// GET /sdk/v1/funding/bundeslaender
|
||||
func (h *FundingHandlers) GetBundeslandProfiles(c *gin.Context) {
|
||||
if h.bundeslandProfiles == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "bundesland profiles not loaded"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, h.bundeslandProfiles)
|
||||
}
|
||||
|
||||
// GetBundeslandProfile returns a specific bundesland profile
|
||||
// GET /sdk/v1/funding/bundeslaender/:state
|
||||
func (h *FundingHandlers) GetBundeslandProfile(c *gin.Context) {
|
||||
state := c.Param("state")
|
||||
|
||||
if h.bundeslandProfiles == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "bundesland profiles not loaded"})
|
||||
return
|
||||
}
|
||||
|
||||
profile, ok := h.bundeslandProfiles[state]
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "bundesland not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, profile)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Statistics Endpoint
|
||||
// ============================================================================
|
||||
|
||||
// GetStatistics returns funding statistics
|
||||
// GET /sdk/v1/funding/statistics
|
||||
func (h *FundingHandlers) GetStatistics(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// History Endpoint
|
||||
// ============================================================================
|
||||
|
||||
// GetApplicationHistory returns the audit trail
|
||||
// GET /sdk/v1/funding/applications/:id/history
|
||||
func (h *FundingHandlers) GetApplicationHistory(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid application ID"})
|
||||
return
|
||||
}
|
||||
|
||||
history, err := h.store.GetHistory(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, history)
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/gci"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type GCIHandlers struct {
|
||||
engine *gci.Engine
|
||||
}
|
||||
|
||||
func NewGCIHandlers(engine *gci.Engine) *GCIHandlers {
|
||||
return &GCIHandlers{engine: engine}
|
||||
}
|
||||
|
||||
// GetScore returns the GCI score for the current tenant
|
||||
// GET /sdk/v1/gci/score
|
||||
func (h *GCIHandlers) GetScore(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c).String()
|
||||
profile := c.DefaultQuery("profile", "default")
|
||||
|
||||
result := h.engine.Calculate(tenantID, profile)
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GetScoreBreakdown returns the detailed 4-level GCI breakdown
|
||||
// GET /sdk/v1/gci/score/breakdown
|
||||
func (h *GCIHandlers) GetScoreBreakdown(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c).String()
|
||||
profile := c.DefaultQuery("profile", "default")
|
||||
|
||||
breakdown := h.engine.CalculateBreakdown(tenantID, profile)
|
||||
c.JSON(http.StatusOK, breakdown)
|
||||
}
|
||||
|
||||
// GetHistory returns historical GCI snapshots for trend analysis
|
||||
// GET /sdk/v1/gci/score/history
|
||||
func (h *GCIHandlers) GetHistory(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c).String()
|
||||
|
||||
history := h.engine.GetHistory(tenantID)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"tenant_id": tenantID,
|
||||
"snapshots": history,
|
||||
"total": len(history),
|
||||
})
|
||||
}
|
||||
|
||||
// GetMatrix returns the compliance matrix (roles x regulations)
|
||||
// GET /sdk/v1/gci/matrix
|
||||
func (h *GCIHandlers) GetMatrix(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c).String()
|
||||
|
||||
matrix := h.engine.GetMatrix(tenantID)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"tenant_id": tenantID,
|
||||
"matrix": matrix,
|
||||
})
|
||||
}
|
||||
|
||||
// GetAuditTrail returns the audit trail for the latest GCI calculation
|
||||
// GET /sdk/v1/gci/audit-trail
|
||||
func (h *GCIHandlers) GetAuditTrail(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c).String()
|
||||
profile := c.DefaultQuery("profile", "default")
|
||||
|
||||
result := h.engine.Calculate(tenantID, profile)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"tenant_id": tenantID,
|
||||
"gci_score": result.GCIScore,
|
||||
"audit_trail": result.AuditTrail,
|
||||
})
|
||||
}
|
||||
|
||||
// GetNIS2Score returns the NIS2-specific compliance score
|
||||
// GET /sdk/v1/gci/nis2/score
|
||||
func (h *GCIHandlers) GetNIS2Score(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c).String()
|
||||
|
||||
score := gci.CalculateNIS2Score(tenantID)
|
||||
c.JSON(http.StatusOK, score)
|
||||
}
|
||||
|
||||
// ListNIS2Roles returns available NIS2 responsibility roles
|
||||
// GET /sdk/v1/gci/nis2/roles
|
||||
func (h *GCIHandlers) ListNIS2Roles(c *gin.Context) {
|
||||
roles := gci.ListNIS2Roles()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"roles": roles,
|
||||
"total": len(roles),
|
||||
})
|
||||
}
|
||||
|
||||
// AssignNIS2Role assigns a NIS2 role to a user (stub - returns mock)
|
||||
// POST /sdk/v1/gci/nis2/roles/assign
|
||||
func (h *GCIHandlers) AssignNIS2Role(c *gin.Context) {
|
||||
var req struct {
|
||||
RoleID string `json:"role_id" binding:"required"`
|
||||
UserID string `json:"user_id" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
role, found := gci.GetNIS2Role(req.RoleID)
|
||||
if !found {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "NIS2 role not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "assigned",
|
||||
"role": role,
|
||||
"user_id": req.UserID,
|
||||
})
|
||||
}
|
||||
|
||||
// GetISOGapAnalysis returns the ISO 27001 gap analysis
|
||||
// GET /sdk/v1/gci/iso/gap-analysis
|
||||
func (h *GCIHandlers) GetISOGapAnalysis(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c).String()
|
||||
|
||||
analysis := gci.CalculateISOGapAnalysis(tenantID)
|
||||
c.JSON(http.StatusOK, analysis)
|
||||
}
|
||||
|
||||
// ListISOMappings returns all ISO 27001 control mappings
|
||||
// GET /sdk/v1/gci/iso/mappings
|
||||
func (h *GCIHandlers) ListISOMappings(c *gin.Context) {
|
||||
category := c.Query("category")
|
||||
|
||||
if category != "" {
|
||||
controls := gci.GetISOControlsByCategory(category)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"controls": controls,
|
||||
"total": len(controls),
|
||||
"category": category,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
categories := []string{"A.5", "A.6", "A.7", "A.8"}
|
||||
result := make(map[string][]gci.ISOControl)
|
||||
total := 0
|
||||
for _, cat := range categories {
|
||||
controls := gci.GetISOControlsByCategory(cat)
|
||||
if len(controls) > 0 {
|
||||
result[cat] = controls
|
||||
total += len(controls)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"categories": result,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
// GetISOMapping returns a single ISO control by ID
|
||||
// GET /sdk/v1/gci/iso/mappings/:controlId
|
||||
func (h *GCIHandlers) GetISOMapping(c *gin.Context) {
|
||||
controlID := c.Param("controlId")
|
||||
|
||||
control, found := gci.GetISOControlByID(controlID)
|
||||
if !found {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "ISO control not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, control)
|
||||
}
|
||||
|
||||
// GetWeightProfiles returns available weighting profiles
|
||||
// GET /sdk/v1/gci/profiles
|
||||
func (h *GCIHandlers) GetWeightProfiles(c *gin.Context) {
|
||||
profiles := []string{"default", "nis2_relevant", "ki_nutzer"}
|
||||
result := make([]gci.WeightProfile, 0, len(profiles))
|
||||
for _, id := range profiles {
|
||||
result = append(result, gci.GetProfile(id))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"profiles": result,
|
||||
})
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/industry"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// IndustryHandlers handles industry-specific compliance template requests.
|
||||
// All data is static (embedded Go structs), so no store/database is needed.
|
||||
type IndustryHandlers struct{}
|
||||
|
||||
// NewIndustryHandlers creates new industry handlers
|
||||
func NewIndustryHandlers() *IndustryHandlers {
|
||||
return &IndustryHandlers{}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Industry Template Endpoints
|
||||
// ============================================================================
|
||||
|
||||
// ListIndustries returns a summary list of all available industry templates.
|
||||
// GET /sdk/v1/industries
|
||||
func (h *IndustryHandlers) ListIndustries(c *gin.Context) {
|
||||
templates := industry.GetAllTemplates()
|
||||
|
||||
summaries := make([]industry.IndustrySummary, 0, len(templates))
|
||||
for _, t := range templates {
|
||||
summaries = append(summaries, industry.IndustrySummary{
|
||||
Slug: t.Slug,
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
Icon: t.Icon,
|
||||
RegulationCount: len(t.Regulations),
|
||||
TemplateCount: len(t.VVTTemplates),
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, industry.IndustryListResponse{
|
||||
Industries: summaries,
|
||||
Total: len(summaries),
|
||||
})
|
||||
}
|
||||
|
||||
// GetIndustry returns the full industry template for a given slug.
|
||||
// GET /sdk/v1/industries/:slug
|
||||
func (h *IndustryHandlers) GetIndustry(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
|
||||
tmpl := industry.GetTemplateBySlug(slug)
|
||||
if tmpl == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "industry template not found", "slug": slug})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tmpl)
|
||||
}
|
||||
|
||||
// GetVVTTemplates returns only the VVT templates for a given industry.
|
||||
// GET /sdk/v1/industries/:slug/vvt-templates
|
||||
func (h *IndustryHandlers) GetVVTTemplates(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
|
||||
tmpl := industry.GetTemplateBySlug(slug)
|
||||
if tmpl == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "industry template not found", "slug": slug})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"slug": tmpl.Slug,
|
||||
"industry": tmpl.Name,
|
||||
"vvt_templates": tmpl.VVTTemplates,
|
||||
"total": len(tmpl.VVTTemplates),
|
||||
})
|
||||
}
|
||||
|
||||
// GetTOMRecommendations returns only the TOM recommendations for a given industry.
|
||||
// GET /sdk/v1/industries/:slug/tom-recommendations
|
||||
func (h *IndustryHandlers) GetTOMRecommendations(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
|
||||
tmpl := industry.GetTemplateBySlug(slug)
|
||||
if tmpl == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "industry template not found", "slug": slug})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"slug": tmpl.Slug,
|
||||
"industry": tmpl.Name,
|
||||
"tom_recommendations": tmpl.TOMRecommendations,
|
||||
"total": len(tmpl.TOMRecommendations),
|
||||
})
|
||||
}
|
||||
|
||||
// GetRiskScenarios returns only the risk scenarios for a given industry.
|
||||
// GET /sdk/v1/industries/:slug/risk-scenarios
|
||||
func (h *IndustryHandlers) GetRiskScenarios(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
|
||||
tmpl := industry.GetTemplateBySlug(slug)
|
||||
if tmpl == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "industry template not found", "slug": slug})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"slug": tmpl.Slug,
|
||||
"industry": tmpl.Name,
|
||||
"risk_scenarios": tmpl.RiskScenarios,
|
||||
"total": len(tmpl.RiskScenarios),
|
||||
})
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/multitenant"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// MultiTenantHandlers handles multi-tenant administration endpoints.
|
||||
type MultiTenantHandlers struct {
|
||||
store *multitenant.Store
|
||||
rbacStore *rbac.Store
|
||||
}
|
||||
|
||||
// NewMultiTenantHandlers creates new multi-tenant handlers.
|
||||
func NewMultiTenantHandlers(store *multitenant.Store, rbacStore *rbac.Store) *MultiTenantHandlers {
|
||||
return &MultiTenantHandlers{
|
||||
store: store,
|
||||
rbacStore: rbacStore,
|
||||
}
|
||||
}
|
||||
|
||||
// GetOverview returns all tenants with compliance scores and module highlights.
|
||||
// GET /sdk/v1/multi-tenant/overview
|
||||
func (h *MultiTenantHandlers) GetOverview(c *gin.Context) {
|
||||
overview, err := h.store.GetOverview(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, overview)
|
||||
}
|
||||
|
||||
// GetTenantDetail returns detailed compliance info for one tenant.
|
||||
// GET /sdk/v1/multi-tenant/tenants/:id
|
||||
func (h *MultiTenantHandlers) GetTenantDetail(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tenant ID"})
|
||||
return
|
||||
}
|
||||
|
||||
detail, err := h.store.GetTenantDetail(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "tenant not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, detail)
|
||||
}
|
||||
|
||||
// CreateTenant creates a new tenant with default setup.
|
||||
// It creates the tenant via the RBAC store and then creates a default "main" namespace.
|
||||
// POST /sdk/v1/multi-tenant/tenants
|
||||
func (h *MultiTenantHandlers) CreateTenant(c *gin.Context) {
|
||||
var req multitenant.CreateTenantRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Build the tenant from the request
|
||||
tenant := &rbac.Tenant{
|
||||
Name: req.Name,
|
||||
Slug: req.Slug,
|
||||
MaxUsers: req.MaxUsers,
|
||||
LLMQuotaMonthly: req.LLMQuotaMonthly,
|
||||
}
|
||||
|
||||
// Create tenant via RBAC store (assigns ID, timestamps, defaults)
|
||||
if err := h.rbacStore.CreateTenant(c.Request.Context(), tenant); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Create default "main" namespace for the new tenant
|
||||
defaultNamespace := &rbac.Namespace{
|
||||
TenantID: tenant.ID,
|
||||
Name: "Main",
|
||||
Slug: "main",
|
||||
}
|
||||
if err := h.rbacStore.CreateNamespace(c.Request.Context(), defaultNamespace); err != nil {
|
||||
// Tenant was created successfully but namespace creation failed.
|
||||
// Log and continue -- the tenant is still usable.
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"tenant": tenant,
|
||||
"warning": "tenant created but default namespace creation failed: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"tenant": tenant,
|
||||
"namespace": defaultNamespace,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateTenant performs a partial update of tenant settings.
|
||||
// Only non-nil fields in the request body are applied.
|
||||
// PUT /sdk/v1/multi-tenant/tenants/:id
|
||||
func (h *MultiTenantHandlers) UpdateTenant(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tenant ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req multitenant.UpdateTenantRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch the existing tenant so we can apply partial updates
|
||||
tenant, err := h.rbacStore.GetTenant(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "tenant not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Apply only the fields that were provided
|
||||
if req.Name != nil {
|
||||
tenant.Name = *req.Name
|
||||
}
|
||||
if req.MaxUsers != nil {
|
||||
tenant.MaxUsers = *req.MaxUsers
|
||||
}
|
||||
if req.LLMQuotaMonthly != nil {
|
||||
tenant.LLMQuotaMonthly = *req.LLMQuotaMonthly
|
||||
}
|
||||
if req.Status != nil {
|
||||
tenant.Status = rbac.TenantStatus(*req.Status)
|
||||
}
|
||||
|
||||
if err := h.rbacStore.UpdateTenant(c.Request.Context(), tenant); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tenant)
|
||||
}
|
||||
|
||||
// ListNamespaces returns all namespaces for a specific tenant.
|
||||
// GET /sdk/v1/multi-tenant/tenants/:id/namespaces
|
||||
func (h *MultiTenantHandlers) ListNamespaces(c *gin.Context) {
|
||||
tenantID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tenant ID"})
|
||||
return
|
||||
}
|
||||
|
||||
namespaces, err := h.rbacStore.ListNamespaces(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"namespaces": namespaces,
|
||||
"total": len(namespaces),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateNamespace creates a new namespace within a tenant.
|
||||
// POST /sdk/v1/multi-tenant/tenants/:id/namespaces
|
||||
func (h *MultiTenantHandlers) CreateNamespace(c *gin.Context) {
|
||||
tenantID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tenant ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the tenant exists
|
||||
_, err = h.rbacStore.GetTenant(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "tenant not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var req multitenant.CreateNamespaceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
namespace := &rbac.Namespace{
|
||||
TenantID: tenantID,
|
||||
Name: req.Name,
|
||||
Slug: req.Slug,
|
||||
}
|
||||
|
||||
// Apply optional fields if provided
|
||||
if req.IsolationLevel != "" {
|
||||
namespace.IsolationLevel = rbac.IsolationLevel(req.IsolationLevel)
|
||||
}
|
||||
if req.DataClassification != "" {
|
||||
namespace.DataClassification = rbac.DataClassification(req.DataClassification)
|
||||
}
|
||||
|
||||
if err := h.rbacStore.CreateNamespace(c.Request.Context(), namespace); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, namespace)
|
||||
}
|
||||
|
||||
// SwitchTenant returns the tenant info needed for the frontend to switch context.
|
||||
// The caller provides a tenant_id and receives back the tenant details needed
|
||||
// to update the frontend's active tenant state.
|
||||
// POST /sdk/v1/multi-tenant/switch
|
||||
func (h *MultiTenantHandlers) SwitchTenant(c *gin.Context) {
|
||||
var req multitenant.SwitchTenantRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tenantID, err := uuid.Parse(req.TenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tenant ID"})
|
||||
return
|
||||
}
|
||||
|
||||
tenant, err := h.rbacStore.GetTenant(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "tenant not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the tenant is active
|
||||
if tenant.Status != rbac.TenantStatusActive {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "tenant not active",
|
||||
"status": string(tenant.Status),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get namespaces for the tenant so the frontend can populate namespace selectors
|
||||
namespaces, err := h.rbacStore.ListNamespaces(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
// Non-fatal: return tenant info without namespaces
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"tenant": multitenant.SwitchTenantResponse{
|
||||
TenantID: tenant.ID,
|
||||
TenantName: tenant.Name,
|
||||
TenantSlug: tenant.Slug,
|
||||
Status: string(tenant.Status),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"tenant": multitenant.SwitchTenantResponse{
|
||||
TenantID: tenant.ID,
|
||||
TenantName: tenant.Name,
|
||||
TenantSlug: tenant.Slug,
|
||||
Status: string(tenant.Status),
|
||||
},
|
||||
"namespaces": namespaces,
|
||||
})
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/reporting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ReportingHandlers struct {
|
||||
store *reporting.Store
|
||||
}
|
||||
|
||||
func NewReportingHandlers(store *reporting.Store) *ReportingHandlers {
|
||||
return &ReportingHandlers{store: store}
|
||||
}
|
||||
|
||||
// GetExecutiveReport generates a comprehensive compliance report
|
||||
// GET /sdk/v1/reporting/executive
|
||||
func (h *ReportingHandlers) GetExecutiveReport(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
report, err := h.store.GenerateReport(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, report)
|
||||
}
|
||||
|
||||
// GetComplianceScore returns just the overall compliance score
|
||||
// GET /sdk/v1/reporting/score
|
||||
func (h *ReportingHandlers) GetComplianceScore(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
report, err := h.store.GenerateReport(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"compliance_score": report.ComplianceScore,
|
||||
"risk_level": report.RiskOverview.OverallLevel,
|
||||
"generated_at": report.GeneratedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// GetUpcomingDeadlines returns deadlines across all modules
|
||||
// GET /sdk/v1/reporting/deadlines
|
||||
func (h *ReportingHandlers) GetUpcomingDeadlines(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
report, err := h.store.GenerateReport(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"deadlines": report.UpcomingDeadlines,
|
||||
"total": len(report.UpcomingDeadlines),
|
||||
})
|
||||
}
|
||||
|
||||
// GetRiskOverview returns the aggregated risk assessment
|
||||
// GET /sdk/v1/reporting/risks
|
||||
func (h *ReportingHandlers) GetRiskOverview(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
report, err := h.store.GenerateReport(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, report.RiskOverview)
|
||||
}
|
||||
@@ -1,631 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/sso"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// SSOHandlers handles SSO-related HTTP requests.
|
||||
type SSOHandlers struct {
|
||||
store *sso.Store
|
||||
jwtSecret string
|
||||
}
|
||||
|
||||
// NewSSOHandlers creates new SSO handlers.
|
||||
func NewSSOHandlers(store *sso.Store, jwtSecret string) *SSOHandlers {
|
||||
return &SSOHandlers{store: store, jwtSecret: jwtSecret}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SSO Configuration CRUD
|
||||
// ============================================================================
|
||||
|
||||
// CreateConfig creates a new SSO configuration for the tenant.
|
||||
// POST /sdk/v1/sso/configs
|
||||
func (h *SSOHandlers) CreateConfig(c *gin.Context) {
|
||||
var req sso.CreateSSOConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
|
||||
cfg, err := h.store.CreateConfig(c.Request.Context(), tenantID, &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"config": cfg})
|
||||
}
|
||||
|
||||
// ListConfigs lists all SSO configurations for the tenant.
|
||||
// GET /sdk/v1/sso/configs
|
||||
func (h *SSOHandlers) ListConfigs(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
|
||||
configs, err := h.store.ListConfigs(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"configs": configs,
|
||||
"total": len(configs),
|
||||
})
|
||||
}
|
||||
|
||||
// GetConfig retrieves an SSO configuration by ID.
|
||||
// GET /sdk/v1/sso/configs/:id
|
||||
func (h *SSOHandlers) GetConfig(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
|
||||
configID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid config ID"})
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.store.GetConfig(c.Request.Context(), tenantID, configID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if cfg == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "sso configuration not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"config": cfg})
|
||||
}
|
||||
|
||||
// UpdateConfig updates an SSO configuration.
|
||||
// PUT /sdk/v1/sso/configs/:id
|
||||
func (h *SSOHandlers) UpdateConfig(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
|
||||
configID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid config ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req sso.UpdateSSOConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.store.UpdateConfig(c.Request.Context(), tenantID, configID, &req)
|
||||
if err != nil {
|
||||
if err.Error() == "sso configuration not found" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"config": cfg})
|
||||
}
|
||||
|
||||
// DeleteConfig deletes an SSO configuration.
|
||||
// DELETE /sdk/v1/sso/configs/:id
|
||||
func (h *SSOHandlers) DeleteConfig(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
|
||||
configID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid config ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.DeleteConfig(c.Request.Context(), tenantID, configID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "sso configuration deleted"})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SSO Users
|
||||
// ============================================================================
|
||||
|
||||
// ListUsers lists all SSO-provisioned users for the tenant.
|
||||
// GET /sdk/v1/sso/users
|
||||
func (h *SSOHandlers) ListUsers(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
|
||||
users, err := h.store.ListUsers(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"users": users,
|
||||
"total": len(users),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OIDC Flow
|
||||
// ============================================================================
|
||||
|
||||
// InitiateOIDCLogin initiates the OIDC authorization code flow.
|
||||
// It looks up the enabled SSO config for the tenant, builds the authorization
|
||||
// URL, sets a state cookie, and redirects the user to the IdP.
|
||||
// GET /sdk/v1/sso/oidc/login
|
||||
func (h *SSOHandlers) InitiateOIDCLogin(c *gin.Context) {
|
||||
// Resolve tenant ID from query param
|
||||
tenantIDStr := c.Query("tenant_id")
|
||||
if tenantIDStr == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant_id query parameter is required"})
|
||||
return
|
||||
}
|
||||
|
||||
tenantID, err := uuid.Parse(tenantIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tenant_id"})
|
||||
return
|
||||
}
|
||||
|
||||
// Look up the enabled SSO config
|
||||
cfg, err := h.store.GetEnabledConfig(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if cfg == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no enabled SSO configuration found for this tenant"})
|
||||
return
|
||||
}
|
||||
if cfg.ProviderType != sso.ProviderTypeOIDC {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "SSO configuration is not OIDC"})
|
||||
return
|
||||
}
|
||||
|
||||
// Discover the authorization endpoint
|
||||
discoveryURL := strings.TrimSuffix(cfg.OIDCIssuerURL, "/") + "/.well-known/openid-configuration"
|
||||
authEndpoint, _, _, err := discoverOIDCEndpoints(discoveryURL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("OIDC discovery failed: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate state parameter (random bytes + tenant_id for correlation)
|
||||
stateBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(stateBytes); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate state"})
|
||||
return
|
||||
}
|
||||
state := base64.URLEncoding.EncodeToString(stateBytes) + "." + tenantID.String()
|
||||
|
||||
// Generate nonce
|
||||
nonceBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(nonceBytes); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate nonce"})
|
||||
return
|
||||
}
|
||||
nonce := base64.URLEncoding.EncodeToString(nonceBytes)
|
||||
|
||||
// Build authorization URL
|
||||
scopes := cfg.OIDCScopes
|
||||
if len(scopes) == 0 {
|
||||
scopes = []string{"openid", "profile", "email"}
|
||||
}
|
||||
|
||||
params := url.Values{
|
||||
"client_id": {cfg.OIDCClientID},
|
||||
"redirect_uri": {cfg.OIDCRedirectURI},
|
||||
"response_type": {"code"},
|
||||
"scope": {strings.Join(scopes, " ")},
|
||||
"state": {state},
|
||||
"nonce": {nonce},
|
||||
}
|
||||
|
||||
authURL := authEndpoint + "?" + params.Encode()
|
||||
|
||||
// Set state cookie for CSRF protection (HttpOnly, 10 min expiry)
|
||||
c.SetCookie("sso_state", state, 600, "/", "", true, true)
|
||||
c.SetCookie("sso_nonce", nonce, 600, "/", "", true, true)
|
||||
|
||||
c.Redirect(http.StatusFound, authURL)
|
||||
}
|
||||
|
||||
// HandleOIDCCallback handles the OIDC authorization code callback from the IdP.
|
||||
// It validates the state, exchanges the code for tokens, extracts user info,
|
||||
// performs JIT user provisioning, and issues a JWT.
|
||||
// GET /sdk/v1/sso/oidc/callback
|
||||
func (h *SSOHandlers) HandleOIDCCallback(c *gin.Context) {
|
||||
// Check for errors from the IdP
|
||||
if errParam := c.Query("error"); errParam != "" {
|
||||
errDesc := c.Query("error_description")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": errParam,
|
||||
"description": errDesc,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
code := c.Query("code")
|
||||
stateParam := c.Query("state")
|
||||
if code == "" || stateParam == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing code or state parameter"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate state cookie
|
||||
stateCookie, err := c.Cookie("sso_state")
|
||||
if err != nil || stateCookie != stateParam {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state parameter (CSRF check failed)"})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract tenant ID from state
|
||||
parts := strings.SplitN(stateParam, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "malformed state parameter"})
|
||||
return
|
||||
}
|
||||
tenantID, err := uuid.Parse(parts[1])
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tenant_id in state"})
|
||||
return
|
||||
}
|
||||
|
||||
// Look up the enabled SSO config
|
||||
cfg, err := h.store.GetEnabledConfig(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if cfg == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no enabled SSO configuration found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Discover OIDC endpoints
|
||||
discoveryURL := strings.TrimSuffix(cfg.OIDCIssuerURL, "/") + "/.well-known/openid-configuration"
|
||||
_, tokenEndpoint, userInfoEndpoint, err := discoverOIDCEndpoints(discoveryURL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("OIDC discovery failed: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
// Exchange authorization code for tokens
|
||||
tokenResp, err := exchangeCodeForTokens(tokenEndpoint, code, cfg.OIDCClientID, cfg.OIDCClientSecret, cfg.OIDCRedirectURI)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("token exchange failed: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract user claims from ID token or UserInfo endpoint
|
||||
claims, err := extractUserClaims(tokenResp, userInfoEndpoint)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to extract user claims: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
sub := getStringClaim(claims, "sub")
|
||||
email := getStringClaim(claims, "email")
|
||||
name := getStringClaim(claims, "name")
|
||||
groups := getStringSliceClaim(claims, "groups")
|
||||
|
||||
if sub == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID token missing 'sub' claim"})
|
||||
return
|
||||
}
|
||||
if email == "" {
|
||||
email = sub
|
||||
}
|
||||
if name == "" {
|
||||
name = email
|
||||
}
|
||||
|
||||
// JIT provision the user
|
||||
user, err := h.store.UpsertUser(c.Request.Context(), tenantID, cfg.ID, sub, email, name, groups)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("user provisioning failed: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
// Determine roles from role mapping
|
||||
roles := resolveRoles(cfg, groups)
|
||||
|
||||
// Generate JWT
|
||||
ssoClaims := sso.SSOClaims{
|
||||
UserID: user.ID,
|
||||
TenantID: tenantID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.DisplayName,
|
||||
Roles: roles,
|
||||
SSOConfigID: cfg.ID,
|
||||
}
|
||||
|
||||
jwtToken, err := h.generateJWT(ssoClaims)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("JWT generation failed: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
// Clear state cookies
|
||||
c.SetCookie("sso_state", "", -1, "/", "", true, true)
|
||||
c.SetCookie("sso_nonce", "", -1, "/", "", true, true)
|
||||
|
||||
// Return JWT as JSON (the frontend can also handle redirect)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": jwtToken,
|
||||
"user": user,
|
||||
"roles": roles,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// JWT Generation
|
||||
// ============================================================================
|
||||
|
||||
// generateJWT creates a signed JWT token containing the SSO claims.
|
||||
func (h *SSOHandlers) generateJWT(claims sso.SSOClaims) (string, error) {
|
||||
now := time.Now().UTC()
|
||||
expiry := now.Add(24 * time.Hour)
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"user_id": claims.UserID.String(),
|
||||
"tenant_id": claims.TenantID.String(),
|
||||
"email": claims.Email,
|
||||
"display_name": claims.DisplayName,
|
||||
"roles": claims.Roles,
|
||||
"sso_config_id": claims.SSOConfigID.String(),
|
||||
"iss": "ai-compliance-sdk",
|
||||
"iat": now.Unix(),
|
||||
"exp": expiry.Unix(),
|
||||
})
|
||||
|
||||
tokenString, err := token.SignedString([]byte(h.jwtSecret))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign JWT: %w", err)
|
||||
}
|
||||
|
||||
return tokenString, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OIDC Discovery & Token Exchange (manual HTTP, no external OIDC library)
|
||||
// ============================================================================
|
||||
|
||||
// oidcDiscoveryResponse holds the relevant fields from the OIDC discovery document.
|
||||
type oidcDiscoveryResponse struct {
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
UserinfoEndpoint string `json:"userinfo_endpoint"`
|
||||
JwksURI string `json:"jwks_uri"`
|
||||
Issuer string `json:"issuer"`
|
||||
}
|
||||
|
||||
// discoverOIDCEndpoints fetches the OIDC discovery document and returns
|
||||
// the authorization, token, and userinfo endpoints.
|
||||
func discoverOIDCEndpoints(discoveryURL string) (authEndpoint, tokenEndpoint, userInfoEndpoint string, err error) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
resp, err := client.Get(discoveryURL)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to fetch discovery document: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", "", "", fmt.Errorf("discovery endpoint returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var discovery oidcDiscoveryResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&discovery); err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to decode discovery document: %w", err)
|
||||
}
|
||||
|
||||
if discovery.AuthorizationEndpoint == "" {
|
||||
return "", "", "", fmt.Errorf("discovery document missing authorization_endpoint")
|
||||
}
|
||||
if discovery.TokenEndpoint == "" {
|
||||
return "", "", "", fmt.Errorf("discovery document missing token_endpoint")
|
||||
}
|
||||
|
||||
return discovery.AuthorizationEndpoint, discovery.TokenEndpoint, discovery.UserinfoEndpoint, nil
|
||||
}
|
||||
|
||||
// oidcTokenResponse holds the response from the OIDC token endpoint.
|
||||
type oidcTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
IDToken string `json:"id_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
}
|
||||
|
||||
// exchangeCodeForTokens exchanges an authorization code for tokens at the token endpoint.
|
||||
func exchangeCodeForTokens(tokenEndpoint, code, clientID, clientSecret, redirectURI string) (*oidcTokenResponse, error) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
data := url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {code},
|
||||
"client_id": {clientID},
|
||||
"redirect_uri": {redirectURI},
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", tokenEndpoint, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
// Use client_secret_basic if provided
|
||||
if clientSecret != "" {
|
||||
req.SetBasicAuth(clientID, clientSecret)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var tokenResp oidcTokenResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode token response: %w", err)
|
||||
}
|
||||
|
||||
return &tokenResp, nil
|
||||
}
|
||||
|
||||
// extractUserClaims extracts user claims from the ID token payload.
|
||||
// If the ID token is unavailable or incomplete, it falls back to the UserInfo endpoint.
|
||||
func extractUserClaims(tokenResp *oidcTokenResponse, userInfoEndpoint string) (map[string]interface{}, error) {
|
||||
claims := make(map[string]interface{})
|
||||
|
||||
// Try to decode ID token payload (without signature verification for claims extraction;
|
||||
// in production, you should verify the signature using the JWKS endpoint)
|
||||
if tokenResp.IDToken != "" {
|
||||
parts := strings.Split(tokenResp.IDToken, ".")
|
||||
if len(parts) == 3 {
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err == nil {
|
||||
if err := json.Unmarshal(payload, &claims); err == nil && claims["sub"] != nil {
|
||||
return claims, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to UserInfo endpoint
|
||||
if userInfoEndpoint != "" && tokenResp.AccessToken != "" {
|
||||
userClaims, err := fetchUserInfo(userInfoEndpoint, tokenResp.AccessToken)
|
||||
if err == nil && userClaims["sub"] != nil {
|
||||
return userClaims, nil
|
||||
}
|
||||
}
|
||||
|
||||
if claims["sub"] != nil {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("could not extract user claims from ID token or UserInfo endpoint")
|
||||
}
|
||||
|
||||
// fetchUserInfo calls the OIDC UserInfo endpoint with the access token.
|
||||
func fetchUserInfo(userInfoEndpoint, accessToken string) (map[string]interface{}, error) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
req, err := http.NewRequest("GET", userInfoEndpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("userinfo endpoint returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var claims map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Claim Extraction Helpers
|
||||
// ============================================================================
|
||||
|
||||
// getStringClaim extracts a string claim from a claims map.
|
||||
func getStringClaim(claims map[string]interface{}, key string) string {
|
||||
if v, ok := claims[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getStringSliceClaim extracts a string slice claim from a claims map.
|
||||
func getStringSliceClaim(claims map[string]interface{}, key string) []string {
|
||||
v, ok := claims[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch val := v.(type) {
|
||||
case []interface{}:
|
||||
result := make([]string, 0, len(val))
|
||||
for _, item := range val {
|
||||
if s, ok := item.(string); ok {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
case []string:
|
||||
return val
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// resolveRoles maps SSO groups to internal roles using the config's role mapping.
|
||||
// If no groups match, the default role is returned.
|
||||
func resolveRoles(cfg *sso.SSOConfig, groups []string) []string {
|
||||
if cfg.RoleMapping == nil || len(cfg.RoleMapping) == 0 {
|
||||
if cfg.DefaultRoleID != nil {
|
||||
return []string{cfg.DefaultRoleID.String()}
|
||||
}
|
||||
return []string{"compliance_user"}
|
||||
}
|
||||
|
||||
roleSet := make(map[string]bool)
|
||||
for _, group := range groups {
|
||||
if role, ok := cfg.RoleMapping[group]; ok {
|
||||
roleSet[role] = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(roleSet) == 0 {
|
||||
if cfg.DefaultRoleID != nil {
|
||||
return []string{cfg.DefaultRoleID.String()}
|
||||
}
|
||||
return []string{"compliance_user"}
|
||||
}
|
||||
|
||||
roles := make([]string, 0, len(roleSet))
|
||||
for role := range roleSet {
|
||||
roles = append(roles, role)
|
||||
}
|
||||
return roles
|
||||
}
|
||||
Reference in New Issue
Block a user