mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Enable opt-in v5 RBAC enforcement
Release: v5.1.33
This commit is contained in:
parent
ce1607694e
commit
9a9ec01b84
12 changed files with 529 additions and 12 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
5.1.32
|
||||
5.1.33
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
126
internal/api/rbac_enforcement.go
Normal file
126
internal/api/rbac_enforcement.go
Normal 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, "*"
|
||||
}
|
||||
}
|
||||
223
internal/api/rbac_enforcement_test.go
Normal file
223
internal/api/rbac_enforcement_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue