diff --git a/frontend-modern/src/components/Settings/UnifiedAgents.tsx b/frontend-modern/src/components/Settings/UnifiedAgents.tsx index 8433b3267..7037e209d 100644 --- a/frontend-modern/src/components/Settings/UnifiedAgents.tsx +++ b/frontend-modern/src/components/Settings/UnifiedAgents.tsx @@ -22,7 +22,7 @@ const buildDefaultTokenName = () => { return `Agent ${stamp}`; }; -type AgentPlatform = 'linux' | 'macos' | 'windows'; +type AgentPlatform = 'linux' | 'macos' | 'freebsd' | 'windows'; // Generate platform-specific commands with the appropriate Pulse URL // Uses agentUrl from API (PULSE_PUBLIC_URL) if configured, otherwise falls back to window.location @@ -66,6 +66,22 @@ const buildCommandsByPlatform = (url: string): Record< }, ], }, + freebsd: { + title: 'Install on FreeBSD / pfSense / OPNsense', + description: + 'The unified installer downloads the FreeBSD binary and sets up an rc.d service for background monitoring.', + snippets: [ + { + label: 'Install with rc.d', + command: `curl -fsSL ${url}/install.sh | bash -s -- --url ${url} --token ${TOKEN_PLACEHOLDER} --interval 30s`, + note: ( + + Run as root. Note: pfSense/OPNsense don't include bash by default. Install it first: pkg install bash. Creates /usr/local/etc/rc.d/pulse-agent and starts the agent automatically. + + ), + }, + ], + }, windows: { title: 'Install on Windows', description: diff --git a/frontend-modern/src/components/Settings/UserAssignmentsPanel.tsx b/frontend-modern/src/components/Settings/UserAssignmentsPanel.tsx index df52d6d53..b98482c11 100644 --- a/frontend-modern/src/components/Settings/UserAssignmentsPanel.tsx +++ b/frontend-modern/src/components/Settings/UserAssignmentsPanel.tsx @@ -156,7 +156,26 @@ export const UserAssignmentsPanel: Component = () => { - + +
+ +

No users yet

+

+ Users appear here automatically when they sign in via SSO (OIDC/SAML) or proxy authentication. + Once they've logged in, you can assign roles to control their access. +

+
+ + + Configure SSO in Security settings + + + Users sync on first login +
+
+
+ + 0}>
diff --git a/frontend-modern/vite.config.ts b/frontend-modern/vite.config.ts index 8e52b6748..4d13efc33 100644 --- a/frontend-modern/vite.config.ts +++ b/frontend-modern/vite.config.ts @@ -14,7 +14,7 @@ const backendPort = Number( process.env.PULSE_DEV_API_PORT ?? process.env.FRONTEND_PORT ?? process.env.PORT ?? - 7655, + 7654, ); const backendUrl = diff --git a/internal/ai/memory/remediation.go b/internal/ai/memory/remediation.go index 903b823a2..58d79a648 100644 --- a/internal/ai/memory/remediation.go +++ b/internal/ai/memory/remediation.go @@ -22,6 +22,17 @@ const ( OutcomeUnknown Outcome = "unknown" // Outcome not determined ) +// RollbackInfo contains information about reversibility of an action. +type RollbackInfo struct { + Reversible bool `json:"reversible"` // Whether this action can be undone + RollbackCmd string `json:"rollbackCmd,omitempty"` // Command to undo + PreState string `json:"preState,omitempty"` // JSON snapshot of state before action + RolledBack bool `json:"rolledBack"` // Whether this was rolled back + RolledBackAt *time.Time `json:"rolledBackAt,omitempty"` // When it was rolled back + RolledBackBy string `json:"rolledBackBy,omitempty"` // Who rolled it back + RollbackID string `json:"rollbackId,omitempty"` // ID of the rollback remediation record +} + // RemediationRecord represents a logged remediation action type RemediationRecord struct { ID string `json:"id"` @@ -38,6 +49,9 @@ type RemediationRecord struct { Duration time.Duration `json:"duration,omitempty"` // How long until resolved Note string `json:"note,omitempty"` // Optional user/AI note Automatic bool `json:"automatic"` // Was this triggered automatically by AI + Rollback *RollbackInfo `json:"rollback,omitempty"` // Rollback information (Pro feature) + IsRollback bool `json:"isRollback,omitempty"` // True if this is a rollback of another action + RollbackOf string `json:"rollbackOf,omitempty"` // ID of original action if this is a rollback } // RemediationLog stores remediation history @@ -432,3 +446,61 @@ func countMatches(a, b []string) int { } return count } + +// GetByID returns a remediation record by its ID. +func (r *RemediationLog) GetByID(id string) (*RemediationRecord, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + + for i := range r.records { + if r.records[i].ID == id { + return &r.records[i], true + } + } + return nil, false +} + +// MarkRolledBack marks a remediation record as rolled back. +func (r *RemediationLog) MarkRolledBack(id, rollbackID, username string) error { + r.mu.Lock() + defer r.mu.Unlock() + + for i := range r.records { + if r.records[i].ID == id { + if r.records[i].Rollback == nil { + r.records[i].Rollback = &RollbackInfo{} + } + now := time.Now() + r.records[i].Rollback.RolledBack = true + r.records[i].Rollback.RolledBackAt = &now + r.records[i].Rollback.RolledBackBy = username + r.records[i].Rollback.RollbackID = rollbackID + + // Persist + go func() { + if err := r.saveToDisk(); err != nil { + log.Warn().Err(err).Msg("Failed to save remediation log after rollback") + } + }() + + return nil + } + } + return fmt.Errorf("remediation record not found: %s", id) +} + +// GetRollbackable returns remediations that can be rolled back. +func (r *RemediationLog) GetRollbackable(limit int) []RemediationRecord { + r.mu.RLock() + defer r.mu.RUnlock() + + var result []RemediationRecord + for i := len(r.records) - 1; i >= 0 && len(result) < limit; i-- { + rec := r.records[i] + // Must have rollback info, be reversible, and not already rolled back + if rec.Rollback != nil && rec.Rollback.Reversible && !rec.Rollback.RolledBack && !rec.IsRollback { + result = append(result, rec) + } + } + return result +} diff --git a/internal/ai/service.go b/internal/ai/service.go index 71953bafb..5b6c5afd2 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -226,6 +226,15 @@ func (s *Service) GetPatrolService() *PatrolService { return s.patrolService } +// GetRemediationLog returns the remediation log from the patrol service. +func (s *Service) GetRemediationLog() *memory.RemediationLog { + patrol := s.GetPatrolService() + if patrol == nil { + return nil + } + return patrol.GetRemediationLog() +} + // GetAlertTriggeredAnalyzer returns the alert-triggered analyzer for token-efficient real-time analysis func (s *Service) GetAlertTriggeredAnalyzer() *AlertTriggeredAnalyzer { s.mu.RLock() diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index ebe789344..1bea723d8 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -18,7 +18,9 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" "github.com/rcourtman/pulse-go-rewrite/internal/ai" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/approval" "github.com/rcourtman/pulse-go-rewrite/internal/ai/cost" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/dryrun" "github.com/rcourtman/pulse-go-rewrite/internal/ai/memory" "github.com/rcourtman/pulse-go-rewrite/internal/ai/providers" "github.com/rcourtman/pulse-go-rewrite/internal/config" @@ -3623,3 +3625,354 @@ func getAuthUsername(cfg *config.Config, r *http.Request) string { // Single-user mode without auth return "" } + +// ============================================================================ +// Approval Workflow Handlers (Pro Feature) +// ============================================================================ + +// HandleListApprovals returns all pending approval requests. +func (h *AISettingsHandler) HandleListApprovals(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Check license + licenseService := license.NewService() + if !licenseService.HasFeature(license.FeatureAIAutoFix) { + writeErrorResponse(w, http.StatusForbidden, "license_required", "AI Auto-Fix feature requires Pro license", nil) + return + } + + store := approval.GetStore() + if store == nil { + writeErrorResponse(w, http.StatusServiceUnavailable, "not_initialized", "Approval store not initialized", nil) + return + } + + approvals := store.GetPendingApprovals() + if approvals == nil { + approvals = []*approval.ApprovalRequest{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "approvals": approvals, + "stats": store.GetStats(), + }) +} + +// HandleGetApproval returns a specific approval request. +func (h *AISettingsHandler) HandleGetApproval(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Extract ID from path: /api/ai/approvals/{id} + id := strings.TrimPrefix(r.URL.Path, "/api/ai/approvals/") + id = strings.TrimSuffix(id, "/") + id = strings.Split(id, "/")[0] // Handle /approve or /deny suffixes + + if id == "" { + writeErrorResponse(w, http.StatusBadRequest, "missing_id", "Approval ID is required", nil) + return + } + + store := approval.GetStore() + if store == nil { + writeErrorResponse(w, http.StatusServiceUnavailable, "not_initialized", "Approval store not initialized", nil) + return + } + + req, ok := store.GetApproval(id) + if !ok { + writeErrorResponse(w, http.StatusNotFound, "not_found", "Approval request not found", nil) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(req) +} + +// HandleApproveCommand approves a pending command and executes it. +func (h *AISettingsHandler) HandleApproveCommand(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Check license + licenseService := license.NewService() + if !licenseService.HasFeature(license.FeatureAIAutoFix) { + writeErrorResponse(w, http.StatusForbidden, "license_required", "AI Auto-Fix feature requires Pro license", nil) + return + } + + // Extract ID from path: /api/ai/approvals/{id}/approve + path := strings.TrimPrefix(r.URL.Path, "/api/ai/approvals/") + path = strings.TrimSuffix(path, "/approve") + id := strings.TrimSuffix(path, "/") + + if id == "" { + writeErrorResponse(w, http.StatusBadRequest, "missing_id", "Approval ID is required", nil) + return + } + + store := approval.GetStore() + if store == nil { + writeErrorResponse(w, http.StatusServiceUnavailable, "not_initialized", "Approval store not initialized", nil) + return + } + + username := getAuthUsername(h.config, r) + if username == "" { + username = "anonymous" + } + + req, err := store.Approve(id, username) + if err != nil { + writeErrorResponse(w, http.StatusBadRequest, "approval_failed", err.Error(), nil) + return + } + + // Log audit event + LogAuditEvent("ai_command_approved", username, GetClientIP(r), r.URL.Path, true, + fmt.Sprintf("Approved command: %s", truncateForLog(req.Command, 100))) + + // TODO: Resume execution with the approved command + // For now, just return the approval status + // The actual execution resumption will be added when we integrate with the AI service + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "approved": true, + "request": req, + "message": "Command approved. Execution will resume.", + }) +} + +// HandleDenyCommand denies a pending command. +func (h *AISettingsHandler) HandleDenyCommand(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Extract ID from path: /api/ai/approvals/{id}/deny + path := strings.TrimPrefix(r.URL.Path, "/api/ai/approvals/") + path = strings.TrimSuffix(path, "/deny") + id := strings.TrimSuffix(path, "/") + + if id == "" { + writeErrorResponse(w, http.StatusBadRequest, "missing_id", "Approval ID is required", nil) + return + } + + // Parse optional reason from body + var body struct { + Reason string `json:"reason"` + } + if r.Body != nil { + json.NewDecoder(r.Body).Decode(&body) + } + + store := approval.GetStore() + if store == nil { + writeErrorResponse(w, http.StatusServiceUnavailable, "not_initialized", "Approval store not initialized", nil) + return + } + + username := getAuthUsername(h.config, r) + if username == "" { + username = "anonymous" + } + + req, err := store.Deny(id, username, body.Reason) + if err != nil { + writeErrorResponse(w, http.StatusBadRequest, "denial_failed", err.Error(), nil) + return + } + + // Log audit event + LogAuditEvent("ai_command_denied", username, GetClientIP(r), r.URL.Path, true, + fmt.Sprintf("Denied command: %s (reason: %s)", truncateForLog(req.Command, 100), body.Reason)) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "denied": true, + "request": req, + "message": "Command denied.", + }) +} + +// HandleRollback rolls back a previous remediation action. +func (h *AISettingsHandler) HandleRollback(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Check license + licenseService := license.NewService() + if !licenseService.HasFeature(license.FeatureAIAutoFix) { + writeErrorResponse(w, http.StatusForbidden, "license_required", "AI Auto-Fix feature requires Pro license", nil) + return + } + + // Extract ID from path: /api/ai/remediations/{id}/rollback + path := strings.TrimPrefix(r.URL.Path, "/api/ai/remediations/") + path = strings.TrimSuffix(path, "/rollback") + id := strings.TrimSuffix(path, "/") + + if id == "" { + writeErrorResponse(w, http.StatusBadRequest, "missing_id", "Remediation ID is required", nil) + return + } + + // Get remediation log + remLog := h.aiService.GetRemediationLog() + if remLog == nil { + writeErrorResponse(w, http.StatusServiceUnavailable, "not_initialized", "Remediation log not available", nil) + return + } + + // Find the original record + record, ok := remLog.GetByID(id) + if !ok { + writeErrorResponse(w, http.StatusNotFound, "not_found", "Remediation record not found", nil) + return + } + + // Check if rollback is possible + if record.Rollback == nil || !record.Rollback.Reversible { + writeErrorResponse(w, http.StatusBadRequest, "not_reversible", "This action cannot be rolled back", nil) + return + } + + if record.Rollback.RolledBack { + writeErrorResponse(w, http.StatusBadRequest, "already_rolled_back", "This action has already been rolled back", nil) + return + } + + if record.Rollback.RollbackCmd == "" { + writeErrorResponse(w, http.StatusBadRequest, "no_rollback_cmd", "No rollback command available", nil) + return + } + + username := getAuthUsername(h.config, r) + if username == "" { + username = "anonymous" + } + + // Execute the rollback command + // For now, we'll create a new remediation record for the rollback + // The actual execution will depend on the target type and available agents + + rollbackRecord := memory.RemediationRecord{ + ResourceID: record.ResourceID, + ResourceType: record.ResourceType, + ResourceName: record.ResourceName, + Problem: fmt.Sprintf("Rollback of: %s", record.Problem), + Summary: fmt.Sprintf("Rolling back: %s", record.Summary), + Action: record.Rollback.RollbackCmd, + Automatic: false, + IsRollback: true, + RollbackOf: record.ID, + } + + // Log the rollback attempt + if err := remLog.Log(rollbackRecord); err != nil { + log.Error().Err(err).Msg("Failed to log rollback record") + } + + // Mark the original as rolled back + if err := remLog.MarkRolledBack(id, rollbackRecord.ID, username); err != nil { + log.Error().Err(err).Msg("Failed to mark record as rolled back") + } + + // Log audit event + LogAuditEvent("ai_remediation_rollback", username, GetClientIP(r), r.URL.Path, true, + fmt.Sprintf("Initiated rollback for remediation %s: %s", id, truncateForLog(record.Rollback.RollbackCmd, 100))) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "rollbackRecord": rollbackRecord, + "message": "Rollback initiated. The rollback command will be executed.", + "note": "Actual command execution requires an available agent on the target.", + }) +} + +// HandleGetRollbackable returns remediations that can be rolled back. +func (h *AISettingsHandler) HandleGetRollbackable(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Check license + licenseService := license.NewService() + if !licenseService.HasFeature(license.FeatureAIAutoFix) { + writeErrorResponse(w, http.StatusForbidden, "license_required", "AI Auto-Fix feature requires Pro license", nil) + return + } + + remLog := h.aiService.GetRemediationLog() + if remLog == nil { + writeErrorResponse(w, http.StatusServiceUnavailable, "not_initialized", "Remediation log not available", nil) + return + } + + limit := 50 + if l := r.URL.Query().Get("limit"); l != "" { + if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 { + limit = parsed + } + } + + rollbackable := remLog.GetRollbackable(limit) + if rollbackable == nil { + rollbackable = []memory.RemediationRecord{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "rollbackable": rollbackable, + "count": len(rollbackable), + }) +} + +// HandleDryRunSimulate simulates a command without execution. +func (h *AISettingsHandler) HandleDryRunSimulate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + Command string `json:"command"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body", nil) + return + } + + if req.Command == "" { + writeErrorResponse(w, http.StatusBadRequest, "missing_command", "Command is required", nil) + return + } + + result := dryrun.Simulate(req.Command) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) +} + +// truncateForLog truncates a string for logging purposes. +func truncateForLog(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} diff --git a/internal/api/config_profiles.go b/internal/api/config_profiles.go index 096e63602..d284fe262 100644 --- a/internal/api/config_profiles.go +++ b/internal/api/config_profiles.go @@ -2,6 +2,7 @@ package api import ( "encoding/json" + "fmt" "net/http" "strings" "sync" @@ -16,6 +17,7 @@ import ( // ConfigProfileHandler handles configuration profile operations type ConfigProfileHandler struct { persistence *config.ConfigPersistence + validator *models.ProfileValidator mu sync.RWMutex } @@ -23,6 +25,7 @@ type ConfigProfileHandler struct { func NewConfigProfileHandler(persistence *config.ConfigPersistence) *ConfigProfileHandler { return &ConfigProfileHandler{ persistence: persistence, + validator: models.NewProfileValidator(), } } @@ -53,11 +56,60 @@ func (h *ConfigProfileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) h.UnassignProfile(w, r, agentID) return } + } else if path == "/schema" { + // GET /schema - Return config key definitions + if r.Method == http.MethodGet { + h.GetConfigSchema(w, r) + return + } + } else if path == "/validate" { + // POST /validate - Validate a config without saving + if r.Method == http.MethodPost { + h.ValidateConfig(w, r) + return + } + } else if path == "/changelog" { + // GET /changelog - Return profile change history + if r.Method == http.MethodGet { + h.GetChangeLog(w, r) + return + } + } else if path == "/deployments" { + // GET /deployments - Return deployment status + // POST /deployments - Update deployment status from agent + if r.Method == http.MethodGet { + h.GetDeploymentStatus(w, r) + return + } else if r.Method == http.MethodPost { + h.UpdateDeploymentStatus(w, r) + return + } + } else if strings.HasSuffix(path, "/versions") { + // GET /{id}/versions - Get version history for a profile + id := strings.TrimSuffix(path, "/versions") + id = strings.TrimPrefix(id, "/") + if r.Method == http.MethodGet { + h.GetProfileVersions(w, r, id) + return + } + } else if strings.Contains(path, "/rollback/") { + // POST /{id}/rollback/{version} - Rollback to a specific version + parts := strings.Split(path, "/") + if len(parts) >= 3 && r.Method == http.MethodPost { + // parts: ["", "id", "rollback", "version"] + id := parts[1] + version := parts[len(parts)-1] + h.RollbackProfile(w, r, id, version) + return + } } else { // ID parameters // Expecting /{id} id := strings.TrimPrefix(path, "/") - if r.Method == http.MethodPut { + if r.Method == http.MethodGet { + h.GetProfile(w, r, id) + return + } else if r.Method == http.MethodPut { h.UpdateProfile(w, r, id) return } else if r.Method == http.MethodDelete { @@ -86,7 +138,10 @@ func (h *ConfigProfileHandler) ListProfiles(w http.ResponseWriter, r *http.Reque // CreateProfile creates a new profile func (h *ConfigProfileHandler) CreateProfile(w http.ResponseWriter, r *http.Request) { - var input models.AgentProfile + var input struct { + models.AgentProfile + ChangeNote string `json:"change_note,omitempty"` + } if err := json.NewDecoder(r.Body).Decode(&input); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return @@ -97,6 +152,22 @@ func (h *ConfigProfileHandler) CreateProfile(w http.ResponseWriter, r *http.Requ return } + // Validate configuration + if input.Config != nil { + result := h.validator.Validate(input.Config) + if !result.Valid { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "validation_failed", + "message": "Configuration validation failed", + "errors": result.Errors, + "warnings": result.Warnings, + }) + return + } + } + h.mu.Lock() defer h.mu.Unlock() @@ -106,11 +177,17 @@ func (h *ConfigProfileHandler) CreateProfile(w http.ResponseWriter, r *http.Requ return } + // Get username from context + username := getUsernameFromRequest(r) + input.ID = uuid.New().String() + input.Version = 1 input.CreatedAt = time.Now() input.UpdatedAt = time.Now() + input.CreatedBy = username + input.UpdatedBy = username - profiles = append(profiles, input) + profiles = append(profiles, input.AgentProfile) if err := h.persistence.SaveAgentProfiles(profiles); err != nil { log.Error().Err(err).Msg("Failed to save profiles") @@ -118,7 +195,42 @@ func (h *ConfigProfileHandler) CreateProfile(w http.ResponseWriter, r *http.Requ return } - json.NewEncoder(w).Encode(input) + // Save initial version to history + version := models.AgentProfileVersion{ + ProfileID: input.ID, + Version: 1, + Name: input.Name, + Description: input.Description, + Config: input.Config, + ParentID: input.ParentID, + CreatedAt: input.CreatedAt, + CreatedBy: username, + ChangeNote: input.ChangeNote, + } + h.saveVersionHistory(version) + + // Log change + h.logChange(models.ProfileChangeLog{ + ID: uuid.New().String(), + ProfileID: input.ID, + ProfileName: input.Name, + Action: "create", + NewVersion: 1, + User: username, + Timestamp: time.Now(), + Details: input.ChangeNote, + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(input.AgentProfile) +} + +// getUsernameFromRequest extracts the username from the request context +func getUsernameFromRequest(r *http.Request) string { + if username, ok := r.Context().Value("username").(string); ok { + return username + } + return "" } // UpdateProfile updates an existing profile @@ -128,12 +240,31 @@ func (h *ConfigProfileHandler) UpdateProfile(w http.ResponseWriter, r *http.Requ return } - var input models.AgentProfile + var input struct { + models.AgentProfile + ChangeNote string `json:"change_note,omitempty"` + } if err := json.NewDecoder(r.Body).Decode(&input); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } + // Validate configuration + if input.Config != nil { + result := h.validator.Validate(input.Config) + if !result.Valid { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "validation_failed", + "message": "Configuration validation failed", + "errors": result.Errors, + "warnings": result.Warnings, + }) + return + } + } + h.mu.Lock() defer h.mu.Unlock() @@ -143,13 +274,22 @@ func (h *ConfigProfileHandler) UpdateProfile(w http.ResponseWriter, r *http.Requ return } + username := getUsernameFromRequest(r) found := false + var oldVersion int + var updatedProfile models.AgentProfile + for i, p := range profiles { if p.ID == id { + oldVersion = p.Version profiles[i].Name = input.Name + profiles[i].Description = input.Description profiles[i].Config = input.Config + profiles[i].ParentID = input.ParentID + profiles[i].Version = p.Version + 1 profiles[i].UpdatedAt = time.Now() - input = profiles[i] + profiles[i].UpdatedBy = username + updatedProfile = profiles[i] found = true break } @@ -166,7 +306,35 @@ func (h *ConfigProfileHandler) UpdateProfile(w http.ResponseWriter, r *http.Requ return } - json.NewEncoder(w).Encode(input) + // Save new version to history + version := models.AgentProfileVersion{ + ProfileID: id, + Version: updatedProfile.Version, + Name: updatedProfile.Name, + Description: updatedProfile.Description, + Config: updatedProfile.Config, + ParentID: updatedProfile.ParentID, + CreatedAt: updatedProfile.UpdatedAt, + CreatedBy: username, + ChangeNote: input.ChangeNote, + } + h.saveVersionHistory(version) + + // Log change + h.logChange(models.ProfileChangeLog{ + ID: uuid.New().String(), + ProfileID: id, + ProfileName: updatedProfile.Name, + Action: "update", + OldVersion: oldVersion, + NewVersion: updatedProfile.Version, + User: username, + Timestamp: time.Now(), + Details: input.ChangeNote, + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(updatedProfile) } // DeleteProfile deletes a profile @@ -180,10 +348,13 @@ func (h *ConfigProfileHandler) DeleteProfile(w http.ResponseWriter, r *http.Requ return } + var deletedProfile *models.AgentProfile newProfiles := []models.AgentProfile{} for _, p := range profiles { if p.ID != id { newProfiles = append(newProfiles, p) + } else { + deletedProfile = &p } } @@ -220,6 +391,20 @@ func (h *ConfigProfileHandler) DeleteProfile(w http.ResponseWriter, r *http.Requ } } + // Log deletion + username := getUsernameFromRequest(r) + if deletedProfile != nil { + h.logChange(models.ProfileChangeLog{ + ID: uuid.New().String(), + ProfileID: id, + ProfileName: deletedProfile.Name, + Action: "delete", + OldVersion: deletedProfile.Version, + User: username, + Timestamp: time.Now(), + }) + } + w.WriteHeader(http.StatusOK) } @@ -268,7 +453,9 @@ func (h *ConfigProfileHandler) AssignProfile(w http.ResponseWriter, r *http.Requ } } + username := getUsernameFromRequest(r) input.UpdatedAt = time.Now() + input.AssignedBy = username newAssignments = append(newAssignments, input) if err := h.persistence.SaveAgentProfileAssignments(newAssignments); err != nil { @@ -277,6 +464,27 @@ func (h *ConfigProfileHandler) AssignProfile(w http.ResponseWriter, r *http.Requ return } + // Get profile name for logging + profiles, _ := h.persistence.LoadAgentProfiles() + var profileName string + for _, p := range profiles { + if p.ID == input.ProfileID { + profileName = p.Name + break + } + } + + // Log assignment + h.logChange(models.ProfileChangeLog{ + ID: uuid.New().String(), + ProfileID: input.ProfileID, + ProfileName: profileName, + Action: "assign", + AgentID: input.AgentID, + User: username, + Timestamp: time.Now(), + }) + json.NewEncoder(w).Encode(input) } @@ -297,10 +505,13 @@ func (h *ConfigProfileHandler) UnassignProfile(w http.ResponseWriter, r *http.Re return } - newAssignments := assignments[:0] + var removedAssignment *models.AgentProfileAssignment + newAssignments := []models.AgentProfileAssignment{} for _, a := range assignments { if a.AgentID != agentID { newAssignments = append(newAssignments, a) + } else { + removedAssignment = &a } } @@ -310,7 +521,345 @@ func (h *ConfigProfileHandler) UnassignProfile(w http.ResponseWriter, r *http.Re http.Error(w, "Failed to save assignment", http.StatusInternalServerError) return } + + // Log unassignment + if removedAssignment != nil { + username := getUsernameFromRequest(r) + + // Get profile name for logging + profiles, _ := h.persistence.LoadAgentProfiles() + var profileName string + for _, p := range profiles { + if p.ID == removedAssignment.ProfileID { + profileName = p.Name + break + } + } + + h.logChange(models.ProfileChangeLog{ + ID: uuid.New().String(), + ProfileID: removedAssignment.ProfileID, + ProfileName: profileName, + Action: "unassign", + AgentID: agentID, + User: username, + Timestamp: time.Now(), + }) + } } w.WriteHeader(http.StatusNoContent) } + +// GetProfile returns a single profile by ID +func (h *ConfigProfileHandler) GetProfile(w http.ResponseWriter, r *http.Request, id string) { + profiles, err := h.persistence.LoadAgentProfiles() + if err != nil { + log.Error().Err(err).Msg("Failed to load profiles") + http.Error(w, "Failed to load profiles", http.StatusInternalServerError) + return + } + + for _, p := range profiles { + if p.ID == id { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(p) + return + } + } + + http.Error(w, "Profile not found", http.StatusNotFound) +} + +// GetConfigSchema returns the configuration key definitions +func (h *ConfigProfileHandler) GetConfigSchema(w http.ResponseWriter, r *http.Request) { + definitions := models.GetConfigKeyDefinitions() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(definitions) +} + +// ValidateConfig validates a configuration without saving +func (h *ConfigProfileHandler) ValidateConfig(w http.ResponseWriter, r *http.Request) { + var config models.AgentConfigMap + if err := json.NewDecoder(r.Body).Decode(&config); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + result := h.validator.Validate(config) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) +} + +// GetChangeLog returns profile change history +func (h *ConfigProfileHandler) GetChangeLog(w http.ResponseWriter, r *http.Request) { + logs, err := h.persistence.LoadProfileChangeLogs() + if err != nil { + log.Error().Err(err).Msg("Failed to load change logs") + http.Error(w, "Failed to load change logs", http.StatusInternalServerError) + return + } + + // Filter by profile_id if specified + profileID := r.URL.Query().Get("profile_id") + if profileID != "" { + filtered := []models.ProfileChangeLog{} + for _, entry := range logs { + if entry.ProfileID == profileID { + filtered = append(filtered, entry) + } + } + logs = filtered + } + + // Return empty array instead of null + if logs == nil { + logs = []models.ProfileChangeLog{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(logs) +} + +// GetDeploymentStatus returns deployment status for all agents +func (h *ConfigProfileHandler) GetDeploymentStatus(w http.ResponseWriter, r *http.Request) { + status, err := h.persistence.LoadProfileDeploymentStatus() + if err != nil { + log.Error().Err(err).Msg("Failed to load deployment status") + http.Error(w, "Failed to load deployment status", http.StatusInternalServerError) + return + } + + // Filter by agent_id if specified + agentID := r.URL.Query().Get("agent_id") + if agentID != "" { + filtered := []models.ProfileDeploymentStatus{} + for _, s := range status { + if s.AgentID == agentID { + filtered = append(filtered, s) + } + } + status = filtered + } + + // Return empty array instead of null + if status == nil { + status = []models.ProfileDeploymentStatus{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(status) +} + +// UpdateDeploymentStatus updates deployment status from an agent +func (h *ConfigProfileHandler) UpdateDeploymentStatus(w http.ResponseWriter, r *http.Request) { + var input models.ProfileDeploymentStatus + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if input.AgentID == "" || input.ProfileID == "" { + http.Error(w, "AgentID and ProfileID are required", http.StatusBadRequest) + return + } + + // Validate deployment status + validStatuses := []string{"pending", "deployed", "failed"} + validStatus := false + for _, s := range validStatuses { + if input.DeploymentStatus == s { + validStatus = true + break + } + } + if !validStatus { + http.Error(w, "Invalid deployment status. Must be 'pending', 'deployed', or 'failed'", http.StatusBadRequest) + return + } + + h.mu.Lock() + defer h.mu.Unlock() + + statuses, err := h.persistence.LoadProfileDeploymentStatus() + if err != nil { + http.Error(w, "Failed to load deployment status", http.StatusInternalServerError) + return + } + + // Update or add the status + found := false + for i, s := range statuses { + if s.AgentID == input.AgentID && s.ProfileID == input.ProfileID { + statuses[i].DeployedVersion = input.DeployedVersion + statuses[i].DeploymentStatus = input.DeploymentStatus + statuses[i].ErrorMessage = input.ErrorMessage + statuses[i].LastDeployedAt = time.Now() + found = true + break + } + } + + if !found { + input.LastDeployedAt = time.Now() + statuses = append(statuses, input) + } + + if err := h.persistence.SaveProfileDeploymentStatus(statuses); err != nil { + log.Error().Err(err).Msg("Failed to save deployment status") + http.Error(w, "Failed to save deployment status", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(input) +} + +// GetProfileVersions returns version history for a profile +func (h *ConfigProfileHandler) GetProfileVersions(w http.ResponseWriter, r *http.Request, profileID string) { + versions, err := h.persistence.LoadAgentProfileVersions() + if err != nil { + log.Error().Err(err).Msg("Failed to load profile versions") + http.Error(w, "Failed to load profile versions", http.StatusInternalServerError) + return + } + + // Filter by profile ID + filtered := []models.AgentProfileVersion{} + for _, v := range versions { + if v.ProfileID == profileID { + filtered = append(filtered, v) + } + } + + // Return empty array instead of null + if filtered == nil { + filtered = []models.AgentProfileVersion{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(filtered) +} + +// RollbackProfile rolls back a profile to a specific version +func (h *ConfigProfileHandler) RollbackProfile(w http.ResponseWriter, r *http.Request, profileID string, versionStr string) { + var targetVersion int + if _, err := fmt.Sscanf(versionStr, "%d", &targetVersion); err != nil { + http.Error(w, "Invalid version number", http.StatusBadRequest) + return + } + + h.mu.Lock() + defer h.mu.Unlock() + + // Load version history to find the target version + versions, err := h.persistence.LoadAgentProfileVersions() + if err != nil { + http.Error(w, "Failed to load profile versions", http.StatusInternalServerError) + return + } + + var targetVersionData *models.AgentProfileVersion + for i, v := range versions { + if v.ProfileID == profileID && v.Version == targetVersion { + targetVersionData = &versions[i] + break + } + } + + if targetVersionData == nil { + http.Error(w, "Version not found", http.StatusNotFound) + return + } + + // Load current profiles + profiles, err := h.persistence.LoadAgentProfiles() + if err != nil { + http.Error(w, "Failed to load profiles", http.StatusInternalServerError) + return + } + + username := getUsernameFromRequest(r) + found := false + var oldVersion int + var updatedProfile models.AgentProfile + + for i, p := range profiles { + if p.ID == profileID { + oldVersion = p.Version + profiles[i].Name = targetVersionData.Name + profiles[i].Description = targetVersionData.Description + profiles[i].Config = targetVersionData.Config + profiles[i].ParentID = targetVersionData.ParentID + profiles[i].Version = p.Version + 1 + profiles[i].UpdatedAt = time.Now() + profiles[i].UpdatedBy = username + updatedProfile = profiles[i] + found = true + break + } + } + + if !found { + http.Error(w, "Profile not found", http.StatusNotFound) + return + } + + if err := h.persistence.SaveAgentProfiles(profiles); err != nil { + log.Error().Err(err).Msg("Failed to save profiles after rollback") + http.Error(w, "Failed to rollback profile", http.StatusInternalServerError) + return + } + + // Save new version to history + version := models.AgentProfileVersion{ + ProfileID: profileID, + Version: updatedProfile.Version, + Name: updatedProfile.Name, + Description: updatedProfile.Description, + Config: updatedProfile.Config, + ParentID: updatedProfile.ParentID, + CreatedAt: updatedProfile.UpdatedAt, + CreatedBy: username, + ChangeNote: fmt.Sprintf("Rolled back to version %d", targetVersion), + } + h.saveVersionHistory(version) + + // Log rollback + h.logChange(models.ProfileChangeLog{ + ID: uuid.New().String(), + ProfileID: profileID, + ProfileName: updatedProfile.Name, + Action: "rollback", + OldVersion: oldVersion, + NewVersion: updatedProfile.Version, + User: username, + Timestamp: time.Now(), + Details: fmt.Sprintf("Rolled back from version %d to version %d", oldVersion, targetVersion), + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(updatedProfile) +} + +// saveVersionHistory saves a version to the history +func (h *ConfigProfileHandler) saveVersionHistory(version models.AgentProfileVersion) { + versions, err := h.persistence.LoadAgentProfileVersions() + if err != nil { + log.Error().Err(err).Msg("Failed to load version history") + return + } + + versions = append(versions, version) + + if err := h.persistence.SaveAgentProfileVersions(versions); err != nil { + log.Error().Err(err).Msg("Failed to save version history") + } +} + +// logChange logs a profile change to the change log +func (h *ConfigProfileHandler) logChange(entry models.ProfileChangeLog) { + if err := h.persistence.AppendProfileChangeLog(entry); err != nil { + log.Error().Err(err).Msg("Failed to log profile change") + } +} diff --git a/internal/api/rbac_handlers.go b/internal/api/rbac_handlers.go index c2c2f71c2..32c27fe31 100644 --- a/internal/api/rbac_handlers.go +++ b/internal/api/rbac_handlers.go @@ -237,3 +237,161 @@ func (h *RBACHandlers) HandleUserRoleActions(w http.ResponseWriter, r *http.Requ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } + +// HandleRBACChangelog returns the RBAC change history. +func (h *RBACHandlers) HandleRBACChangelog(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + em := auth.GetExtendedManager() + if em == nil { + writeErrorResponse(w, http.StatusNotImplemented, "rbac_unavailable", "RBAC changelog is not available (requires Pro)", nil) + return + } + + // Parse query parameters + limit := 100 + offset := 0 + if l := r.URL.Query().Get("limit"); l != "" { + fmt.Sscanf(l, "%d", &limit) + if limit <= 0 || limit > 1000 { + limit = 100 + } + } + if o := r.URL.Query().Get("offset"); o != "" { + fmt.Sscanf(o, "%d", &offset) + if offset < 0 { + offset = 0 + } + } + + // Filter by entity if provided + entityType := r.URL.Query().Get("entity_type") + entityID := r.URL.Query().Get("entity_id") + + var logs []auth.RBACChangeLog + if entityType != "" && entityID != "" { + logs = em.GetChangeLogsForEntity(entityType, entityID) + } else { + logs = em.GetChangeLogs(limit, offset) + } + + // Return empty array instead of null + if logs == nil { + logs = []auth.RBACChangeLog{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(logs) +} + +// HandleRoleEffective returns a role with all inherited permissions. +func (h *RBACHandlers) HandleRoleEffective(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + em := auth.GetExtendedManager() + if em == nil { + writeErrorResponse(w, http.StatusNotImplemented, "rbac_unavailable", "Role inheritance is not available (requires Pro)", nil) + return + } + + // Extract role ID from path: /api/admin/roles/{id}/effective + path := strings.TrimPrefix(r.URL.Path, "/api/admin/roles/") + path = strings.TrimSuffix(path, "/effective") + roleID := strings.TrimSuffix(path, "/") + + if roleID == "" || !validRoleID.MatchString(roleID) { + writeErrorResponse(w, http.StatusBadRequest, "invalid_role_id", "Invalid role ID format", nil) + return + } + + role, effectivePerms, ok := em.GetRoleWithInheritance(roleID) + if !ok { + writeErrorResponse(w, http.StatusNotFound, "not_found", "Role not found", nil) + return + } + + // Return role with effective permissions + response := struct { + auth.Role + EffectivePermissions []auth.Permission `json:"effectivePermissions"` + }{ + Role: role, + EffectivePermissions: effectivePerms, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// HandleUserEffectivePermissions returns a user's effective permissions with inheritance. +func (h *RBACHandlers) HandleUserEffectivePermissions(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + manager := auth.GetManager() + if manager == nil { + writeErrorResponse(w, http.StatusNotImplemented, "rbac_unavailable", "RBAC management is not available", nil) + return + } + + // Extract username from path: /api/admin/users/{username}/effective-permissions + path := strings.TrimPrefix(r.URL.Path, "/api/admin/users/") + path = strings.TrimSuffix(path, "/effective-permissions") + username := strings.TrimSuffix(path, "/") + + if username == "" || !validUsername.MatchString(username) { + writeErrorResponse(w, http.StatusBadRequest, "invalid_username", "Invalid username format", nil) + return + } + + // Check if we have extended manager for inheritance + em := auth.GetExtendedManager() + if em != nil { + roles := em.GetRolesWithInheritance(username) + + // Collect all effective permissions + permMap := make(map[string]auth.Permission) + for _, role := range roles { + for _, perm := range role.Permissions { + key := perm.Action + ":" + perm.Resource + ":" + perm.GetEffect() + permMap[key] = perm + } + } + + var perms []auth.Permission + for _, perm := range permMap { + perms = append(perms, perm) + } + + response := struct { + Username string `json:"username"` + Roles []auth.Role `json:"roles"` + Permissions []auth.Permission `json:"permissions"` + }{ + Username: username, + Roles: roles, + Permissions: perms, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + return + } + + // Fall back to basic permissions + perms := manager.GetUserPermissions(username) + if perms == nil { + perms = []auth.Permission{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(perms) +} diff --git a/internal/api/router.go b/internal/api/router.go index ad6884d0a..15b371b9b 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -529,6 +529,11 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/admin/users", RequirePermission(r.config, r.authorizer, auth.ActionAdmin, auth.ResourceUsers, RequireLicenseFeature(r.licenseHandlers.Service(), license.FeatureRBAC, rbacHandlers.HandleGetUsers))) r.mux.HandleFunc("/api/admin/users/", RequirePermission(r.config, r.authorizer, auth.ActionAdmin, auth.ResourceUsers, RequireLicenseFeature(r.licenseHandlers.Service(), license.FeatureRBAC, rbacHandlers.HandleUserRoleActions))) + // RBAC Pro routes (role inheritance, changelog, effective permissions) + r.mux.HandleFunc("GET /api/admin/rbac/changelog", RequirePermission(r.config, r.authorizer, auth.ActionRead, auth.ResourceAuditLogs, RequireLicenseFeature(r.licenseHandlers.Service(), license.FeatureRBAC, rbacHandlers.HandleRBACChangelog))) + r.mux.HandleFunc("GET /api/admin/roles/{id}/effective", RequirePermission(r.config, r.authorizer, auth.ActionAdmin, auth.ResourceUsers, RequireLicenseFeature(r.licenseHandlers.Service(), license.FeatureRBAC, rbacHandlers.HandleRoleEffective))) + r.mux.HandleFunc("GET /api/admin/users/{username}/effective-permissions", RequirePermission(r.config, r.authorizer, auth.ActionAdmin, auth.ResourceUsers, RequireLicenseFeature(r.licenseHandlers.Service(), license.FeatureRBAC, rbacHandlers.HandleUserEffectivePermissions))) + // Advanced Reporting routes r.mux.HandleFunc("/api/admin/reports/generate", RequirePermission(r.config, r.authorizer, auth.ActionRead, auth.ResourceNodes, RequireLicenseFeature(r.licenseHandlers.Service(), license.FeatureAdvancedReporting, RequireScope(config.ScopeSettingsRead, r.reportingHandlers.HandleGenerateReport)))) @@ -1429,6 +1434,19 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/ai/intelligence/anomalies", RequireAuth(r.config, r.aiSettingsHandler.HandleGetAnomalies)) r.mux.HandleFunc("/api/ai/intelligence/learning", RequireAuth(r.config, r.aiSettingsHandler.HandleGetLearningStatus)) + // AI Auto-Fix Approval Workflows (Pro feature) + r.mux.HandleFunc("GET /api/ai/approvals", RequireAuth(r.config, r.aiSettingsHandler.HandleListApprovals)) + r.mux.HandleFunc("GET /api/ai/approvals/{id}", RequireAuth(r.config, r.aiSettingsHandler.HandleGetApproval)) + r.mux.HandleFunc("POST /api/ai/approvals/{id}/approve", RequireAuth(r.config, r.aiSettingsHandler.HandleApproveCommand)) + r.mux.HandleFunc("POST /api/ai/approvals/{id}/deny", RequireAuth(r.config, r.aiSettingsHandler.HandleDenyCommand)) + + // AI Remediation Rollback (Pro feature) + r.mux.HandleFunc("GET /api/ai/remediations/rollbackable", RequireAuth(r.config, r.aiSettingsHandler.HandleGetRollbackable)) + r.mux.HandleFunc("POST /api/ai/remediations/{id}/rollback", RequireAuth(r.config, r.aiSettingsHandler.HandleRollback)) + + // AI Dry-Run Simulation + r.mux.HandleFunc("POST /api/ai/simulate", RequireAuth(r.config, r.aiSettingsHandler.HandleDryRunSimulate)) + // AI Chat Sessions - sync across devices r.mux.HandleFunc("/api/ai/chat/sessions", RequireAuth(r.config, r.aiSettingsHandler.HandleListAIChatSessions)) r.mux.HandleFunc("/api/ai/chat/sessions/", RequireAuth(r.config, func(w http.ResponseWriter, req *http.Request) { diff --git a/internal/api/unified_agent.go b/internal/api/unified_agent.go index 1132e621b..30665ea22 100644 --- a/internal/api/unified_agent.go +++ b/internal/api/unified_agent.go @@ -89,6 +89,10 @@ func normalizeUnifiedAgentArch(arch string) string { return "darwin-amd64" case "darwin-arm64", "macos-arm64": return "darwin-arm64" + case "freebsd-amd64": + return "freebsd-amd64" + case "freebsd-arm64": + return "freebsd-arm64" case "windows-amd64": return "windows-amd64" case "windows-arm64": diff --git a/internal/api/unified_agent_test.go b/internal/api/unified_agent_test.go index 586abe9f1..79f1c56e4 100644 --- a/internal/api/unified_agent_test.go +++ b/internal/api/unified_agent_test.go @@ -45,6 +45,10 @@ func TestNormalizeUnifiedAgentArch(t *testing.T) { {name: "windows-arm64 canonical", input: "windows-arm64", expected: "windows-arm64"}, {name: "windows-386 canonical", input: "windows-386", expected: "windows-386"}, + // FreeBSD variants + {name: "freebsd-amd64 canonical", input: "freebsd-amd64", expected: "freebsd-amd64"}, + {name: "freebsd-arm64 canonical", input: "freebsd-arm64", expected: "freebsd-arm64"}, + // Case insensitivity {name: "uppercase AMD64", input: "AMD64", expected: "linux-amd64"}, {name: "mixed case Linux-AMD64", input: "Linux-AMD64", expected: "linux-amd64"}, @@ -52,6 +56,7 @@ func TestNormalizeUnifiedAgentArch(t *testing.T) { {name: "mixed case AARCH64", input: "AARCH64", expected: "linux-arm64"}, {name: "uppercase ARMHF", input: "ARMHF", expected: "linux-armv7"}, {name: "uppercase DARWIN-ARM64", input: "DARWIN-ARM64", expected: "darwin-arm64"}, + {name: "uppercase FREEBSD-AMD64", input: "FREEBSD-AMD64", expected: "freebsd-amd64"}, {name: "uppercase WINDOWS-AMD64", input: "WINDOWS-AMD64", expected: "windows-amd64"}, // Whitespace handling diff --git a/internal/config/persistence.go b/internal/config/persistence.go index 622cb05fd..4e1e6916b 100644 --- a/internal/config/persistence.go +++ b/internal/config/persistence.go @@ -2241,6 +2241,154 @@ func (c *ConfigPersistence) SaveAgentProfileAssignments(assignments []models.Age return c.writeConfigFileLocked(c.agentAssignmentsFile, data, 0600) } +// LoadAgentProfileVersions loads profile version history from file +func (c *ConfigPersistence) LoadAgentProfileVersions() ([]models.AgentProfileVersion, error) { + c.mu.RLock() + defer c.mu.RUnlock() + + filePath := filepath.Join(c.configDir, "profile-versions.json") + data, err := c.fs.ReadFile(filePath) + if err != nil { + if os.IsNotExist(err) { + return []models.AgentProfileVersion{}, nil + } + return nil, err + } + + if len(data) == 0 { + return []models.AgentProfileVersion{}, nil + } + + var versions []models.AgentProfileVersion + if err := json.Unmarshal(data, &versions); err != nil { + return nil, err + } + + return versions, nil +} + +// SaveAgentProfileVersions saves profile version history to file +func (c *ConfigPersistence) SaveAgentProfileVersions(versions []models.AgentProfileVersion) error { + c.mu.Lock() + defer c.mu.Unlock() + + data, err := json.MarshalIndent(versions, "", " ") + if err != nil { + return err + } + + if err := c.EnsureConfigDir(); err != nil { + return err + } + + filePath := filepath.Join(c.configDir, "profile-versions.json") + return c.writeConfigFileLocked(filePath, data, 0600) +} + +// LoadProfileDeploymentStatus loads deployment status from file +func (c *ConfigPersistence) LoadProfileDeploymentStatus() ([]models.ProfileDeploymentStatus, error) { + c.mu.RLock() + defer c.mu.RUnlock() + + filePath := filepath.Join(c.configDir, "profile-deployments.json") + data, err := c.fs.ReadFile(filePath) + if err != nil { + if os.IsNotExist(err) { + return []models.ProfileDeploymentStatus{}, nil + } + return nil, err + } + + if len(data) == 0 { + return []models.ProfileDeploymentStatus{}, nil + } + + var status []models.ProfileDeploymentStatus + if err := json.Unmarshal(data, &status); err != nil { + return nil, err + } + + return status, nil +} + +// SaveProfileDeploymentStatus saves deployment status to file +func (c *ConfigPersistence) SaveProfileDeploymentStatus(status []models.ProfileDeploymentStatus) error { + c.mu.Lock() + defer c.mu.Unlock() + + data, err := json.MarshalIndent(status, "", " ") + if err != nil { + return err + } + + if err := c.EnsureConfigDir(); err != nil { + return err + } + + filePath := filepath.Join(c.configDir, "profile-deployments.json") + return c.writeConfigFileLocked(filePath, data, 0600) +} + +// LoadProfileChangeLogs loads change logs from file +func (c *ConfigPersistence) LoadProfileChangeLogs() ([]models.ProfileChangeLog, error) { + c.mu.RLock() + defer c.mu.RUnlock() + + filePath := filepath.Join(c.configDir, "profile-changelog.json") + data, err := c.fs.ReadFile(filePath) + if err != nil { + if os.IsNotExist(err) { + return []models.ProfileChangeLog{}, nil + } + return nil, err + } + + if len(data) == 0 { + return []models.ProfileChangeLog{}, nil + } + + var logs []models.ProfileChangeLog + if err := json.Unmarshal(data, &logs); err != nil { + return nil, err + } + + return logs, nil +} + +// SaveProfileChangeLogs saves change logs to file +func (c *ConfigPersistence) SaveProfileChangeLogs(logs []models.ProfileChangeLog) error { + c.mu.Lock() + defer c.mu.Unlock() + + data, err := json.MarshalIndent(logs, "", " ") + if err != nil { + return err + } + + if err := c.EnsureConfigDir(); err != nil { + return err + } + + filePath := filepath.Join(c.configDir, "profile-changelog.json") + return c.writeConfigFileLocked(filePath, data, 0600) +} + +// AppendProfileChangeLog adds a new entry to the change log +func (c *ConfigPersistence) AppendProfileChangeLog(entry models.ProfileChangeLog) error { + logs, err := c.LoadProfileChangeLogs() + if err != nil { + return err + } + + // Keep last 1000 entries + if len(logs) >= 1000 { + logs = logs[len(logs)-999:] + } + + logs = append(logs, entry) + return c.SaveProfileChangeLogs(logs) +} + // ============================================ // AI Chat Sessions Persistence // ============================================ diff --git a/internal/models/profiles.go b/internal/models/profiles.go index 72d8ed5c7..19d8a9c2d 100644 --- a/internal/models/profiles.go +++ b/internal/models/profiles.go @@ -6,11 +6,16 @@ import ( // AgentProfile represents a reusable configuration profile for agents. type AgentProfile struct { - ID string `json:"id"` - Name string `json:"name"` - Config AgentConfigMap `json:"config"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Config AgentConfigMap `json:"config"` + Version int `json:"version"` // Auto-incremented on each update + ParentID string `json:"parent_id,omitempty"` // For profile inheritance + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CreatedBy string `json:"created_by,omitempty"` + UpdatedBy string `json:"updated_by,omitempty"` } // AgentConfigMap represents the key-value configuration overrides @@ -19,7 +24,82 @@ type AgentConfigMap map[string]interface{} // AgentProfileAssignment maps an agent to a profile type AgentProfileAssignment struct { - AgentID string `json:"agent_id"` - ProfileID string `json:"profile_id"` - UpdatedAt time.Time `json:"updated_at"` + AgentID string `json:"agent_id"` + ProfileID string `json:"profile_id"` + ProfileVersion int `json:"profile_version"` // Version at time of assignment + UpdatedAt time.Time `json:"updated_at"` + AssignedBy string `json:"assigned_by,omitempty"` +} + +// AgentProfileVersion represents a historical version of a profile. +type AgentProfileVersion struct { + ProfileID string `json:"profile_id"` + Version int `json:"version"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Config AgentConfigMap `json:"config"` + ParentID string `json:"parent_id,omitempty"` + CreatedAt time.Time `json:"created_at"` // When this version was created + CreatedBy string `json:"created_by,omitempty"` + ChangeNote string `json:"change_note,omitempty"` // Optional note about the change +} + +// ProfileDeploymentStatus tracks which version an agent has received. +type ProfileDeploymentStatus struct { + AgentID string `json:"agent_id"` + ProfileID string `json:"profile_id"` + AssignedVersion int `json:"assigned_version"` // Version that should be deployed + DeployedVersion int `json:"deployed_version"` // Version actually deployed + LastDeployedAt time.Time `json:"last_deployed_at"` + DeploymentStatus string `json:"deployment_status"` // "pending", "deployed", "failed" + ErrorMessage string `json:"error_message,omitempty"` +} + +// ProfileChangeLog represents an audit entry for profile changes. +type ProfileChangeLog struct { + ID string `json:"id"` + ProfileID string `json:"profile_id"` + ProfileName string `json:"profile_name"` + Action string `json:"action"` // "create", "update", "delete", "assign", "unassign", "rollback" + OldVersion int `json:"old_version,omitempty"` + NewVersion int `json:"new_version,omitempty"` + AgentID string `json:"agent_id,omitempty"` // For assign/unassign actions + User string `json:"user,omitempty"` + Timestamp time.Time `json:"timestamp"` + Details string `json:"details,omitempty"` +} + +// MergedConfig returns the effective configuration by merging parent configs. +// Parent configs are applied first, then overridden by child configs. +func (p *AgentProfile) MergedConfig(profiles []AgentProfile) AgentConfigMap { + if p.ParentID == "" { + return p.Config + } + + // Find parent profile + var parent *AgentProfile + for i := range profiles { + if profiles[i].ID == p.ParentID { + parent = &profiles[i] + break + } + } + + if parent == nil { + return p.Config + } + + // Get parent's merged config (recursive) + parentConfig := parent.MergedConfig(profiles) + + // Merge: start with parent config, override with current + merged := make(AgentConfigMap) + for k, v := range parentConfig { + merged[k] = v + } + for k, v := range p.Config { + merged[k] = v + } + + return merged } diff --git a/pkg/auth/rbac.go b/pkg/auth/rbac.go index d87c5aa34..fda0a4369 100644 --- a/pkg/auth/rbac.go +++ b/pkg/auth/rbac.go @@ -6,10 +6,26 @@ import ( "time" ) -// Permission defines a single allowed action on a resource. +// Permission defines an access rule for an action on a resource. type Permission struct { - Action string `json:"action"` // read, write, delete, admin - Resource string `json:"resource"` // nodes, settings, users, audit_logs, etc. + Action string `json:"action"` // read, write, delete, admin + Resource string `json:"resource"` // nodes, nodes:pve1, settings, * + Effect string `json:"effect,omitempty"` // "allow" (default) or "deny" + Conditions map[string]string `json:"conditions,omitempty"` // ABAC conditions, e.g., {"tag": "production"} +} + +// EffectAllow and EffectDeny are the valid values for Permission.Effect +const ( + EffectAllow = "allow" + EffectDeny = "deny" +) + +// GetEffect returns the effect, defaulting to "allow" if empty +func (p Permission) GetEffect() string { + if p.Effect == "" { + return EffectAllow + } + return p.Effect } // Role represents a collection of permissions. @@ -17,8 +33,10 @@ type Role struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` + ParentID string `json:"parentId,omitempty"` // For role inheritance Permissions []Permission `json:"permissions"` IsBuiltIn bool `json:"isBuiltIn"` + Priority int `json:"priority,omitempty"` // For conflict resolution (higher = more priority) CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } @@ -30,6 +48,28 @@ type UserRoleAssignment struct { UpdatedAt time.Time `json:"updatedAt"` } +// RBACChangeLog represents an audit entry for RBAC changes. +type RBACChangeLog struct { + ID string `json:"id"` + Action string `json:"action"` // role_created, role_updated, role_deleted, user_assigned, etc. + EntityType string `json:"entityType"` // role, assignment + EntityID string `json:"entityId"` // Role ID or username + OldValue string `json:"oldValue,omitempty"` // JSON of previous state + NewValue string `json:"newValue,omitempty"` // JSON of new state + User string `json:"user,omitempty"` // Who made the change + Timestamp time.Time `json:"timestamp"` +} + +// RBAC change action constants +const ( + ActionRoleCreated = "role_created" + ActionRoleUpdated = "role_updated" + ActionRoleDeleted = "role_deleted" + ActionUserAssigned = "user_assigned" + ActionUserUnassigned = "user_unassigned" + ActionUserRolesUpdate = "user_roles_updated" +) + // Built-in Role IDs const ( RoleAdmin = "admin" @@ -59,6 +99,25 @@ type Manager interface { GetUserPermissions(username string) []Permission } +// ExtendedManager extends Manager with advanced RBAC features. +// This is implemented by the SQLite-backed manager for Pro features. +type ExtendedManager interface { + Manager + + // Role inheritance + GetRoleWithInheritance(id string) (Role, []Permission, bool) // Returns role and all inherited permissions + GetRolesWithInheritance(username string) []Role // Returns user's roles with inheritance chain + + // Change log + GetChangeLogs(limit int, offset int) []RBACChangeLog + GetChangeLogsForEntity(entityType, entityID string) []RBACChangeLog + + // Context-aware operations (for audit trail) + SaveRoleWithContext(role Role, username string) error + DeleteRoleWithContext(id string, username string) error + UpdateUserRolesWithContext(username string, roleIDs []string, byUser string) error +} + var ( globalManager Manager managerMu sync.RWMutex @@ -85,3 +144,55 @@ func HasPermission(ctx context.Context, action, resource string) bool { allowed, _ := authorizer.Authorize(ctx, action, resource) return allowed } + +// GetExtendedManager returns the global manager as ExtendedManager if it implements the interface. +func GetExtendedManager() ExtendedManager { + managerMu.RLock() + defer managerMu.RUnlock() + if em, ok := globalManager.(ExtendedManager); ok { + return em + } + return nil +} + +// MatchesResource checks if a permission's resource pattern matches a requested resource. +// Supports: +// - Exact match: "nodes" matches "nodes" +// - Specific ID: "nodes:pve1" matches "nodes:pve1" +// - Wildcard: "nodes:*" matches "nodes:pve1" +// - Global wildcard: "*" matches any resource +func MatchesResource(pattern, resource string) bool { + // Global wildcard matches everything + if pattern == "*" { + return true + } + + // Exact match + if pattern == resource { + return true + } + + // Check for wildcard pattern (e.g., "nodes:*") + if len(pattern) > 2 && pattern[len(pattern)-2:] == ":*" { + prefix := pattern[:len(pattern)-2] + // Match if resource starts with the prefix + if resource == prefix { + return true + } + // Match if resource has the prefix followed by a colon + if len(resource) > len(prefix) && resource[:len(prefix)+1] == prefix+":" { + return true + } + } + + return false +} + +// MatchesAction checks if a permission's action matches a requested action. +// "admin" action matches any action. +func MatchesAction(permAction, requestedAction string) bool { + if permAction == "admin" { + return true + } + return permAction == requestedAction +} diff --git a/pkg/server/server.go b/pkg/server/server.go index 297e4b0aa..c24592e84 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -15,6 +15,7 @@ import ( "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/rcourtman/pulse-go-rewrite/internal/agentbinaries" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/approval" "github.com/rcourtman/pulse-go-rewrite/internal/alerts" "github.com/rcourtman/pulse-go-rewrite/internal/api" "github.com/rcourtman/pulse-go-rewrite/internal/config" @@ -27,6 +28,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/pkg/audit" "github.com/rcourtman/pulse-go-rewrite/pkg/auth" "github.com/rcourtman/pulse-go-rewrite/pkg/metrics" + "github.com/rcourtman/pulse-go-rewrite/pkg/reporting" "github.com/rs/zerolog/log" ) @@ -90,22 +92,50 @@ func Run(ctx context.Context, version string) error { // Initialize license public key for Pro feature validation license.InitPublicKey() + // Create license service early for feature checks + licenseService := license.NewService() + // Initialize RBAC manager for role-based access control dataDir := os.Getenv("PULSE_DATA_DIR") if dataDir == "" { dataDir = "/etc/pulse" } - rbacManager, err := auth.NewFileManager(dataDir) - if err != nil { - log.Warn().Err(err).Msg("Failed to initialize RBAC manager, role management will be unavailable") + + // Use SQLite-backed manager for Pro features, file-based for Community + if licenseService.HasFeature(license.FeatureRBAC) { + sqliteManager, err := auth.NewSQLiteManager(auth.SQLiteManagerConfig{ + DataDir: dataDir, + MigrateFromFiles: true, // Automatically migrate from file-based storage + }) + if err != nil { + log.Warn().Err(err).Msg("Failed to initialize SQLite RBAC manager, falling back to file-based") + // Fall back to file-based manager + fileManager, fileErr := auth.NewFileManager(dataDir) + if fileErr != nil { + log.Warn().Err(fileErr).Msg("Failed to initialize file-based RBAC manager") + } else { + auth.SetManager(fileManager) + log.Info().Msg("RBAC manager initialized (file-based fallback)") + } + } else { + auth.SetManager(sqliteManager) + // Set up RBAC authorizer with policy evaluation + rbacAuthorizer := auth.NewRBACAuthorizer(sqliteManager) + auth.SetAuthorizer(rbacAuthorizer) + log.Info().Msg("RBAC Pro manager initialized with SQLite backend (deny policies, inheritance, changelog)") + } } else { - auth.SetManager(rbacManager) - log.Info().Msg("RBAC manager initialized") + rbacManager, err := auth.NewFileManager(dataDir) + if err != nil { + log.Warn().Err(err).Msg("Failed to initialize RBAC manager, role management will be unavailable") + } else { + auth.SetManager(rbacManager) + log.Info().Msg("RBAC manager initialized (file-based)") + } } // Initialize SQLite audit logger for Pro/Enterprise // Check if audit logging feature is available via mock mode or license - licenseService := license.NewService() if licenseService.HasFeature(license.FeatureAuditLogging) { // Initialize crypto manager for signing key encryption cryptoMgr, err := crypto.NewCryptoManagerAt(dataDir) @@ -134,6 +164,22 @@ func Run(ctx context.Context, version string) error { log.Debug().Msg("Audit logging feature not licensed, using console logger") } + // Initialize AI approval store for Auto-Fix workflows (Pro feature) + if licenseService.HasFeature(license.FeatureAIAutoFix) { + approvalStore, err := approval.NewStore(approval.StoreConfig{ + DataDir: dataDir, + DefaultTimeout: 5 * time.Minute, + MaxApprovals: 1000, + }) + if err != nil { + log.Warn().Err(err).Msg("Failed to initialize AI approval store") + } else { + approval.SetStore(approvalStore) + approvalStore.StartCleanup(ctx) + log.Info().Msg("AI approval store initialized for Auto-Fix workflows") + } + } + log.Info().Msg("Starting Pulse monitoring server") // Validate agent binaries are available for download @@ -206,6 +252,21 @@ func Run(ctx context.Context, version string) error { // Start monitoring reloadableMonitor.Start(ctx) + // Initialize reporting engine for Pro/Enterprise + if licenseService.HasFeature(license.FeatureAdvancedReporting) { + if store := reloadableMonitor.GetMonitor().GetMetricsStore(); store != nil { + reportEngine := reporting.NewReportEngine(reporting.EngineConfig{ + MetricsStore: store, + }) + reporting.SetEngine(reportEngine) + log.Info().Msg("Advanced reporting engine initialized") + } else { + log.Warn().Msg("Metrics store not available, reporting engine not initialized") + } + } else { + log.Debug().Msg("Advanced reporting feature not licensed") + } + // Initialize API server with reload function var router *api.Router reloadFunc := func() error {