Files
breakpilot-lehrer/edu-search-service/internal/api/handlers/policy_handlers.go
Benjamin Admin 9ba420fa91
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-school (push) Successful in 42s
CI / test-go-edu-search (push) Successful in 34s
CI / test-python-klausur (push) Failing after 2m51s
CI / test-python-agent-core (push) Successful in 21s
CI / test-nodejs-website (push) Successful in 29s
Fix: Remove broken getKlausurApiUrl and clean up empty lines
sed replacement left orphaned hostname references in story page
and empty lines in getApiBase functions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 16:02:04 +02:00

417 lines
12 KiB
Go

package handlers
import (
"net/http"
"github.com/breakpilot/edu-search-service/internal/policy"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// PolicyHandler contains all policy-related HTTP handlers.
type PolicyHandler struct {
store *policy.Store
enforcer *policy.Enforcer
}
// policyHandler is the singleton instance
var policyHandler *PolicyHandler
// InitPolicyHandler initializes the policy handler with a database pool.
func InitPolicyHandler(store *policy.Store) {
policyHandler = &PolicyHandler{
store: store,
enforcer: policy.NewEnforcer(store),
}
}
// GetPolicyHandler returns the policy handler instance.
func GetPolicyHandler() *PolicyHandler {
return policyHandler
}
// =============================================================================
// POLICIES
// =============================================================================
// ListPolicies returns all source policies.
func (h *PolicyHandler) ListPolicies(c *gin.Context) {
var filter policy.PolicyListFilter
if err := c.ShouldBindQuery(&filter); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters", "details": err.Error()})
return
}
// Set defaults
if filter.Limit <= 0 || filter.Limit > 100 {
filter.Limit = 50
}
policies, total, err := h.store.ListPolicies(c.Request.Context(), &filter)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list policies", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"policies": policies,
"total": total,
"limit": filter.Limit,
"offset": filter.Offset,
})
}
// GetPolicy returns a single policy by ID.
func (h *PolicyHandler) GetPolicy(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid policy ID"})
return
}
p, err := h.store.GetPolicy(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get policy", "details": err.Error()})
return
}
if p == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Policy not found"})
return
}
c.JSON(http.StatusOK, p)
}
// CreatePolicy creates a new source policy.
func (h *PolicyHandler) CreatePolicy(c *gin.Context) {
var req policy.CreateSourcePolicyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
return
}
p, err := h.store.CreatePolicy(c.Request.Context(), &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create policy", "details": err.Error()})
return
}
// Log audit
userEmail := getUserEmail(c)
h.enforcer.LogChange(c.Request.Context(), policy.AuditActionCreate, policy.AuditEntitySourcePolicy, &p.ID, nil, p, userEmail)
c.JSON(http.StatusCreated, p)
}
// UpdatePolicy updates an existing policy.
func (h *PolicyHandler) UpdatePolicy(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid policy ID"})
return
}
// Get old value for audit
oldPolicy, err := h.store.GetPolicy(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get policy", "details": err.Error()})
return
}
if oldPolicy == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Policy not found"})
return
}
var req policy.UpdateSourcePolicyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
return
}
p, err := h.store.UpdatePolicy(c.Request.Context(), id, &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update policy", "details": err.Error()})
return
}
// Log audit
userEmail := getUserEmail(c)
h.enforcer.LogChange(c.Request.Context(), policy.AuditActionUpdate, policy.AuditEntitySourcePolicy, &p.ID, oldPolicy, p, userEmail)
c.JSON(http.StatusOK, p)
}
// =============================================================================
// SOURCES (WHITELIST)
// =============================================================================
// ListSources returns all allowed sources.
func (h *PolicyHandler) ListSources(c *gin.Context) {
var filter policy.SourceListFilter
if err := c.ShouldBindQuery(&filter); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters", "details": err.Error()})
return
}
// Set defaults
if filter.Limit <= 0 || filter.Limit > 100 {
filter.Limit = 50
}
sources, total, err := h.store.ListSources(c.Request.Context(), &filter)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list sources", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"sources": sources,
"total": total,
"limit": filter.Limit,
"offset": filter.Offset,
})
}
// GetSource returns a single source by ID.
func (h *PolicyHandler) GetSource(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid source ID"})
return
}
source, err := h.store.GetSource(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get source", "details": err.Error()})
return
}
if source == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Source not found"})
return
}
c.JSON(http.StatusOK, source)
}
// CreateSource creates a new allowed source.
func (h *PolicyHandler) CreateSource(c *gin.Context) {
var req policy.CreateAllowedSourceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
return
}
source, err := h.store.CreateSource(c.Request.Context(), &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create source", "details": err.Error()})
return
}
// Log audit
userEmail := getUserEmail(c)
h.enforcer.LogChange(c.Request.Context(), policy.AuditActionCreate, policy.AuditEntityAllowedSource, &source.ID, nil, source, userEmail)
c.JSON(http.StatusCreated, source)
}
// UpdateSource updates an existing source.
func (h *PolicyHandler) UpdateSource(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid source ID"})
return
}
// Get old value for audit
oldSource, err := h.store.GetSource(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get source", "details": err.Error()})
return
}
if oldSource == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Source not found"})
return
}
var req policy.UpdateAllowedSourceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
return
}
source, err := h.store.UpdateSource(c.Request.Context(), id, &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update source", "details": err.Error()})
return
}
// Log audit
userEmail := getUserEmail(c)
h.enforcer.LogChange(c.Request.Context(), policy.AuditActionUpdate, policy.AuditEntityAllowedSource, &source.ID, oldSource, source, userEmail)
c.JSON(http.StatusOK, source)
}
// DeleteSource deletes a source.
func (h *PolicyHandler) DeleteSource(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid source ID"})
return
}
// Get source for audit before deletion
source, err := h.store.GetSource(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get source", "details": err.Error()})
return
}
if source == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Source not found"})
return
}
if err := h.store.DeleteSource(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete source", "details": err.Error()})
return
}
// Log audit
userEmail := getUserEmail(c)
h.enforcer.LogChange(c.Request.Context(), policy.AuditActionDelete, policy.AuditEntityAllowedSource, &id, source, nil, userEmail)
c.JSON(http.StatusOK, gin.H{"deleted": true, "id": id})
}
// =============================================================================
// OPERATIONS MATRIX
// =============================================================================
// GetOperationsMatrix returns all sources with their operation permissions.
func (h *PolicyHandler) GetOperationsMatrix(c *gin.Context) {
sources, err := h.store.GetOperationsMatrix(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get operations matrix", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"sources": sources,
"operations": []string{
string(policy.OperationLookup),
string(policy.OperationRAG),
string(policy.OperationTraining),
string(policy.OperationExport),
},
})
}
// UpdateOperationPermission updates a single operation permission.
func (h *PolicyHandler) UpdateOperationPermission(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid operation permission ID"})
return
}
var req policy.UpdateOperationPermissionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
return
}
// SECURITY: Prevent enabling training
if req.IsAllowed != nil && *req.IsAllowed {
// Check if this is a training operation by querying
ops, _ := h.store.GetOperationsBySourceID(c.Request.Context(), id)
for _, op := range ops {
if op.ID == id && op.Operation == policy.OperationTraining {
c.JSON(http.StatusForbidden, gin.H{
"error": "Training operations cannot be enabled",
"message": "Training with external data is FORBIDDEN by policy",
})
return
}
}
}
op, err := h.store.UpdateOperationPermission(c.Request.Context(), id, &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update operation permission", "details": err.Error()})
return
}
// Log audit
userEmail := getUserEmail(c)
h.enforcer.LogChange(c.Request.Context(), policy.AuditActionUpdate, policy.AuditEntityOperationPermission, &op.ID, nil, op, userEmail)
c.JSON(http.StatusOK, op)
}
// =============================================================================
// HELPERS
// =============================================================================
// getUserEmail extracts user email from context or headers.
func getUserEmail(c *gin.Context) *string {
// Try to get from header (set by auth proxy)
email := c.GetHeader("X-User-Email")
if email != "" {
return &email
}
// Try to get from context (set by auth middleware)
if e, exists := c.Get("user_email"); exists {
if emailStr, ok := e.(string); ok {
return &emailStr
}
}
return nil
}
// =============================================================================
// ROUTE SETUP
// =============================================================================
// SetupPolicyRoutes configures all policy-related routes.
func SetupPolicyRoutes(r *gin.RouterGroup) {
if policyHandler == nil {
return
}
h := policyHandler
// Policies
r.GET("/policies", h.ListPolicies)
r.GET("/policies/:id", h.GetPolicy)
r.POST("/policies", h.CreatePolicy)
r.PUT("/policies/:id", h.UpdatePolicy)
// Sources (Whitelist)
r.GET("/sources", h.ListSources)
r.GET("/sources/:id", h.GetSource)
r.POST("/sources", h.CreateSource)
r.PUT("/sources/:id", h.UpdateSource)
r.DELETE("/sources/:id", h.DeleteSource)
// Operations Matrix
r.GET("/operations-matrix", h.GetOperationsMatrix)
r.PUT("/operations/:id", h.UpdateOperationPermission)
// PII Rules
r.GET("/pii-rules", h.ListPIIRules)
r.GET("/pii-rules/:id", h.GetPIIRule)
r.POST("/pii-rules", h.CreatePIIRule)
r.PUT("/pii-rules/:id", h.UpdatePIIRule)
r.DELETE("/pii-rules/:id", h.DeletePIIRule)
r.POST("/pii-rules/test", h.TestPIIRules)
// Audit & Compliance
r.GET("/policy-audit", h.ListAuditLogs)
r.GET("/blocked-content", h.ListBlockedContent)
r.POST("/check-compliance", h.CheckCompliance)
r.GET("/policy-stats", h.GetPolicyStats)
r.GET("/compliance-report", h.GenerateComplianceReport)
}