mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
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
This commit is contained in:
parent
d6554a0d87
commit
55f5f071ed
20 changed files with 558 additions and 34 deletions
|
|
@ -1167,7 +1167,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||
AI Patrol monitors your infrastructure 24/7 and surfaces issues before they become outages.
|
||||
</p>
|
||||
<a
|
||||
href={patrolStatus()?.upgrade_url || 'https://pulse.sh/pro'}
|
||||
href={patrolStatus()?.upgrade_url || 'https://pulserelay.pro/'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1.5 mt-2.5 text-xs font-bold text-purple-600 dark:text-purple-400 hover:text-purple-700 dark:hover:text-purple-300 transition-colors group/link"
|
||||
|
|
|
|||
|
|
@ -215,7 +215,7 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (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 ?? []);
|
||||
|
||||
|
|
|
|||
|
|
@ -1186,7 +1186,7 @@ export const AISettings: Component = () => {
|
|||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
<a
|
||||
class="text-indigo-600 dark:text-indigo-400 font-medium hover:underline"
|
||||
href="https://pulse.sh/pro"
|
||||
href="https://pulserelay.pro/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
|
@ -1275,7 +1275,7 @@ export const AISettings: Component = () => {
|
|||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<a
|
||||
class="text-indigo-600 dark:text-indigo-400 font-medium hover:underline"
|
||||
href="https://pulse.sh/pro"
|
||||
href="https://pulserelay.pro/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
|
@ -1317,7 +1317,7 @@ export const AISettings: Component = () => {
|
|||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
<a
|
||||
class="text-indigo-600 dark:text-indigo-400 font-medium hover:underline"
|
||||
href="https://pulse.sh/pro"
|
||||
href="https://pulserelay.pro/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -260,7 +260,7 @@ export const AgentProfilesPanel: Component = () => {
|
|||
logging levels, and reporting intervals from a central location.
|
||||
</p>
|
||||
<a
|
||||
href="https://www.yourpulse.io/pro"
|
||||
href="https://pulserelay.pro/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-gradient-to-r from-amber-500 to-orange-500 px-4 py-2 text-sm font-medium text-white transition-all hover:from-amber-600 hover:to-orange-600"
|
||||
|
|
|
|||
|
|
@ -509,7 +509,7 @@ export default function AuditLogPanel() {
|
|||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="https://pulse.sh/pro"
|
||||
href="https://pulserelay.pro/"
|
||||
target="_blank"
|
||||
class="px-5 py-2.5 text-sm font-semibold bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -289,7 +289,7 @@ export const OIDCPanel: Component<Props> = (props) => {
|
|||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="https://pulse.sh/pro"
|
||||
href="https://pulserelay.pro/"
|
||||
target="_blank"
|
||||
class="px-5 py-2.5 text-sm font-semibold bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
free: 'Free',
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ export const RolesPanel: Component = () => {
|
|||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="https://pulse.sh/pro"
|
||||
href="https://pulserelay.pro/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="px-5 py-2.5 text-sm font-semibold bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ export const UserAssignmentsPanel: Component = () => {
|
|||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="https://pulse.sh/pro"
|
||||
href="https://pulserelay.pro/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="px-5 py-2.5 text-sm font-semibold bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { AIAPI } from '@/api/ai';
|
|||
import { logger } from '@/utils/logger';
|
||||
import type { RemediationRecord, RemediationStats } from '@/types/aiIntelligence';
|
||||
|
||||
const DEFAULT_UPGRADE_URL = 'https://pulse.sh/pro';
|
||||
const DEFAULT_UPGRADE_URL = 'https://pulserelay.pro/';
|
||||
|
||||
export const AIImpactTimelinePanel: Component<{ hours?: number; showWhenEmpty?: boolean }> = (props) => {
|
||||
const [remediations, setRemediations] = createSignal<RemediationRecord[]>([]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<InfrastructureChange[]>([]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
350
pkg/auth/rbac_manager.go
Normal file
350
pkg/auth/rbac_manager.go
Normal file
|
|
@ -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
|
||||
}
|
||||
174
pkg/auth/rbac_manager_test.go
Normal file
174
pkg/auth/rbac_manager_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue