mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 12:15:42 +00:00
Guard infrastructure import plan approvals
- add candidate import approval and preview gate for node onboarding - document metrics, availability, and proxy role behavior - lock config auth reads and preserve storage metrics fallback
This commit is contained in:
@@ -825,16 +825,16 @@ func checkAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request, write
|
||||
}
|
||||
return false
|
||||
}
|
||||
// Check username
|
||||
userMatch := constantTimeStringEqual(parts[0], cfg.AuthUser)
|
||||
|
||||
// Check password - support both hashed and plain text for migration
|
||||
// Config always has hashed password now (auto-hashed on load)
|
||||
passMatch := internalauth.CheckPasswordHash(parts[1], cfg.AuthPass)
|
||||
config.Mu.RLock()
|
||||
cfgAuthUser := cfg.AuthUser
|
||||
cfgAuthPass := cfg.AuthPass
|
||||
config.Mu.RUnlock()
|
||||
userMatch := constantTimeStringEqual(parts[0], cfgAuthUser)
|
||||
passMatch := internalauth.CheckPasswordHash(parts[1], cfgAuthPass)
|
||||
|
||||
log.Debug().
|
||||
Str("provided_user", parts[0]).
|
||||
Str("expected_user", cfg.AuthUser).
|
||||
Str("expected_user", cfgAuthUser).
|
||||
Bool("user_match", userMatch).
|
||||
Bool("pass_match", passMatch).
|
||||
Msg("Auth check")
|
||||
|
||||
@@ -19622,3 +19622,36 @@ func TestContract_ProxyAuthConfiguredRoleHeaderMissingIsNonAdmin(t *testing.T) {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContract_LocalAuthConfigReadsUseSharedLock pins the shared mutable-config
|
||||
// boundary for local username/password checks. Auth handlers may snapshot
|
||||
// credentials under the config lock, but must not read mutable auth fields
|
||||
// directly while hashing passwords or mutating sessions.
|
||||
func TestContract_LocalAuthConfigReadsUseSharedLock(t *testing.T) {
|
||||
expectations := map[string][]string{
|
||||
"auth.go": {
|
||||
"config.Mu.RLock()\n\t\t\t\t\t\tcfgAuthUser := cfg.AuthUser\n\t\t\t\t\t\tcfgAuthPass := cfg.AuthPass\n\t\t\t\t\t\tconfig.Mu.RUnlock()",
|
||||
"constantTimeStringEqual(parts[0], cfgAuthUser)",
|
||||
"internalauth.CheckPasswordHash(parts[1], cfgAuthPass)",
|
||||
},
|
||||
"router.go": {
|
||||
"config.Mu.RLock()\n\t\tauthConfigured := (r.config.AuthUser != \"\" && r.config.AuthPass != \"\")",
|
||||
"r.config.ProxyAuthSecret != \"\"\n\t\tconfig.Mu.RUnlock()",
|
||||
"cfgAuthUser := r.config.AuthUser\n\tcfgAuthPass := r.config.AuthPass",
|
||||
"constantTimeStringEqual(loginReq.Username, cfgAuthUser) && auth.CheckPasswordHash(loginReq.Password, cfgAuthPass)",
|
||||
},
|
||||
}
|
||||
|
||||
for file, snippets := range expectations {
|
||||
sourceBytes, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", file, err)
|
||||
}
|
||||
source := string(sourceBytes)
|
||||
for _, snippet := range snippets {
|
||||
if !strings.Contains(source, snippet) {
|
||||
t.Fatalf("%s must contain %q", file, snippet)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-6
@@ -4098,13 +4098,17 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
skipCSRF := false
|
||||
// Quick setup can run before auth exists. Keep bootstrap/recovery flows usable
|
||||
// without a prior session+CSRF pair, but enforce CSRF once auth is configured.
|
||||
config.Mu.RLock()
|
||||
authConfigured := (r.config.AuthUser != "" && r.config.AuthPass != "") ||
|
||||
r.config.HasAPITokens() ||
|
||||
r.config.ProxyAuthSecret != "" ||
|
||||
(func() bool {
|
||||
ssoCfg := r.ensureSSOConfig()
|
||||
return ssoCfg != nil && ssoCfg.HasEnabledProviders()
|
||||
})()
|
||||
r.config.ProxyAuthSecret != ""
|
||||
config.Mu.RUnlock()
|
||||
if !authConfigured {
|
||||
ssoCfg := r.ensureSSOConfig()
|
||||
if ssoCfg != nil && ssoCfg.HasEnabledProviders() {
|
||||
authConfigured = true
|
||||
}
|
||||
}
|
||||
validRecoveryToken := false
|
||||
if recoveryToken := strings.TrimSpace(req.Header.Get("X-Recovery-Token")); recoveryToken != "" {
|
||||
validRecoveryToken = GetRecoveryTokenStore().IsRecoveryTokenValidConstantTime(recoveryToken, clientIP)
|
||||
@@ -5103,7 +5107,11 @@ func (r *Router) handleLogin(w http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
|
||||
// Verify credentials (or accept any credentials when admin bypass is enabled)
|
||||
credentialsValid := constantTimeStringEqual(loginReq.Username, r.config.AuthUser) && auth.CheckPasswordHash(loginReq.Password, r.config.AuthPass)
|
||||
config.Mu.RLock()
|
||||
cfgAuthUser := r.config.AuthUser
|
||||
cfgAuthPass := r.config.AuthPass
|
||||
config.Mu.RUnlock()
|
||||
credentialsValid := constantTimeStringEqual(loginReq.Username, cfgAuthUser) && auth.CheckPasswordHash(loginReq.Password, cfgAuthPass)
|
||||
effectiveUsername := loginReq.Username
|
||||
if adminBypassEnabled() {
|
||||
credentialsValid = true
|
||||
|
||||
@@ -2232,6 +2232,24 @@ func TestReloadAndRuntimeContextStayOnCanonicalMonitoringPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnifiedStorageMetricsFallbackToResourceIDWhenResolverTargetMissing(t *testing.T) {
|
||||
data, err := os.ReadFile("monitor.go")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read monitor.go: %v", err)
|
||||
}
|
||||
source := string(data)
|
||||
for _, snippet := range []string{
|
||||
"target := resolver.MetricsTargetForResource(resource.ID)",
|
||||
"if target != nil && target.ResourceType == \"storage\" && strings.TrimSpace(target.ResourceID) != \"\" {",
|
||||
"targetID = strings.TrimSpace(target.ResourceID)",
|
||||
"} else {\n\t\t\ttargetID = resource.ID\n\t\t}",
|
||||
} {
|
||||
if !strings.Contains(source, snippet) {
|
||||
t.Fatalf("monitor.go must contain %q", snippet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPollersHonorDisabledInstanceFlag asserts that the PVE, PBS, and PMG
|
||||
// pollers skip instances whose `Disabled` flag is set, both at client
|
||||
// initialization/reconnect time and on every per-instance iteration of the
|
||||
|
||||
@@ -4813,10 +4813,12 @@ func (m *Monitor) syncUnifiedStorageMetrics(store ResourceStoreInterface) {
|
||||
}
|
||||
|
||||
target := resolver.MetricsTargetForResource(resource.ID)
|
||||
if target == nil || target.ResourceType != "storage" || strings.TrimSpace(target.ResourceID) == "" {
|
||||
continue
|
||||
var targetID string
|
||||
if target != nil && target.ResourceType == "storage" && strings.TrimSpace(target.ResourceID) != "" {
|
||||
targetID = strings.TrimSpace(target.ResourceID)
|
||||
} else {
|
||||
targetID = resource.ID
|
||||
}
|
||||
targetID := strings.TrimSpace(target.ResourceID)
|
||||
if _, ok := seenTargets[targetID]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user