mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 05:29:45 +00:00
dc2057a08c
ensureAdminSession, the session check behind RequireAdmin and the settings scope helpers, admitted anyone who could manage the organization named in the request. That was meant for the org-bound settings pages, but it applied to every admin route. On a multi-tenant install an organization owner could therefore rotate the instance administrator's API token and receive its secret, widen any token's scopes, add or replace SSO providers, change system settings, relay and log level, apply or roll back updates, restart the service, and read instance logs and diagnostics. ensureAdminSession now admits only instance administrators: the configured admin or an instance RBAC admin grant, whichever org the request names. Routes whose handlers act only on the request organization opt in explicitly through RequireOrgAdmin, ensureOrgAdminSession or ensureOrgSettingsWriteScope, which also admit that organization's owners and admins. Those routes cover agents, nodes, connections, availability, TrueNAS and VMware, agent profiles, install commands, deploy status, configuration transfer and the org-bound relay mobile token. Anything that does not opt in fails closed. Reporting stays instance administration, and the security status snapshot stops offering it to org managers, because report generation still reads the instance's default metrics store. Notifications, LAN discovery and the action-capability fallback also stay instance administration. The two credential routes no longer need their own check, so ensureInstanceAdminSession is folded into this rule.
153 lines
5.0 KiB
Go
153 lines
5.0 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
type configTransferOperation string
|
|
|
|
const (
|
|
configTransferExport configTransferOperation = "export"
|
|
configTransferImport configTransferOperation = "import"
|
|
)
|
|
|
|
func (op configTransferOperation) requiredScope() string {
|
|
if op == configTransferImport {
|
|
return config.ScopeSettingsWrite
|
|
}
|
|
return config.ScopeSettingsRead
|
|
}
|
|
|
|
// configTransferAuthenticationConfigured is the canonical fail-closed view of
|
|
// whether configuration transfer must authenticate. It deliberately includes
|
|
// hosted operation and an uncertain SSO load: neither state may fall back to
|
|
// unauthenticated recovery.
|
|
func (r *Router) configTransferAuthenticationConfigured() bool {
|
|
if r == nil || r.config == nil {
|
|
return true
|
|
}
|
|
|
|
config.Mu.RLock()
|
|
localAuthConfigured := strings.TrimSpace(r.config.AuthUser) != "" || strings.TrimSpace(r.config.AuthPass) != ""
|
|
tokenAuthConfigured := r.config.HasAPITokens()
|
|
proxyAuthConfigured := strings.TrimSpace(r.config.ProxyAuthSecret) != ""
|
|
config.Mu.RUnlock()
|
|
|
|
return localAuthConfigured ||
|
|
tokenAuthConfigured ||
|
|
proxyAuthConfigured ||
|
|
hasEnabledSSOProvidersForAuth(r.config) ||
|
|
r.hostedMode ||
|
|
r.ssoAuthenticationLoadFailed()
|
|
}
|
|
|
|
func (r *Router) allowUnauthenticatedConfigTransfer(req *http.Request, op configTransferOperation) bool {
|
|
if r.configTransferAuthenticationConfigured() {
|
|
return false
|
|
}
|
|
if isDirectLoopbackRequest(req) {
|
|
return true
|
|
}
|
|
return op == configTransferExport && os.Getenv("ALLOW_UNPROTECTED_EXPORT") == "true"
|
|
}
|
|
|
|
// authorizeConfigTransfer is the single route-local boundary for export and
|
|
// import. It runs before either handler receives the request, so denial cannot
|
|
// parse an archive, read export persistence, write import state, or reload the
|
|
// runtime.
|
|
func (r *Router) authorizeConfigTransfer(w http.ResponseWriter, req *http.Request, op configTransferOperation) bool {
|
|
if adminBypassEnabled() {
|
|
return true
|
|
}
|
|
if r == nil || r.config == nil {
|
|
http.Error(w, "Configuration authorization unavailable", http.StatusServiceUnavailable)
|
|
return false
|
|
}
|
|
|
|
scope := op.requiredScope()
|
|
|
|
// Explicit API-token credentials take precedence over every browser
|
|
// credential. AuthContextMiddleware has already validated the token against
|
|
// the resolved tenant config, and TenantMiddleware has enforced its org
|
|
// binding before this route can run.
|
|
if _, provided := explicitAPITokenFromRequest(req); provided {
|
|
record := getAPITokenRecordFromRequest(req)
|
|
if record == nil {
|
|
http.Error(w, "Invalid API token", http.StatusUnauthorized)
|
|
return false
|
|
}
|
|
if !record.HasScope(scope) {
|
|
respondMissingScope(w, scope)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// A valid proxy identity is authoritative. Membership-like proxy roles are
|
|
// insufficient for a secret-bearing transfer.
|
|
if strings.TrimSpace(r.config.ProxyAuthSecret) != "" {
|
|
if valid, username, isAdmin := CheckProxyAuth(r.config, req); valid {
|
|
if !isAdmin {
|
|
logAuthDenial(req, username, "Non-admin proxy user attempted configuration transfer", nil)
|
|
http.Error(w, "Admin privileges required for configuration transfer", http.StatusForbidden)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
|
|
// A presented valid session must itself carry management authority. For a
|
|
// tenant this is CanUserIDManage on the resolved organization; for the
|
|
// default organization it is the canonical instance-admin rule.
|
|
if cookie, err := readSessionCookie(req); err == nil && cookie.Value != "" {
|
|
session := GetSessionStore().GetSession(cookie.Value)
|
|
validSession := session != nil && ValidateSession(cookie.Value)
|
|
if validSession && session.RecoveryBypass {
|
|
validSession = requestMatchesRecoverySession(req, session)
|
|
}
|
|
if validSession {
|
|
if !ensureOrgAdminSession(r.config, w, req) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
|
|
// HTTP Basic represents the configured instance administrator. Validate it
|
|
// directly so this decision cannot accidentally inherit no-auth fallback.
|
|
if username, password, ok := req.BasicAuth(); ok {
|
|
config.Mu.RLock()
|
|
configuredUser := r.config.AuthUser
|
|
configuredHash := r.config.AuthPass
|
|
config.Mu.RUnlock()
|
|
if configuredUser != "" && configuredHash != "" &&
|
|
constantTimeStringEqual(username, configuredUser) &&
|
|
internalauth.CheckPasswordHash(password, configuredHash) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
if r.allowUnauthenticatedConfigTransfer(req, op) {
|
|
return true
|
|
}
|
|
|
|
if r.configTransferAuthenticationConfigured() {
|
|
logAuthDenial(req, "", "Unauthenticated configuration transfer attempt", nil)
|
|
http.Error(w, "Unauthorized - please log in or provide an API token", http.StatusUnauthorized)
|
|
return false
|
|
}
|
|
|
|
log.Warn().
|
|
Str("ip", req.RemoteAddr).
|
|
Str("operation", string(op)).
|
|
Msg("Configuration transfer blocked outside direct loopback recovery policy")
|
|
http.Error(w, "Configuration transfer requires authentication outside direct loopback", http.StatusForbidden)
|
|
return false
|
|
}
|