diff --git a/VERSION b/VERSION index 3c1fccd5e..8bd1ab9e0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.1.32 +5.1.33 diff --git a/frontend-modern/src/components/Settings/RolesPanel.tsx b/frontend-modern/src/components/Settings/RolesPanel.tsx index 87f4477f3..15ae9b132 100644 --- a/frontend-modern/src/components/Settings/RolesPanel.tsx +++ b/frontend-modern/src/components/Settings/RolesPanel.tsx @@ -1,6 +1,8 @@ import { Component, createSignal, onMount, Show, For } from 'solid-js'; import { Card } from '@/components/shared/Card'; +import { Toggle } from '@/components/shared/Toggle'; import { RBACAPI } from '@/api/rbac'; +import { SettingsAPI } from '@/api/settings'; import type { Role, Permission } from '@/types/rbac'; import { notificationStore } from '@/stores/notifications'; import { logger } from '@/utils/logger'; @@ -13,7 +15,7 @@ import BadgeCheck from 'lucide-solid/icons/badge-check'; import X from 'lucide-solid/icons/x'; const ACTIONS = ['read', 'write', 'delete', 'admin', '*']; -const RESOURCES = ['settings', 'audit_logs', 'nodes', 'users', 'license', '*']; +const RESOURCES = ['settings', 'audit_logs', 'nodes', 'users', 'license', 'ai', 'discovery', '*']; export const RolesPanel: Component = () => { const [roles, setRoles] = createSignal([]); @@ -22,6 +24,11 @@ export const RolesPanel: Component = () => { const [editingRole, setEditingRole] = createSignal(null); const [saving, setSaving] = createSignal(false); + // RBAC enforcement toggle state + const [enforcementEnabled, setEnforcementEnabled] = createSignal(false); + const [enforcementLoaded, setEnforcementLoaded] = createSignal(false); + const [enforcementSaving, setEnforcementSaving] = createSignal(false); + // Form state const [formId, setFormId] = createSignal(''); const [formName, setFormName] = createSignal(''); @@ -41,9 +48,41 @@ export const RolesPanel: Component = () => { } }; + const loadEnforcement = async () => { + try { + const settings = await SettingsAPI.getSystemSettings(); + setEnforcementEnabled(Boolean(settings.rbacEnforcementEnabled)); + } catch (err) { + logger.error('Failed to load RBAC enforcement setting', err); + } finally { + setEnforcementLoaded(true); + } + }; + + const handleToggleEnforcement = async () => { + if (enforcementSaving()) return; + const next = !enforcementEnabled(); + setEnforcementSaving(true); + try { + await SettingsAPI.updateSystemSettings({ rbacEnforcementEnabled: next }); + setEnforcementEnabled(next); + notificationStore.success( + next + ? 'RBAC enforcement enabled. Users are now restricted to their assigned roles.' + : 'RBAC enforcement disabled. All authenticated users have full access.', + ); + } catch (err) { + logger.error('Failed to update RBAC enforcement setting', err); + notificationStore.error('Failed to update RBAC enforcement setting'); + } finally { + setEnforcementSaving(false); + } + }; + onMount(() => { loadLicenseStatus(); loadRoles(); + loadEnforcement(); }); const handleCreate = () => { @@ -168,6 +207,28 @@ export const RolesPanel: Component = () => { + +
+ + Enforce role-based access + + } + description="When off, every authenticated user has full access and the roles below are not applied." + /> +

+ Warning: if you sign in with SSO and do not have a local admin account, map at + least one SSO group to a full-access role before enabling, or you may lock + yourself out. To recover, set PULSE_RBAC_ENFORCEMENT=false and + restart Pulse. +

+
+
+
diff --git a/frontend-modern/src/types/config.ts b/frontend-modern/src/types/config.ts index 9b4714503..35429fb87 100644 --- a/frontend-modern/src/types/config.ts +++ b/frontend-modern/src/types/config.ts @@ -35,6 +35,7 @@ export interface SystemConfig { backupPollingInterval?: number; // Backup polling interval in seconds (0 = default cadence) backupPollingEnabled?: boolean; // Enable backup polling of PVE/PBS data temperatureMonitoringEnabled?: boolean; // Collect CPU/NVMe temperatures via SSH + rbacEnforcementEnabled?: boolean; // Enforce role-based access control (Pro feature, default off) sshPort?: number; // SSH port for temperature monitoring (default: 22) allowedOrigins?: string; // CORS allowed origins frontendPort?: number; // Frontend UI port (default: 7655) diff --git a/internal/api/auth.go b/internal/api/auth.go index f1eaab4c1..6b0e06065 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -743,12 +743,7 @@ func RequirePermission(cfg *config.Config, authorizer auth.Authorizer, action, r } } - // Extract user from header (set by CheckAuth) and inject into context - username := w.Header().Get("X-Authenticated-User") - ctx := r.Context() - if username != "" { - ctx = internalauth.WithUser(ctx, username) - } + ctx, username := authenticatedContextForAuthorization(w, r) // Check permission via authorizer allowed, err := authorizer.Authorize(ctx, action, resource) @@ -803,6 +798,47 @@ func RequireScope(scope string, handler http.HandlerFunc) http.HandlerFunc { } } +func authenticatedContextForAuthorization(w http.ResponseWriter, r *http.Request) (context.Context, string) { + ctx := r.Context() + username := internalauth.GetUser(ctx) + if username != "" { + return ctx, username + } + + // Defensive compatibility for auth paths that set only the response header. + // The router's auth context middleware should normally attach identity to + // the request context before authorization runs. + if headerUser := w.Header().Get("X-Authenticated-User"); headerUser != "" { + log.Warn(). + Str("user", headerUser). + Str("path", r.URL.Path). + Msg("Authenticated user found only in response header; upstream auth path should attach request context") + ctx = internalauth.WithUser(ctx, headerUser) + return ctx, headerUser + } + + if authMethod := w.Header().Get("X-Auth-Method"); authMethod == "none" { + ctx = internalauth.WithUser(ctx, "anonymous") + return ctx, "anonymous" + } + + return ctx, "" +} + +func respondForbiddenRBAC(w http.ResponseWriter, action string, resource string) { + if w == nil { + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "forbidden", + "message": "You do not have permission to perform this action", + "action": action, + "resource": resource, + }) +} + func respondMissingScope(w http.ResponseWriter, scope string) { if w == nil { return @@ -823,10 +859,48 @@ func ensureScope(w http.ResponseWriter, r *http.Request, scope string) bool { return true } record := getAPITokenRecordFromRequest(r) - if record == nil || record.HasScope(scope) { + if record != nil { + if record.HasScope(scope) { + return true + } + respondMissingScope(w, scope) + return false + } + + action, resource := rbacPermissionForScope(scope) + ctx, username := authenticatedContextForAuthorization(w, r) + allowed, err := auth.GetAuthorizer().Authorize(ctx, action, resource) + if err != nil { + log.Error(). + Err(err). + Str("user", username). + Str("scope", scope). + Str("action", action). + Str("resource", resource). + Msg("RBAC scope authorization failed due to system error") + if strings.HasPrefix(r.URL.Path, "/api/") || strings.Contains(r.Header.Get("Accept"), "application/json") { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error":"internal_error","message":"Failed to verify permissions"}`)) + } else { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } + return false + } + if allowed { return true } - respondMissingScope(w, scope) + + log.Warn(). + Str("user", username). + Str("ip", r.RemoteAddr). + Str("path", r.URL.Path). + Str("scope", scope). + Str("action", action). + Str("resource", resource). + Msg("Forbidden access attempt (RBAC scope)") + + respondForbiddenRBAC(w, action, resource) return false } diff --git a/internal/api/rbac_enforcement.go b/internal/api/rbac_enforcement.go new file mode 100644 index 000000000..faaf9b06d --- /dev/null +++ b/internal/api/rbac_enforcement.go @@ -0,0 +1,126 @@ +package api + +import ( + "context" + "os" + "strconv" + "strings" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/license" + "github.com/rcourtman/pulse-go-rewrite/pkg/auth" + "github.com/rs/zerolog/log" +) + +// rbacEnforcementEnvVar is the break-glass override for RBAC enforcement. It is +// read once at startup. Setting it to a falsey value (e.g. "false") and +// restarting force-disables enforcement regardless of the stored setting, so an +// operator who locks themselves out of an SSO-only deployment can always +// recover. A truthy value force-enables it (still subject to the Pro license). +const rbacEnforcementEnvVar = "PULSE_RBAC_ENFORCEMENT" + +// gatedRBACAuthorizer wraps the real RBAC authorizer with the gating policy that +// keeps Pulse's historical behaviour intact unless enforcement is explicitly +// turned on. When enforcement is off it allows every action (the pre-existing +// DefaultAuthorizer behaviour), so upgrading never silently flips a running +// deployment to deny-by-default. +// +// Enforcement is active only when ALL of the following hold: +// - the operator opted in (config setting or the env override), and +// - the active license grants the RBAC feature (Pro and above). +// +// The wrapped RBACAuthorizer still grants the configured admin user full access, +// so a local admin can never lock themselves out of the toggle. +type gatedRBACAuthorizer struct { + cfg *config.Config + inner auth.Authorizer + envOverride *bool // nil = env var unset; non-nil = forced value from the env var +} + +// InstallRBACEnforcement registers the gated RBAC authorizer as the global +// authorizer. It must be called before the API router is constructed, because +// the router captures auth.GetAuthorizer() once at build time. +func InstallRBACEnforcement(cfg *config.Config, manager auth.Manager) { + if cfg == nil || manager == nil { + return + } + + var envOverride *bool + if raw := os.Getenv(rbacEnforcementEnvVar); raw != "" { + if v, err := strconv.ParseBool(raw); err == nil { + envOverride = &v + log.Info().Bool("enforce", v).Msgf("RBAC enforcement overridden by %s", rbacEnforcementEnvVar) + } else { + log.Warn().Str("value", raw).Msgf("Ignoring invalid %s (expected true/false)", rbacEnforcementEnvVar) + } + } + + auth.SetAuthorizer(&gatedRBACAuthorizer{ + cfg: cfg, + inner: auth.NewRBACAuthorizer(manager), + envOverride: envOverride, + }) + log.Info().Msg("RBAC authorizer installed (enforcement gated by license + setting)") +} + +// enforcing reports whether role enforcement should be applied for this request. +func (g *gatedRBACAuthorizer) enforcing(ctx context.Context) bool { + enabled := g.cfg.RBACEnforcementEnabled + if g.envOverride != nil { + enabled = *g.envOverride + } + if !enabled { + return false + } + + // License gate: never enforce without the Pro RBAC feature, so unlicensed + // (community) deployments are never subject to deny-by-default. + svc := getLicenseServiceForContext(ctx) + if svc == nil || !svc.HasFeature(license.FeatureRBAC) { + return false + } + return true +} + +// Authorize implements auth.Authorizer. +func (g *gatedRBACAuthorizer) Authorize(ctx context.Context, action string, resource string) (bool, error) { + if !g.enforcing(ctx) { + return true, nil + } + return g.inner.Authorize(ctx, action, resource) +} + +// SetAdminUser implements auth.AdminConfigurable by delegating to the wrapped +// authorizer so the configured admin retains full access when enforcement is on. +func (g *gatedRBACAuthorizer) SetAdminUser(username string) { + if configurable, ok := g.inner.(auth.AdminConfigurable); ok { + configurable.SetAdminUser(username) + } +} + +func rbacPermissionForScope(scope string) (string, string) { + switch strings.TrimSpace(scope) { + case config.ScopeMonitoringRead, config.ScopeHostConfigRead: + return auth.ActionRead, auth.ResourceNodes + case config.ScopeMonitoringWrite, + config.ScopeDockerReport, + config.ScopeDockerManage, + config.ScopeKubernetesReport, + config.ScopeKubernetesManage, + config.ScopeHostReport, + config.ScopeHostManage: + return auth.ActionWrite, auth.ResourceNodes + case config.ScopeSettingsRead: + return auth.ActionRead, auth.ResourceSettings + case config.ScopeSettingsWrite: + return auth.ActionWrite, auth.ResourceSettings + case config.ScopeAIChat: + return auth.ActionRead, auth.ResourceAI + case config.ScopeAIExecute, config.ScopeAgentExec: + return auth.ActionWrite, auth.ResourceAI + case config.ScopeWildcard: + return auth.ActionAdmin, "*" + default: + return auth.ActionAdmin, "*" + } +} diff --git a/internal/api/rbac_enforcement_test.go b/internal/api/rbac_enforcement_test.go new file mode 100644 index 000000000..be2c2f9c4 --- /dev/null +++ b/internal/api/rbac_enforcement_test.go @@ -0,0 +1,223 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/license" + "github.com/rcourtman/pulse-go-rewrite/pkg/auth" +) + +// denyAllAuthorizer always denies, so tests can prove the gate short-circuits to +// allow-all (returns true) WITHOUT consulting the wrapped authorizer. +type denyAllAuthorizer struct{} + +func (denyAllAuthorizer) Authorize(_ context.Context, _ string, _ string) (bool, error) { + return false, nil +} + +type fakeLicenseProvider struct { + svc *license.Service +} + +func (f fakeLicenseProvider) Service(_ context.Context) *license.Service { + return f.svc +} + +func withLicenseProvider(t *testing.T, provider LicenseServiceProvider) { + t.Helper() + licenseServiceMu.Lock() + prev := licenseServiceProvider + licenseServiceMu.Unlock() + SetLicenseServiceProvider(provider) + t.Cleanup(func() { + licenseServiceMu.Lock() + licenseServiceProvider = prev + licenseServiceMu.Unlock() + }) +} + +func TestGatedRBACAuthorizer_AllowsAllWhenDisabled(t *testing.T) { + g := &gatedRBACAuthorizer{ + cfg: &config.Config{RBACEnforcementEnabled: false}, + inner: denyAllAuthorizer{}, + } + ok, err := g.Authorize(context.Background(), auth.ActionRead, auth.ResourceNodes) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected allow-all when enforcement is disabled, got deny") + } +} + +func TestGatedRBACAuthorizer_EnvOverrideForcesDisabled(t *testing.T) { + disabled := false + g := &gatedRBACAuthorizer{ + cfg: &config.Config{RBACEnforcementEnabled: true}, // setting on... + inner: denyAllAuthorizer{}, + envOverride: &disabled, // ...but env break-glass wins + } + ok, err := g.Authorize(context.Background(), auth.ActionRead, auth.ResourceNodes) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected env override to force allow-all, got deny") + } +} + +func TestGatedRBACAuthorizer_NoLicenseFailsOpen(t *testing.T) { + // A service with no activated license grants only free-tier features, so the + // RBAC feature gate must keep enforcement off (allow-all) even when the + // operator turned the setting on. + withLicenseProvider(t, fakeLicenseProvider{svc: license.NewService()}) + + g := &gatedRBACAuthorizer{ + cfg: &config.Config{RBACEnforcementEnabled: true}, + inner: denyAllAuthorizer{}, + } + ok, err := g.Authorize(context.Background(), auth.ActionRead, auth.ResourceNodes) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected allow-all without an RBAC-licensed service, got deny") + } +} + +// TestGatedRBACAuthorizer_EndToEndEnforcement drives the real enforcement path +// (gate -> RBACAuthorizer -> PolicyEvaluator -> FileManager) with a genuine +// read-only role, proving that the configured admin bypasses, an assigned +// read-only user is allowed reads but denied writes, and an unassigned user is +// denied. This is the behaviour a v5 Pro customer like Luis expects. +func TestGatedRBACAuthorizer_EndToEndEnforcement(t *testing.T) { + t.Setenv("PULSE_DEV", "true") // grant the RBAC feature without a signed license + withLicenseProvider(t, fakeLicenseProvider{svc: license.NewService()}) + + mgr, err := auth.NewFileManager(t.TempDir()) + if err != nil { + t.Fatalf("NewFileManager: %v", err) + } + if err := mgr.SaveRole(auth.Role{ + ID: "nodes-reader", + Name: "Nodes Reader", + Permissions: []auth.Permission{{Action: auth.ActionRead, Resource: auth.ResourceNodes, Effect: auth.EffectAllow}}, + }); err != nil { + t.Fatalf("SaveRole: %v", err) + } + if err := mgr.UpdateUserRoles("viewer-user", []string{"nodes-reader"}); err != nil { + t.Fatalf("UpdateUserRoles: %v", err) + } + + g := &gatedRBACAuthorizer{ + cfg: &config.Config{RBACEnforcementEnabled: true}, + inner: auth.NewRBACAuthorizer(mgr), + } + g.SetAdminUser("admin") // mirrors router.go calling auth.SetAdminUser(cfg.AuthUser) + + cases := []struct { + name string + user string + action string + resource string + want bool + }{ + {"admin bypasses on write", "admin", auth.ActionAdmin, auth.ResourceSettings, true}, + {"viewer allowed read nodes", "viewer-user", auth.ActionRead, auth.ResourceNodes, true}, + {"viewer denied write nodes", "viewer-user", "write", auth.ResourceNodes, false}, + {"viewer denied other resource", "viewer-user", auth.ActionRead, auth.ResourceSettings, false}, + {"unassigned user denied", "ghost", auth.ActionRead, auth.ResourceNodes, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx := auth.WithUser(context.Background(), tc.user) + got, err := g.Authorize(ctx, tc.action, tc.resource) + if err != nil { + t.Fatalf("Authorize: %v", err) + } + if got != tc.want { + t.Fatalf("Authorize(%s, %s, %s) = %v, want %v", tc.user, tc.action, tc.resource, got, tc.want) + } + }) + } +} + +func TestGatedRBACAuthorizer_EnforcesWhenLicensedAndEnabled(t *testing.T) { + // Dev mode makes HasFeature return true for all Pro features, standing in for + // an RBAC-licensed deployment without needing a signed license key. + t.Setenv("PULSE_DEV", "true") + withLicenseProvider(t, fakeLicenseProvider{svc: license.NewService()}) + + g := &gatedRBACAuthorizer{ + cfg: &config.Config{RBACEnforcementEnabled: true}, + inner: denyAllAuthorizer{}, + } + ok, err := g.Authorize(context.Background(), auth.ActionRead, auth.ResourceNodes) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok { + t.Fatal("expected enforcement to delegate to the (deny-all) inner authorizer, got allow") + } +} + +func TestEnsureScope_EnforcesRBACForSessionUsers(t *testing.T) { + t.Setenv("PULSE_DEV", "true") // grant the RBAC feature without a signed license + withLicenseProvider(t, fakeLicenseProvider{svc: license.NewService()}) + + mgr, err := auth.NewFileManager(t.TempDir()) + if err != nil { + t.Fatalf("NewFileManager: %v", err) + } + if err := mgr.UpdateUserRoles("viewer-user", []string{auth.RoleViewer}); err != nil { + t.Fatalf("UpdateUserRoles: %v", err) + } + + prevAuthorizer := auth.GetAuthorizer() + auth.SetAuthorizer(&gatedRBACAuthorizer{ + cfg: &config.Config{RBACEnforcementEnabled: true}, + inner: auth.NewRBACAuthorizer(mgr), + }) + t.Cleanup(func() { + auth.SetAuthorizer(prevAuthorizer) + }) + + readReq := httptest.NewRequest(http.MethodGet, "/api/charts", nil) + readReq = readReq.WithContext(auth.WithUser(readReq.Context(), "viewer-user")) + readRec := httptest.NewRecorder() + if !ensureScope(readRec, readReq, config.ScopeMonitoringRead) { + t.Fatalf("expected viewer to pass monitoring read scope, got %d", readRec.Code) + } + + writeReq := httptest.NewRequest(http.MethodPost, "/api/system/settings/update", nil) + writeReq = writeReq.WithContext(auth.WithUser(writeReq.Context(), "viewer-user")) + writeRec := httptest.NewRecorder() + if ensureScope(writeRec, writeReq, config.ScopeSettingsWrite) { + t.Fatal("expected viewer to fail settings write scope") + } + if writeRec.Code != http.StatusForbidden { + t.Fatalf("expected 403 for denied RBAC scope, got %d", writeRec.Code) + } +} + +func TestEnsureScope_AllowsSessionUsersWhenRBACDisabled(t *testing.T) { + prevAuthorizer := auth.GetAuthorizer() + auth.SetAuthorizer(&gatedRBACAuthorizer{ + cfg: &config.Config{RBACEnforcementEnabled: false}, + inner: denyAllAuthorizer{}, + }) + t.Cleanup(func() { + auth.SetAuthorizer(prevAuthorizer) + }) + + req := httptest.NewRequest(http.MethodPost, "/api/system/settings/update", nil) + req = req.WithContext(auth.WithUser(req.Context(), "viewer-user")) + rec := httptest.NewRecorder() + if !ensureScope(rec, req, config.ScopeSettingsWrite) { + t.Fatalf("expected disabled RBAC gate to preserve legacy session access, got %d", rec.Code) + } +} diff --git a/internal/api/system_settings.go b/internal/api/system_settings.go index 2faae0e1d..5c8226348 100644 --- a/internal/api/system_settings.go +++ b/internal/api/system_settings.go @@ -249,6 +249,12 @@ func validateSystemSettings(_ *config.SystemSettings, rawRequest map[string]inte } } + if val, ok := rawRequest["rbacEnforcementEnabled"]; ok { + if _, ok := val.(bool); !ok { + return fmt.Errorf("rbacEnforcementEnabled must be a boolean") + } + } + // Validate auto-update check interval (min 1 hour, max 7 days) if val, ok := rawRequest["autoUpdateCheckInterval"]; ok { if interval, ok := val.(float64); ok { @@ -437,6 +443,7 @@ func (h *SystemSettingsHandler) HandleGetSystemSettings(w http.ResponseWriter, r settings.BackupPollingEnabled = &enabled settings.DiscoveryConfig = config.CloneDiscoveryConfig(h.config.Discovery) settings.TemperatureMonitoringEnabled = h.config.TemperatureMonitoringEnabled + settings.RBACEnforcementEnabled = h.config.RBACEnforcementEnabled // Expose Docker update actions setting (respects env override) settings.DisableDockerUpdateActions = h.config.DisableDockerUpdateActions // Expose public URL so the frontend can display it when env-overridden @@ -653,6 +660,10 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter settings.DisableDockerUpdateActions = updates.DisableDockerUpdateActions h.config.DisableDockerUpdateActions = settings.DisableDockerUpdateActions } + if _, ok := rawRequest["rbacEnforcementEnabled"]; ok { + settings.RBACEnforcementEnabled = updates.RBACEnforcementEnabled + h.config.RBACEnforcementEnabled = settings.RBACEnforcementEnabled + } if _, ok := rawRequest["fullWidthMode"]; ok { settings.FullWidthMode = updates.FullWidthMode } diff --git a/internal/config/config.go b/internal/config/config.go index 71fe8cd9e..6fe3c9450 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -105,6 +105,7 @@ type Config struct { BackupPollingInterval time.Duration `envconfig:"BACKUP_POLLING_INTERVAL"` EnableBackupPolling bool `envconfig:"ENABLE_BACKUP_POLLING" default:"true"` TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"` + RBACEnforcementEnabled bool `json:"rbacEnforcementEnabled"` // Enforce role-based access (Pro feature, default off; see PULSE_RBAC_ENFORCEMENT escape hatch) WebhookBatchDelay time.Duration `envconfig:"WEBHOOK_BATCH_DELAY" default:"10s"` AdaptivePollingEnabled bool `envconfig:"ADAPTIVE_POLLING_ENABLED" default:"false"` AdaptivePollingBaseInterval time.Duration `envconfig:"ADAPTIVE_POLLING_BASE_INTERVAL" default:"10s"` @@ -741,6 +742,7 @@ func Load() (*Config, error) { } cfg.Discovery = NormalizeDiscoveryConfig(CloneDiscoveryConfig(systemSettings.DiscoveryConfig)) cfg.TemperatureMonitoringEnabled = systemSettings.TemperatureMonitoringEnabled + cfg.RBACEnforcementEnabled = systemSettings.RBACEnforcementEnabled // Load DNS cache timeout if systemSettings.DNSCacheTimeout > 0 { cfg.DNSCacheTimeout = time.Duration(systemSettings.DNSCacheTimeout) * time.Second diff --git a/internal/config/persistence.go b/internal/config/persistence.go index 21b559944..3f9872b00 100644 --- a/internal/config/persistence.go +++ b/internal/config/persistence.go @@ -1169,6 +1169,7 @@ type SystemSettings struct { AllowEmbedding bool `json:"allowEmbedding"` // Allow iframe embedding AllowedEmbedOrigins string `json:"allowedEmbedOrigins,omitempty"` // Comma-separated list of allowed origins for embedding TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"` + RBACEnforcementEnabled bool `json:"rbacEnforcementEnabled"` // Enforce role-based access control (Pro feature, default off) DNSCacheTimeout int `json:"dnsCacheTimeout,omitempty"` // DNS cache timeout in seconds (0 = default 5 minutes) SSHPort int `json:"sshPort,omitempty"` // Default SSH port for temperature monitoring (0 = use 22) WebhookAllowedPrivateCIDRs string `json:"webhookAllowedPrivateCIDRs,omitempty"` // Comma-separated list of private CIDR ranges allowed for webhooks (e.g., "192.168.1.0/24,10.0.0.0/8") diff --git a/pkg/auth/policy_evaluator_test.go b/pkg/auth/policy_evaluator_test.go index 65eec4c84..ca0284d0d 100644 --- a/pkg/auth/policy_evaluator_test.go +++ b/pkg/auth/policy_evaluator_test.go @@ -335,6 +335,18 @@ func TestRBACAuthorizer(t *testing.T) { }) } +func TestMatchesActionWildcard(t *testing.T) { + if !MatchesAction("*", ActionDelete) { + t.Fatal("expected wildcard action to match delete") + } + if !MatchesAction(ActionAdmin, ActionWrite) { + t.Fatal("expected admin action to match write") + } + if MatchesAction(ActionRead, ActionWrite) { + t.Fatal("expected read action not to match write") + } +} + func TestMatchesResource(t *testing.T) { tests := []struct { pattern string diff --git a/pkg/auth/rbac.go b/pkg/auth/rbac.go index fda0a4369..a72785e6f 100644 --- a/pkg/auth/rbac.go +++ b/pkg/auth/rbac.go @@ -189,9 +189,9 @@ func MatchesResource(pattern, resource string) bool { } // MatchesAction checks if a permission's action matches a requested action. -// "admin" action matches any action. +// "admin" and "*" actions match any action. func MatchesAction(permAction, requestedAction string) bool { - if permAction == "admin" { + if permAction == "*" || permAction == ActionAdmin { return true } return permAction == requestedAction diff --git a/pkg/server/server.go b/pkg/server/server.go index 6a44b02c1..6eaa2be3a 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -103,6 +103,12 @@ func Run(ctx context.Context, version string) error { } else { auth.SetManager(rbacManager) log.Info().Msg("RBAC manager initialized") + + // Register the gated RBAC authorizer before the API router is built (the + // router captures the global authorizer once at construction). Enforcement + // stays off unless the operator opts in AND the license grants RBAC, so + // this is a no-op for community and un-opted-in deployments. + api.InstallRBACEnforcement(cfg, rbacManager) } // Run multi-tenant data migration only when the feature is explicitly enabled.