Enable opt-in v5 RBAC enforcement

Release: v5.1.33
This commit is contained in:
rcourtman 2026-05-29 11:57:15 +01:00
parent ce1607694e
commit 9a9ec01b84
12 changed files with 529 additions and 12 deletions

View file

@ -1 +1 @@
5.1.32
5.1.33

View file

@ -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<Role[]>([]);
@ -22,6 +24,11 @@ export const RolesPanel: Component = () => {
const [editingRole, setEditingRole] = createSignal<Role | null>(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 = () => {
</div>
</Show>
<Show when={licenseLoaded() && hasFeature('rbac') && enforcementLoaded()}>
<div class="p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl space-y-3">
<Toggle
checked={enforcementEnabled()}
disabled={enforcementSaving()}
onToggle={handleToggleEnforcement}
label={
<span class="font-medium text-gray-900 dark:text-gray-100">
Enforce role-based access
</span>
}
description="When off, every authenticated user has full access and the roles below are not applied."
/>
<p class="text-xs text-amber-700 dark:text-amber-300">
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 <code>PULSE_RBAC_ENFORCEMENT=false</code> and
restart Pulse.
</p>
</div>
</Show>
<Show when={loading()}>
<div class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-500" />

View file

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

View file

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

View file

@ -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, "*"
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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