mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
fix(agent): add FreeBSD platform support to agent download and UI (#1051)
- Add freebsd-amd64 and freebsd-arm64 to normalizeUnifiedAgentArch() so the download endpoint serves FreeBSD binaries when requested - Add FreeBSD/pfSense/OPNsense platform option to agent setup UI with note about bash installation requirement - Add FreeBSD test cases to unified_agent_test.go Fixes installation on pfSense/OPNsense where users were getting 404 errors because the backend didn't recognize the freebsd-amd64 arch parameter from install.sh.
This commit is contained in:
@@ -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: (
|
||||
<span>
|
||||
Run as root. <strong>Note:</strong> pfSense/OPNsense don't include bash by default. Install it first: <code>pkg install bash</code>. Creates <code>/usr/local/etc/rc.d/pulse-agent</code> and starts the agent automatically.
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
windows: {
|
||||
title: 'Install on Windows',
|
||||
description:
|
||||
|
||||
@@ -156,7 +156,26 @@ export const UserAssignmentsPanel: Component = () => {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading()}>
|
||||
<Show when={!loading() && filteredAssignments().length === 0}>
|
||||
<div class="text-center py-12 px-6">
|
||||
<Users class="w-12 h-12 mx-auto text-gray-300 dark:text-gray-600 mb-4" />
|
||||
<h4 class="text-base font-medium text-gray-900 dark:text-gray-100 mb-2">No users yet</h4>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 max-w-md mx-auto">
|
||||
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.
|
||||
</p>
|
||||
<div class="mt-6 flex flex-col sm:flex-row items-center justify-center gap-3 text-xs text-gray-400 dark:text-gray-500">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<Shield class="w-3.5 h-3.5" />
|
||||
Configure SSO in Security settings
|
||||
</span>
|
||||
<span class="hidden sm:inline">•</span>
|
||||
<span>Users sync on first login</span>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading() && filteredAssignments().length > 0}>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
|
||||
@@ -14,7 +14,7 @@ const backendPort = Number(
|
||||
process.env.PULSE_DEV_API_PORT ??
|
||||
process.env.FRONTEND_PORT ??
|
||||
process.env.PORT ??
|
||||
7655,
|
||||
7654,
|
||||
);
|
||||
|
||||
const backendUrl =
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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] + "..."
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
// ============================================
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
117
pkg/auth/rbac.go
117
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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user