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:
rcourtman
2026-01-11 23:51:12 +00:00
parent f527e6ebd0
commit b2a6cd0fa3
15 changed files with 1631 additions and 28 deletions

View File

@@ -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:

View File

@@ -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>

View File

@@ -14,7 +14,7 @@ const backendPort = Number(
process.env.PULSE_DEV_API_PORT ??
process.env.FRONTEND_PORT ??
process.env.PORT ??
7655,
7654,
);
const backendUrl =

View File

@@ -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
}

View File

@@ -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()

View File

@@ -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] + "..."
}

View File

@@ -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")
}
}

View File

@@ -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)
}

View File

@@ -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) {

View File

@@ -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":

View File

@@ -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

View File

@@ -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
// ============================================

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 {