From 55f5f071edda0bb88033457f1be2b16d39625d10 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 10 Jan 2026 21:39:39 +0000 Subject: [PATCH] fix: replace hallucinated upgrade URLs with correct pulserelay.pro Previous LLM sessions incorrectly inserted fake URLs (pulse.sh/pro and yourpulse.io/pro) for the Pro upgrade links. Neither domain exists. Replaced all 34 instances with the correct URL: https://pulserelay.pro/ Fixes #1077 --- frontend-modern/src/components/AI/AIChat.tsx | 2 +- .../Kubernetes/KubernetesClusters.tsx | 2 +- .../src/components/Settings/AISettings.tsx | 6 +- .../Settings/AgentProfilesPanel.tsx | 2 +- .../src/components/Settings/AuditLogPanel.tsx | 2 +- .../src/components/Settings/OIDCPanel.tsx | 2 +- .../components/Settings/ProLicensePanel.tsx | 2 +- .../src/components/Settings/RolesPanel.tsx | 2 +- .../Settings/UserAssignmentsPanel.tsx | 2 +- .../shared/AIImpactTimelinePanel.tsx | 2 +- .../src/components/shared/AIInsightsPanel.tsx | 4 +- .../src/components/shared/AIOverviewTable.tsx | 2 +- .../shared/AIRecentChangesPanel.tsx | 2 +- frontend-modern/src/pages/Alerts.tsx | 4 +- internal/api/ai_handlers.go | 24 +- internal/api/ai_intelligence_handlers.go | 2 +- internal/api/license_handlers.go | 4 +- internal/api/router.go | 2 +- pkg/auth/rbac_manager.go | 350 ++++++++++++++++++ pkg/auth/rbac_manager_test.go | 174 +++++++++ 20 files changed, 558 insertions(+), 34 deletions(-) create mode 100644 pkg/auth/rbac_manager.go create mode 100644 pkg/auth/rbac_manager_test.go diff --git a/frontend-modern/src/components/AI/AIChat.tsx b/frontend-modern/src/components/AI/AIChat.tsx index 20a85e15d..c3200d0fb 100644 --- a/frontend-modern/src/components/AI/AIChat.tsx +++ b/frontend-modern/src/components/AI/AIChat.tsx @@ -1167,7 +1167,7 @@ export const AIChat: Component = (props) => { AI Patrol monitors your infrastructure 24/7 and surfaces issues before they become outages.

= (props) => const kubernetesAiEnabled = createMemo(() => licenseFeatures()?.features?.kubernetes_ai === true); const aiConfigured = createMemo(() => aiSettings()?.configured === true); - const upgradeUrl = createMemo(() => licenseFeatures()?.upgrade_url || 'https://pulse.sh/pro'); + const upgradeUrl = createMemo(() => licenseFeatures()?.upgrade_url || 'https://pulserelay.pro/'); const clustersForAnalysis = createMemo(() => props.clusters ?? []); diff --git a/frontend-modern/src/components/Settings/AISettings.tsx b/frontend-modern/src/components/Settings/AISettings.tsx index b5a318fb3..53ef8fd9d 100644 --- a/frontend-modern/src/components/Settings/AISettings.tsx +++ b/frontend-modern/src/components/Settings/AISettings.tsx @@ -1186,7 +1186,7 @@ export const AISettings: Component = () => {

@@ -1275,7 +1275,7 @@ export const AISettings: Component = () => {

@@ -1317,7 +1317,7 @@ export const AISettings: Component = () => {

diff --git a/frontend-modern/src/components/Settings/AgentProfilesPanel.tsx b/frontend-modern/src/components/Settings/AgentProfilesPanel.tsx index 3b6d561d2..60ef1da6d 100644 --- a/frontend-modern/src/components/Settings/AgentProfilesPanel.tsx +++ b/frontend-modern/src/components/Settings/AgentProfilesPanel.tsx @@ -260,7 +260,7 @@ export const AgentProfilesPanel: Component = () => { logging levels, and reporting intervals from a central location.

diff --git a/frontend-modern/src/components/Settings/OIDCPanel.tsx b/frontend-modern/src/components/Settings/OIDCPanel.tsx index c7d8227b7..135cfabdb 100644 --- a/frontend-modern/src/components/Settings/OIDCPanel.tsx +++ b/frontend-modern/src/components/Settings/OIDCPanel.tsx @@ -289,7 +289,7 @@ export const OIDCPanel: Component = (props) => {

diff --git a/frontend-modern/src/components/Settings/ProLicensePanel.tsx b/frontend-modern/src/components/Settings/ProLicensePanel.tsx index 6bb6c0286..752215546 100644 --- a/frontend-modern/src/components/Settings/ProLicensePanel.tsx +++ b/frontend-modern/src/components/Settings/ProLicensePanel.tsx @@ -5,7 +5,7 @@ import { notificationStore } from '@/stores/notifications'; import { LicenseAPI, type LicenseStatus } from '@/api/license'; import RefreshCw from 'lucide-solid/icons/refresh-cw'; -const PULSE_PRO_URL = 'https://pulse.sh/pro'; +const PULSE_PRO_URL = 'https://pulserelay.pro/'; const TIER_LABELS: Record = { free: 'Free', diff --git a/frontend-modern/src/components/Settings/RolesPanel.tsx b/frontend-modern/src/components/Settings/RolesPanel.tsx index 503fe0502..6962ae4ba 100644 --- a/frontend-modern/src/components/Settings/RolesPanel.tsx +++ b/frontend-modern/src/components/Settings/RolesPanel.tsx @@ -157,7 +157,7 @@ export const RolesPanel: Component = () => {

{

= (props) => { const [remediations, setRemediations] = createSignal([]); diff --git a/frontend-modern/src/components/shared/AIInsightsPanel.tsx b/frontend-modern/src/components/shared/AIInsightsPanel.tsx index 33bd2b87f..76c8e6be9 100644 --- a/frontend-modern/src/components/shared/AIInsightsPanel.tsx +++ b/frontend-modern/src/components/shared/AIInsightsPanel.tsx @@ -14,7 +14,7 @@ export const AIInsightsPanel: Component<{ resourceId?: string; showWhenEmpty?: b const [expanded, setExpanded] = createSignal(false); const [locked, setLocked] = createSignal(false); const [lockedCount, setLockedCount] = createSignal(0); - const [upgradeUrl, setUpgradeUrl] = createSignal('https://pulse.sh/pro'); + const [upgradeUrl, setUpgradeUrl] = createSignal('https://pulserelay.pro/'); const [error, setError] = createSignal(''); const showWhenEmpty = () => Boolean(props.showWhenEmpty); @@ -28,7 +28,7 @@ export const AIInsightsPanel: Component<{ resourceId?: string; showWhenEmpty?: b ]); const licenseLocked = Boolean(predResp.license_required || corrResp.license_required); setLocked(licenseLocked); - setUpgradeUrl(predResp.upgrade_url || corrResp.upgrade_url || 'https://pulse.sh/pro'); + setUpgradeUrl(predResp.upgrade_url || corrResp.upgrade_url || 'https://pulserelay.pro/'); if (licenseLocked) { const predCount = predResp.count || 0; const corrCount = corrResp.count || 0; diff --git a/frontend-modern/src/components/shared/AIOverviewTable.tsx b/frontend-modern/src/components/shared/AIOverviewTable.tsx index a7731ee45..2e80c9206 100644 --- a/frontend-modern/src/components/shared/AIOverviewTable.tsx +++ b/frontend-modern/src/components/shared/AIOverviewTable.tsx @@ -3,7 +3,7 @@ import { AIAPI } from '@/api/ai'; import { logger } from '@/utils/logger'; import type { FailurePrediction, InfrastructureChange, RemediationRecord, RemediationStats, AnomalyReport } from '@/types/aiIntelligence'; -const DEFAULT_UPGRADE_URL = 'https://pulse.sh/pro'; +const DEFAULT_UPGRADE_URL = 'https://pulserelay.pro/'; interface InsightRow { id: string; diff --git a/frontend-modern/src/components/shared/AIRecentChangesPanel.tsx b/frontend-modern/src/components/shared/AIRecentChangesPanel.tsx index 8d11268ca..5067e6462 100644 --- a/frontend-modern/src/components/shared/AIRecentChangesPanel.tsx +++ b/frontend-modern/src/components/shared/AIRecentChangesPanel.tsx @@ -3,7 +3,7 @@ import { AIAPI } from '@/api/ai'; import { logger } from '@/utils/logger'; import type { InfrastructureChange } from '@/types/aiIntelligence'; -const DEFAULT_UPGRADE_URL = 'https://pulse.sh/pro'; +const DEFAULT_UPGRADE_URL = 'https://pulserelay.pro/'; export const AIRecentChangesPanel: Component<{ hours?: number; showWhenEmpty?: boolean }> = (props) => { const [changes, setChanges] = createSignal([]); diff --git a/frontend-modern/src/pages/Alerts.tsx b/frontend-modern/src/pages/Alerts.tsx index 8bb3fdcc8..6d38df4f3 100644 --- a/frontend-modern/src/pages/Alerts.tsx +++ b/frontend-modern/src/pages/Alerts.tsx @@ -2283,7 +2283,7 @@ function OverviewTab(props: { if (!status) return false; return !status.features?.['ai_alerts']; }); - const aiAlertsUpgradeURL = createMemo(() => licenseFeatures()?.upgrade_url || 'https://pulse.sh/pro'); + const aiAlertsUpgradeURL = createMemo(() => licenseFeatures()?.upgrade_url || 'https://pulserelay.pro/'); // Live streaming state for running patrol const [expandedLiveStream, setExpandedLiveStream] = createSignal(false); // Track streaming blocks for sequential display (like AI chat) @@ -2296,7 +2296,7 @@ function OverviewTab(props: { const [currentThinking, setCurrentThinking] = createSignal(''); let liveStreamUnsubscribe: (() => void) | null = null; const patrolRequiresLicense = createMemo(() => patrolStatus()?.license_required === true); - const patrolUpgradeURL = createMemo(() => patrolStatus()?.upgrade_url || 'https://pulse.sh/pro'); + const patrolUpgradeURL = createMemo(() => patrolStatus()?.upgrade_url || 'https://pulserelay.pro/'); const patrolLicenseNote = createMemo(() => { if (!patrolRequiresLicense()) return ''; const status = patrolStatus()?.license_status; diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index 1f4249fd4..ebe789344 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -385,7 +385,7 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt "error": "license_required", "message": "AI Auto-Fix requires Pulse Pro", "feature": ai.FeatureAIAutoFix, - "upgrade_url": "https://pulse.sh/pro", + "upgrade_url": "https://pulserelay.pro/", }) return } @@ -401,7 +401,7 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt "error": "license_required", "message": "Proactive Thresholds requires Pulse Pro", "feature": ai.FeatureAIPatrol, - "upgrade_url": "https://pulse.sh/pro", + "upgrade_url": "https://pulserelay.pro/", }) return } @@ -421,7 +421,7 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt "error": "license_required", "message": "Autonomous Mode requires Pulse Pro", "feature": ai.FeatureAIAutoFix, - "upgrade_url": "https://pulse.sh/pro", + "upgrade_url": "https://pulserelay.pro/", }) return } @@ -502,7 +502,7 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt "error": "license_required", "message": "Background AI Patrol requires Pulse Pro", "feature": ai.FeatureAIPatrol, - "upgrade_url": "https://pulse.sh/pro", + "upgrade_url": "https://pulserelay.pro/", }) return } @@ -522,7 +522,7 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt "error": "license_required", "message": "Background AI Patrol requires Pulse Pro", "feature": ai.FeatureAIPatrol, - "upgrade_url": "https://pulse.sh/pro", + "upgrade_url": "https://pulserelay.pro/", }) return } @@ -555,7 +555,7 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt "error": "license_required", "message": "AI Alert Analysis requires Pulse Pro", "feature": ai.FeatureAIAlerts, - "upgrade_url": "https://pulse.sh/pro", + "upgrade_url": "https://pulserelay.pro/", }) return } @@ -892,7 +892,7 @@ func (h *AISettingsHandler) HandleExecute(w http.ResponseWriter, r *http.Request "error": "license_required", "message": "AI Auto-Fix requires Pulse Pro", "feature": ai.FeatureAIAutoFix, - "upgrade_url": "https://pulse.sh/pro", + "upgrade_url": "https://pulserelay.pro/", }) return } @@ -904,7 +904,7 @@ func (h *AISettingsHandler) HandleExecute(w http.ResponseWriter, r *http.Request "error": "license_required", "message": "AI Patrol reasoning requires Pulse Pro", "feature": ai.FeatureAIPatrol, - "upgrade_url": "https://pulse.sh/pro", + "upgrade_url": "https://pulserelay.pro/", }) return } @@ -1078,7 +1078,7 @@ func (h *AISettingsHandler) HandleExecuteStream(w http.ResponseWriter, r *http.R "error": "license_required", "message": "AI Auto-Fix requires Pulse Pro", "feature": ai.FeatureAIAutoFix, - "upgrade_url": "https://pulse.sh/pro", + "upgrade_url": "https://pulserelay.pro/", }) return } @@ -1090,7 +1090,7 @@ func (h *AISettingsHandler) HandleExecuteStream(w http.ResponseWriter, r *http.R "error": "license_required", "message": "AI Patrol reasoning requires Pulse Pro", "feature": ai.FeatureAIPatrol, - "upgrade_url": "https://pulse.sh/pro", + "upgrade_url": "https://pulserelay.pro/", }) return } @@ -1312,7 +1312,7 @@ func (h *AISettingsHandler) HandleRunCommand(w http.ResponseWriter, r *http.Requ "error": "license_required", "message": "AI Auto-Fix requires Pulse Pro", "feature": ai.FeatureAIAutoFix, - "upgrade_url": "https://pulse.sh/pro", + "upgrade_url": "https://pulserelay.pro/", }) return } @@ -2298,7 +2298,7 @@ func (h *AISettingsHandler) HandleGetPatrolStatus(w http.ResponseWriter, r *http LicenseStatus: licenseStatus, } if !hasPatrolFeature { - response.UpgradeURL = "https://pulse.sh/pro" + response.UpgradeURL = "https://pulserelay.pro/" } response.Summary.Critical = summary.Critical response.Summary.Warning = summary.Warning diff --git a/internal/api/ai_intelligence_handlers.go b/internal/api/ai_intelligence_handlers.go index 60a5e993e..0c116ca35 100644 --- a/internal/api/ai_intelligence_handlers.go +++ b/internal/api/ai_intelligence_handlers.go @@ -11,7 +11,7 @@ import ( "github.com/rs/zerolog/log" ) -const aiIntelligenceUpgradeURL = "https://pulse.sh/pro" +const aiIntelligenceUpgradeURL = "https://pulserelay.pro/" // HandleGetPatterns returns detected failure patterns (GET /api/ai/intelligence/patterns) func (h *AISettingsHandler) HandleGetPatterns(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/license_handlers.go b/internal/api/license_handlers.go index 5536d2b8f..8e9663cef 100644 --- a/internal/api/license_handlers.go +++ b/internal/api/license_handlers.go @@ -102,7 +102,7 @@ func (h *LicenseHandlers) HandleLicenseFeatures(w http.ResponseWriter, r *http.R license.FeatureAuditLogging: h.service.HasFeature(license.FeatureAuditLogging), license.FeatureAdvancedReporting: h.service.HasFeature(license.FeatureAdvancedReporting), }, - UpgradeURL: "https://pulse.sh/pro", + UpgradeURL: "https://pulserelay.pro/", } w.Header().Set("Content-Type", "application/json") @@ -228,7 +228,7 @@ func RequireLicenseFeature(service *license.Service, feature string, next http.H "error": "license_required", "message": err.Error(), "feature": feature, - "upgrade_url": "https://pulse.sh/pro", + "upgrade_url": "https://pulserelay.pro/", }) return } diff --git a/internal/api/router.go b/internal/api/router.go index 73dd4a5df..f1efbe3b5 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1330,7 +1330,7 @@ func (r *Router) setupRoutes() { "error": "license_required", "message": err.Error(), "feature": license.FeatureAIPatrol, - "upgrade_url": "https://pulse.sh/pro", + "upgrade_url": "https://pulserelay.pro/", }) return } diff --git a/pkg/auth/rbac_manager.go b/pkg/auth/rbac_manager.go new file mode 100644 index 000000000..5f38ece1a --- /dev/null +++ b/pkg/auth/rbac_manager.go @@ -0,0 +1,350 @@ +package auth + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +// FileManager implements the Manager interface with file-based persistence. +type FileManager struct { + mu sync.RWMutex + dataDir string + roles map[string]Role + assignments map[string]UserRoleAssignment +} + +// NewFileManager creates a new file-based RBAC manager. +func NewFileManager(dataDir string) (*FileManager, error) { + m := &FileManager{ + dataDir: dataDir, + roles: make(map[string]Role), + assignments: make(map[string]UserRoleAssignment), + } + + // Initialize built-in roles + m.initBuiltInRoles() + + // Load persisted data + if err := m.load(); err != nil { + // Non-fatal - just start fresh if no data exists + if !os.IsNotExist(err) { + return nil, fmt.Errorf("failed to load RBAC data: %w", err) + } + } + + return m, nil +} + +func (m *FileManager) initBuiltInRoles() { + now := time.Now() + + builtInRoles := []Role{ + { + ID: RoleAdmin, + Name: "Administrator", + Description: "Full administrative access to all features", + Permissions: []Permission{ + {Action: "admin", Resource: "*"}, + }, + IsBuiltIn: true, + CreatedAt: now, + UpdatedAt: now, + }, + { + ID: RoleOperator, + Name: "Operator", + Description: "Can manage nodes and perform operational tasks", + Permissions: []Permission{ + {Action: "read", Resource: "*"}, + {Action: "write", Resource: "nodes"}, + {Action: "write", Resource: "vms"}, + {Action: "write", Resource: "containers"}, + {Action: "write", Resource: "alerts"}, + }, + IsBuiltIn: true, + CreatedAt: now, + UpdatedAt: now, + }, + { + ID: RoleViewer, + Name: "Viewer", + Description: "Read-only access to monitoring data", + Permissions: []Permission{ + {Action: "read", Resource: "*"}, + }, + IsBuiltIn: true, + CreatedAt: now, + UpdatedAt: now, + }, + { + ID: RoleAuditor, + Name: "Auditor", + Description: "Access to audit logs and compliance data", + Permissions: []Permission{ + {Action: "read", Resource: "audit_logs"}, + {Action: "read", Resource: "nodes"}, + {Action: "read", Resource: "alerts"}, + {Action: "read", Resource: "compliance"}, + }, + IsBuiltIn: true, + CreatedAt: now, + UpdatedAt: now, + }, + } + + for _, role := range builtInRoles { + m.roles[role.ID] = role + } +} + +func (m *FileManager) rolesFile() string { + return filepath.Join(m.dataDir, "rbac_roles.json") +} + +func (m *FileManager) assignmentsFile() string { + return filepath.Join(m.dataDir, "rbac_assignments.json") +} + +func (m *FileManager) load() error { + // Load custom roles (built-in roles are always initialized) + if data, err := os.ReadFile(m.rolesFile()); err == nil { + var roles []Role + if err := json.Unmarshal(data, &roles); err != nil { + return err + } + for _, role := range roles { + if !role.IsBuiltIn { + m.roles[role.ID] = role + } + } + } + + // Load assignments + if data, err := os.ReadFile(m.assignmentsFile()); err == nil { + var assignments []UserRoleAssignment + if err := json.Unmarshal(data, &assignments); err != nil { + return err + } + for _, a := range assignments { + m.assignments[a.Username] = a + } + } + + return nil +} + +func (m *FileManager) saveRoles() error { + roles := make([]Role, 0, len(m.roles)) + for _, role := range m.roles { + roles = append(roles, role) + } + + data, err := json.MarshalIndent(roles, "", " ") + if err != nil { + return err + } + + return os.WriteFile(m.rolesFile(), data, 0600) +} + +func (m *FileManager) saveAssignments() error { + assignments := make([]UserRoleAssignment, 0, len(m.assignments)) + for _, a := range m.assignments { + assignments = append(assignments, a) + } + + data, err := json.MarshalIndent(assignments, "", " ") + if err != nil { + return err + } + + return os.WriteFile(m.assignmentsFile(), data, 0600) +} + +// GetRoles returns all roles. +func (m *FileManager) GetRoles() []Role { + m.mu.RLock() + defer m.mu.RUnlock() + + roles := make([]Role, 0, len(m.roles)) + for _, role := range m.roles { + roles = append(roles, role) + } + return roles +} + +// GetRole returns a role by ID. +func (m *FileManager) GetRole(id string) (Role, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + + role, ok := m.roles[id] + return role, ok +} + +// SaveRole creates or updates a role. +func (m *FileManager) SaveRole(role Role) error { + m.mu.Lock() + defer m.mu.Unlock() + + // Cannot modify built-in roles + if existing, ok := m.roles[role.ID]; ok && existing.IsBuiltIn { + return fmt.Errorf("cannot modify built-in role: %s", role.ID) + } + + role.UpdatedAt = time.Now() + if role.CreatedAt.IsZero() { + role.CreatedAt = role.UpdatedAt + } + + m.roles[role.ID] = role + return m.saveRoles() +} + +// DeleteRole removes a role by ID. +func (m *FileManager) DeleteRole(id string) error { + m.mu.Lock() + defer m.mu.Unlock() + + role, ok := m.roles[id] + if !ok { + return nil // Already deleted + } + + if role.IsBuiltIn { + return fmt.Errorf("cannot delete built-in role: %s", id) + } + + delete(m.roles, id) + return m.saveRoles() +} + +// GetUserAssignments returns all user role assignments. +func (m *FileManager) GetUserAssignments() []UserRoleAssignment { + m.mu.RLock() + defer m.mu.RUnlock() + + assignments := make([]UserRoleAssignment, 0, len(m.assignments)) + for _, a := range m.assignments { + assignments = append(assignments, a) + } + return assignments +} + +// GetUserAssignment returns the role assignment for a user. +func (m *FileManager) GetUserAssignment(username string) (UserRoleAssignment, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + + a, ok := m.assignments[username] + return a, ok +} + +// AssignRole adds a role to a user. +func (m *FileManager) AssignRole(username string, roleID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + // Verify role exists + if _, ok := m.roles[roleID]; !ok { + return fmt.Errorf("role not found: %s", roleID) + } + + a, ok := m.assignments[username] + if !ok { + a = UserRoleAssignment{ + Username: username, + RoleIDs: []string{}, + } + } + + // Check if already assigned + for _, id := range a.RoleIDs { + if id == roleID { + return nil // Already assigned + } + } + + a.RoleIDs = append(a.RoleIDs, roleID) + a.UpdatedAt = time.Now() + m.assignments[username] = a + + return m.saveAssignments() +} + +// UpdateUserRoles replaces all roles for a user. +func (m *FileManager) UpdateUserRoles(username string, roleIDs []string) error { + m.mu.Lock() + defer m.mu.Unlock() + + // Verify all roles exist + for _, roleID := range roleIDs { + if _, ok := m.roles[roleID]; !ok { + return fmt.Errorf("role not found: %s", roleID) + } + } + + m.assignments[username] = UserRoleAssignment{ + Username: username, + RoleIDs: roleIDs, + UpdatedAt: time.Now(), + } + + return m.saveAssignments() +} + +// RemoveRole removes a role from a user. +func (m *FileManager) RemoveRole(username string, roleID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + a, ok := m.assignments[username] + if !ok { + return nil // No assignment exists + } + + newRoles := make([]string, 0, len(a.RoleIDs)) + for _, id := range a.RoleIDs { + if id != roleID { + newRoles = append(newRoles, id) + } + } + + a.RoleIDs = newRoles + a.UpdatedAt = time.Now() + m.assignments[username] = a + + return m.saveAssignments() +} + +// GetUserPermissions returns the effective permissions for a user. +func (m *FileManager) GetUserPermissions(username string) []Permission { + m.mu.RLock() + defer m.mu.RUnlock() + + a, ok := m.assignments[username] + if !ok { + return nil + } + + // Collect unique permissions from all assigned roles + permMap := make(map[string]Permission) + for _, roleID := range a.RoleIDs { + if role, ok := m.roles[roleID]; ok { + for _, perm := range role.Permissions { + key := perm.Action + ":" + perm.Resource + permMap[key] = perm + } + } + } + + perms := make([]Permission, 0, len(permMap)) + for _, perm := range permMap { + perms = append(perms, perm) + } + return perms +} diff --git a/pkg/auth/rbac_manager_test.go b/pkg/auth/rbac_manager_test.go new file mode 100644 index 000000000..17651d227 --- /dev/null +++ b/pkg/auth/rbac_manager_test.go @@ -0,0 +1,174 @@ +package auth + +import ( + "os" + "testing" +) + +func TestFileManager(t *testing.T) { + // Create temp directory for tests + tmpDir, err := os.MkdirTemp("", "rbac-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + m, err := NewFileManager(tmpDir) + if err != nil { + t.Fatalf("Failed to create FileManager: %v", err) + } + + t.Run("Built-in roles exist", func(t *testing.T) { + roles := m.GetRoles() + if len(roles) < 4 { + t.Errorf("Expected at least 4 built-in roles, got %d", len(roles)) + } + + // Check admin role exists + admin, ok := m.GetRole(RoleAdmin) + if !ok { + t.Error("Admin role not found") + } + if !admin.IsBuiltIn { + t.Error("Admin role should be built-in") + } + }) + + t.Run("Cannot delete built-in role", func(t *testing.T) { + err := m.DeleteRole(RoleAdmin) + if err == nil { + t.Error("Expected error when deleting built-in role") + } + }) + + t.Run("Cannot modify built-in role", func(t *testing.T) { + admin, _ := m.GetRole(RoleAdmin) + admin.Name = "Modified Admin" + err := m.SaveRole(admin) + if err == nil { + t.Error("Expected error when modifying built-in role") + } + }) + + t.Run("Create custom role", func(t *testing.T) { + customRole := Role{ + ID: "custom", + Name: "Custom Role", + Description: "A custom test role", + Permissions: []Permission{{Action: "read", Resource: "nodes"}}, + } + if err := m.SaveRole(customRole); err != nil { + t.Errorf("Failed to save custom role: %v", err) + } + + retrieved, ok := m.GetRole("custom") + if !ok { + t.Error("Custom role not found after save") + } + if retrieved.Name != "Custom Role" { + t.Errorf("Expected name 'Custom Role', got '%s'", retrieved.Name) + } + }) + + t.Run("Delete custom role", func(t *testing.T) { + if err := m.DeleteRole("custom"); err != nil { + t.Errorf("Failed to delete custom role: %v", err) + } + + _, ok := m.GetRole("custom") + if ok { + t.Error("Custom role should not exist after delete") + } + }) + + t.Run("Assign role to user", func(t *testing.T) { + if err := m.AssignRole("testuser", RoleViewer); err != nil { + t.Errorf("Failed to assign role: %v", err) + } + + assignment, ok := m.GetUserAssignment("testuser") + if !ok { + t.Error("User assignment not found") + } + if len(assignment.RoleIDs) != 1 || assignment.RoleIDs[0] != RoleViewer { + t.Errorf("Expected viewer role, got %v", assignment.RoleIDs) + } + }) + + t.Run("Get user permissions", func(t *testing.T) { + perms := m.GetUserPermissions("testuser") + if len(perms) == 0 { + t.Error("Expected permissions for user with viewer role") + } + + hasRead := false + for _, p := range perms { + if p.Action == "read" { + hasRead = true + break + } + } + if !hasRead { + t.Error("Viewer role should have read permissions") + } + }) + + t.Run("Update user roles", func(t *testing.T) { + if err := m.UpdateUserRoles("testuser", []string{RoleAdmin, RoleOperator}); err != nil { + t.Errorf("Failed to update user roles: %v", err) + } + + assignment, _ := m.GetUserAssignment("testuser") + if len(assignment.RoleIDs) != 2 { + t.Errorf("Expected 2 roles, got %d", len(assignment.RoleIDs)) + } + }) + + t.Run("Remove role from user", func(t *testing.T) { + if err := m.RemoveRole("testuser", RoleOperator); err != nil { + t.Errorf("Failed to remove role: %v", err) + } + + assignment, _ := m.GetUserAssignment("testuser") + if len(assignment.RoleIDs) != 1 { + t.Errorf("Expected 1 role after removal, got %d", len(assignment.RoleIDs)) + } + }) + + t.Run("Persistence across reload", func(t *testing.T) { + // Save a custom role + customRole := Role{ + ID: "persistent", + Name: "Persistent Role", + Description: "Should survive reload", + Permissions: []Permission{{Action: "write", Resource: "alerts"}}, + } + if err := m.SaveRole(customRole); err != nil { + t.Fatalf("Failed to save role: %v", err) + } + + // Create new manager with same data dir + m2, err := NewFileManager(tmpDir) + if err != nil { + t.Fatalf("Failed to create second FileManager: %v", err) + } + + // Check custom role persisted + retrieved, ok := m2.GetRole("persistent") + if !ok { + t.Error("Custom role should persist across reload") + } + if retrieved.Name != "Persistent Role" { + t.Errorf("Expected 'Persistent Role', got '%s'", retrieved.Name) + } + + // Check user assignment persisted + assignment, ok := m2.GetUserAssignment("testuser") + if !ok { + t.Error("User assignment should persist across reload") + } + if len(assignment.RoleIDs) == 0 { + t.Error("User should still have roles after reload") + } + }) +}