mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -51,3 +52,56 @@ func removeAuthEnvFiles(configPath string, dataPath string) error {
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
type authEnvFileSnapshot struct {
|
||||
path string
|
||||
data []byte
|
||||
mode os.FileMode
|
||||
exists bool
|
||||
}
|
||||
|
||||
func snapshotAuthEnvFiles(configPath string, dataPath string) ([]authEnvFileSnapshot, error) {
|
||||
paths := resolveAuthEnvWritePaths(configPath, dataPath)
|
||||
snapshots := make([]authEnvFileSnapshot, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
snapshots = append(snapshots, authEnvFileSnapshot{path: path})
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read auth environment file %s: %w", path, err)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat auth environment file %s: %w", path, err)
|
||||
}
|
||||
snapshots = append(snapshots, authEnvFileSnapshot{
|
||||
path: path,
|
||||
data: data,
|
||||
mode: info.Mode().Perm(),
|
||||
exists: true,
|
||||
})
|
||||
}
|
||||
return snapshots, nil
|
||||
}
|
||||
|
||||
func restoreAuthEnvFiles(snapshots []authEnvFileSnapshot) error {
|
||||
var restoreErr error
|
||||
for _, snapshot := range snapshots {
|
||||
if !snapshot.exists {
|
||||
if err := os.Remove(snapshot.path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
restoreErr = errors.Join(restoreErr, fmt.Errorf("remove newly created auth environment file %s: %w", snapshot.path, err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(snapshot.path), 0o755); err != nil {
|
||||
restoreErr = errors.Join(restoreErr, fmt.Errorf("recreate auth environment directory for %s: %w", snapshot.path, err))
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(snapshot.path, snapshot.data, snapshot.mode); err != nil {
|
||||
restoreErr = errors.Join(restoreErr, fmt.Errorf("restore auth environment file %s: %w", snapshot.path, err))
|
||||
}
|
||||
}
|
||||
return restoreErr
|
||||
}
|
||||
|
||||
@@ -36,8 +36,58 @@ func (r *Router) handleResetFirstRunSecurity(w http.ResponseWriter, req *http.Re
|
||||
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 = ""
|
||||
@@ -56,27 +106,6 @@ func (r *Router) handleResetFirstRunSecurity(w http.ResponseWriter, req *http.Re
|
||||
InvalidateUserSessions(previousAuthUser)
|
||||
}
|
||||
r.clearSession(w, req)
|
||||
r.clearBootstrapToken()
|
||||
|
||||
if err := removeAuthEnvFiles(r.config.ConfigPath, r.config.DataPath); err != nil {
|
||||
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
|
||||
}
|
||||
if r.persistence != nil {
|
||||
if err := r.persistence.SaveAPITokens([]config.APITokenRecord{}); err != nil {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
token, _, 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
|
||||
}
|
||||
r.bootstrapTokenHash = internalauth.HashAPIToken(token)
|
||||
r.bootstrapTokenPath = path
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
)
|
||||
@@ -132,3 +133,82 @@ func TestResetFirstRunSecurityClearsEnvBackedAuthFromStatus(t *testing.T) {
|
||||
t.Fatalf("detailLevel = %v, want %q", got, securityStatusDetailPublic)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetFirstRunSecurityPreservesLiveCredentialsWhenTokenPersistenceFails(t *testing.T) {
|
||||
t.Setenv("PULSE_DEV", "true")
|
||||
t.Setenv("NODE_ENV", "")
|
||||
t.Setenv("PULSE_AUTH_USER", "admin")
|
||||
t.Setenv("PULSE_AUTH_PASS", "hashed-password")
|
||||
t.Setenv("REQUIRE_AUTH", "true")
|
||||
|
||||
rawToken := "reset-first-run-token-persist-failure.12345678"
|
||||
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsWrite}, nil)
|
||||
cfg := newTestConfigWithTokens(t, record)
|
||||
cfg.AuthUser = "admin"
|
||||
cfg.AuthPass = "hashed-password"
|
||||
cfg.SortAPITokens()
|
||||
authEnvContent := []byte("PULSE_AUTH_USER='admin'\nPULSE_AUTH_PASS='hashed-password'\nREQUIRE_AUTH='true'\n")
|
||||
authEnvPath, err := writeAuthEnvFile(cfg.ConfigPath, cfg.DataPath, authEnvContent)
|
||||
if err != nil {
|
||||
t.Fatalf("write auth environment file: %v", err)
|
||||
}
|
||||
|
||||
persistence := config.NewConfigPersistence(cfg.DataPath)
|
||||
if err := persistence.SaveAPITokens(cfg.APITokens); err != nil {
|
||||
t.Fatalf("persist initial API tokens: %v", err)
|
||||
}
|
||||
persistence.SetFileSystem(failingWriteFileSystem{})
|
||||
|
||||
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
||||
router.persistence = persistence
|
||||
sessionToken := "reset-first-run-session-persist-failure"
|
||||
GetSessionStore().CreateSession(sessionToken, time.Hour, "browser", "127.0.0.1", "admin")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/security/dev/reset-first-run", nil)
|
||||
req.Header.Set("X-API-Token", rawToken)
|
||||
rec := httptest.NewRecorder()
|
||||
router.Handler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d (%s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if cfg.AuthUser != "admin" || cfg.AuthPass != "hashed-password" {
|
||||
t.Fatalf("failed reset changed live password auth: user=%q pass=%q", cfg.AuthUser, cfg.AuthPass)
|
||||
}
|
||||
if len(cfg.APITokens) != 1 || cfg.APITokens[0].ID != record.ID {
|
||||
t.Fatalf("failed reset changed live API tokens: %+v", cfg.APITokens)
|
||||
}
|
||||
if cfg.APIToken != record.Hash || !cfg.IsValidAPIToken(rawToken) {
|
||||
t.Fatal("failed reset changed the live primary API token projection")
|
||||
}
|
||||
if !ValidateSession(sessionToken) {
|
||||
t.Fatal("failed reset invalidated an existing user session")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(cfg.DataPath, bootstrapTokenFilename)); !os.IsNotExist(err) {
|
||||
t.Fatalf("failed reset left a staged bootstrap token behind: %v", err)
|
||||
}
|
||||
gotAuthEnv, err := os.ReadFile(authEnvPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed reset did not restore auth environment file: %v", err)
|
||||
}
|
||||
if string(gotAuthEnv) != string(authEnvContent) {
|
||||
t.Fatalf("restored auth environment file = %q, want %q", gotAuthEnv, authEnvContent)
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"PULSE_AUTH_USER": "admin",
|
||||
"PULSE_AUTH_PASS": "hashed-password",
|
||||
"REQUIRE_AUTH": "true",
|
||||
} {
|
||||
if got := os.Getenv(key); got != want {
|
||||
t.Fatalf("failed reset changed %s=%q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
persisted, err := config.NewConfigPersistence(cfg.DataPath).LoadAPITokens()
|
||||
if err != nil {
|
||||
t.Fatalf("load persisted API tokens: %v", err)
|
||||
}
|
||||
if len(persisted) != 1 || persisted[0].ID != record.ID {
|
||||
t.Fatalf("failed reset changed restart-time API tokens: %+v", persisted)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user