Files
pulse/internal/api/security_first_run_reset.go
T
pulse-triage[bot] 9e8cdde75c Keep first-run credential reset atomic
Commit restart-time token revocation before clearing live authentication or sessions. Restore auth environment files and remove the staged bootstrap credential when token persistence fails, preserving a usable retry path.

Contract-Neutral: hardens development reset failure handling without changing successful API payloads or extension contracts
2026-08-29 06:16:52 +01:00

118 lines
3.8 KiB
Go

package api
import (
"encoding/json"
"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 firstRunResetResponse struct {
BootstrapToken string `json:"bootstrapToken"`
BootstrapTokenPath string `json:"bootstrapTokenPath,omitempty"`
}
func devModeEnabled() bool {
return os.Getenv("PULSE_DEV") == "true" || strings.EqualFold(os.Getenv("NODE_ENV"), "development")
}
func (r *Router) handleResetFirstRunSecurity(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if !devModeEnabled() {
http.Error(w, "Development mode required", http.StatusForbidden)
return
}
if !CheckAuth(r.config, w, req) {
return
}
if !ensureSettingsWriteScope(r.config, w, req) {
return
}
// Prepare the recovery credential before changing any active authentication
// state. A failure here must leave the caller's current credentials usable so
// they can repair the data path and retry the reset.
token, bootstrapCreated, path, err := loadOrCreateBootstrapToken(r.config.DataPath)
if err != nil {
log.Error().Err(err).Msg("Failed to recreate bootstrap token during first-run reset")
http.Error(w, "Failed to recreate bootstrap token", http.StatusInternalServerError)
return
}
rollbackBootstrap := func() {
if !bootstrapCreated {
return
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
log.Error().Err(err).Str("token_path", path).Msg("Failed to roll back bootstrap token after first-run reset failure")
}
}
// Remove restart-time password configuration before committing token
// revocation. Runtime authentication remains unchanged until both durable
// operations succeed.
authEnvSnapshots, err := snapshotAuthEnvFiles(r.config.ConfigPath, r.config.DataPath)
if err != nil {
rollbackBootstrap()
log.Warn().Err(err).Msg("Failed to snapshot auth env files during first-run reset")
http.Error(w, "Failed to read persisted auth configuration", http.StatusInternalServerError)
return
}
if err := removeAuthEnvFiles(r.config.ConfigPath, r.config.DataPath); err != nil {
rollbackBootstrap()
if restoreErr := restoreAuthEnvFiles(authEnvSnapshots); restoreErr != nil {
log.Error().Err(restoreErr).Msg("Failed to restore auth env files after first-run reset failure")
}
log.Warn().Err(err).Msg("Failed to remove auth env files during first-run reset")
http.Error(w, "Failed to remove persisted auth configuration", http.StatusInternalServerError)
return
}
config.Mu.Lock()
previousAuthUser := strings.TrimSpace(r.config.AuthUser)
if r.persistence != nil {
if err := r.persistence.SaveAPITokens([]config.APITokenRecord{}); err != nil {
config.Mu.Unlock()
rollbackBootstrap()
if restoreErr := restoreAuthEnvFiles(authEnvSnapshots); restoreErr != nil {
log.Error().Err(restoreErr).Msg("Failed to restore auth env files after API token reset failure")
}
log.Warn().Err(err).Msg("Failed to clear persisted API tokens during first-run reset")
http.Error(w, "Failed to clear persisted API tokens", http.StatusInternalServerError)
return
}
}
r.config.AuthUser = ""
r.config.AuthPass = ""
r.config.APIToken = ""
r.config.APITokens = nil
config.Mu.Unlock()
for _, key := range []string{
"PULSE_AUTH_USER",
"PULSE_AUTH_PASS",
"REQUIRE_AUTH",
} {
_ = os.Unsetenv(key)
}
if previousAuthUser != "" {
InvalidateUserSessions(previousAuthUser)
}
r.clearSession(w, req)
r.bootstrapTokenHash = internalauth.HashAPIToken(token)
r.bootstrapTokenPath = path
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(firstRunResetResponse{
BootstrapToken: token,
BootstrapTokenPath: path,
})
}