package handlers import ( "net/http" "time" "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) } // ============================================================================= // PII RULES // ============================================================================= // ListPIIRules returns all PII detection rules. func (h *PolicyHandler) ListPIIRules(c *gin.Context) { activeOnly := c.Query("active_only") == "true" rules, err := h.store.ListPIIRules(c.Request.Context(), activeOnly) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list PII rules", "details": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "rules": rules, "total": len(rules), }) } // GetPIIRule returns a single PII rule by ID. func (h *PolicyHandler) GetPIIRule(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid PII rule ID"}) return } rule, err := h.store.GetPIIRule(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get PII rule", "details": err.Error()}) return } if rule == nil { c.JSON(http.StatusNotFound, gin.H{"error": "PII rule not found"}) return } c.JSON(http.StatusOK, rule) } // CreatePIIRule creates a new PII detection rule. func (h *PolicyHandler) CreatePIIRule(c *gin.Context) { var req policy.CreatePIIRuleRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()}) return } rule, err := h.store.CreatePIIRule(c.Request.Context(), &req) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create PII rule", "details": err.Error()}) return } // Log audit userEmail := getUserEmail(c) h.enforcer.LogChange(c.Request.Context(), policy.AuditActionCreate, policy.AuditEntityPIIRule, &rule.ID, nil, rule, userEmail) c.JSON(http.StatusCreated, rule) } // UpdatePIIRule updates an existing PII rule. func (h *PolicyHandler) UpdatePIIRule(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid PII rule ID"}) return } // Get old value for audit oldRule, err := h.store.GetPIIRule(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get PII rule", "details": err.Error()}) return } if oldRule == nil { c.JSON(http.StatusNotFound, gin.H{"error": "PII rule not found"}) return } var req policy.UpdatePIIRuleRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()}) return } rule, err := h.store.UpdatePIIRule(c.Request.Context(), id, &req) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update PII rule", "details": err.Error()}) return } // Log audit userEmail := getUserEmail(c) h.enforcer.LogChange(c.Request.Context(), policy.AuditActionUpdate, policy.AuditEntityPIIRule, &rule.ID, oldRule, rule, userEmail) c.JSON(http.StatusOK, rule) } // DeletePIIRule deletes a PII rule. func (h *PolicyHandler) DeletePIIRule(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid PII rule ID"}) return } // Get rule for audit before deletion rule, err := h.store.GetPIIRule(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get PII rule", "details": err.Error()}) return } if rule == nil { c.JSON(http.StatusNotFound, gin.H{"error": "PII rule not found"}) return } if err := h.store.DeletePIIRule(c.Request.Context(), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete PII rule", "details": err.Error()}) return } // Log audit userEmail := getUserEmail(c) h.enforcer.LogChange(c.Request.Context(), policy.AuditActionDelete, policy.AuditEntityPIIRule, &id, rule, nil, userEmail) c.JSON(http.StatusOK, gin.H{"deleted": true, "id": id}) } // TestPIIRules tests PII detection against sample text. func (h *PolicyHandler) TestPIIRules(c *gin.Context) { var req policy.PIITestRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()}) return } response, err := h.enforcer.DetectPII(c.Request.Context(), req.Text) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to test PII detection", "details": err.Error()}) return } c.JSON(http.StatusOK, response) } // ============================================================================= // AUDIT & COMPLIANCE // ============================================================================= // ListAuditLogs returns audit log entries. func (h *PolicyHandler) ListAuditLogs(c *gin.Context) { var filter policy.AuditLogFilter 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 > 500 { filter.Limit = 100 } logs, total, err := h.store.ListAuditLogs(c.Request.Context(), &filter) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list audit logs", "details": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "logs": logs, "total": total, "limit": filter.Limit, "offset": filter.Offset, }) } // ListBlockedContent returns blocked content log entries. func (h *PolicyHandler) ListBlockedContent(c *gin.Context) { var filter policy.BlockedContentFilter 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 > 500 { filter.Limit = 100 } logs, total, err := h.store.ListBlockedContent(c.Request.Context(), &filter) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list blocked content", "details": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "blocked": logs, "total": total, "limit": filter.Limit, "offset": filter.Offset, }) } // CheckCompliance performs a compliance check for a URL. func (h *PolicyHandler) CheckCompliance(c *gin.Context) { var req policy.CheckComplianceRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()}) return } response, err := h.enforcer.CheckCompliance(c.Request.Context(), &req) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check compliance", "details": err.Error()}) return } c.JSON(http.StatusOK, response) } // GetPolicyStats returns aggregated statistics. func (h *PolicyHandler) GetPolicyStats(c *gin.Context) { stats, err := h.store.GetStats(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get stats", "details": err.Error()}) return } c.JSON(http.StatusOK, stats) } // GenerateComplianceReport generates an audit report. func (h *PolicyHandler) GenerateComplianceReport(c *gin.Context) { var auditFilter policy.AuditLogFilter var blockedFilter policy.BlockedContentFilter // Parse date filters fromStr := c.Query("from") toStr := c.Query("to") if fromStr != "" { from, err := time.Parse("2006-01-02", fromStr) if err == nil { auditFilter.FromDate = &from blockedFilter.FromDate = &from } } if toStr != "" { to, err := time.Parse("2006-01-02", toStr) if err == nil { // Add 1 day to include the end date to = to.Add(24 * time.Hour) auditFilter.ToDate = &to blockedFilter.ToDate = &to } } // No limit for report auditFilter.Limit = 10000 blockedFilter.Limit = 10000 auditor := policy.NewAuditor(h.store) report, err := auditor.GenerateAuditReport(c.Request.Context(), &auditFilter, &blockedFilter) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate report", "details": err.Error()}) return } // Set filename for download format := c.Query("format") if format == "download" { filename := "compliance-report-" + time.Now().Format("2006-01-02") + ".json" c.Header("Content-Disposition", "attachment; filename="+filename) c.Header("Content-Type", "application/json") } c.JSON(http.StatusOK, report) } // ============================================================================= // 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) }