mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 18:45:53 +00:00
3adeb77d60
Co-authored-by: Pulse Autonomous Maintainer <rcourtman@users.noreply.github.com>
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 !ensureAdminSession(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
|
|
}
|