Canonicalize fixed alerts and licensing paths

This commit is contained in:
rcourtman 2026-03-29 14:14:36 +01:00
parent 7dcd564997
commit f1fc17e627
9 changed files with 220 additions and 37 deletions

View file

@ -44,6 +44,7 @@ operator-facing alert routing behavior for live runtime alerts.
22. `frontend-modern/src/utils/alertTabsPresentation.ts`
23. `frontend-modern/src/utils/alertThresholdsPresentation.ts`
24. `frontend-modern/src/utils/alertThresholdsSectionPresentation.ts`
25. `internal/alerts/history.go`
## Shared Boundaries
@ -54,6 +55,7 @@ operator-facing alert routing behavior for live runtime alerts.
1. Add new alert rule kinds in `internal/alerts/specs/`
2. Add typed collector/builders in `internal/alerts/alerts.go`
3. Add identity/persistence updates through canonical alert helpers only
4. Add or change alert history persistence through `internal/alerts/history.go` using normalized owned storage roots and fixed storage leaves only
## Forbidden Paths
@ -74,6 +76,14 @@ Canonical alert identity and evaluation are the live runtime model. Remaining
legacy references should exist only in explicit migration or negative test
boundaries.
Alert history persistence is also part of that canonical boundary. The history
manager may choose the owned runtime data directory, but it must normalize that
directory once and then resolve only the fixed `alert-history.json` and
`alert-history.backup.json` leaves through the shared storage-path helper
before any filesystem read, write, rename, or delete. Future history-persistence
changes must not reintroduce raw `filepath.Join(dataDir, ...)` joins from
caller-supplied directories or ad hoc history filenames.
Notification transport, provider delivery, queue safety, and notification API
transport now live under the explicit `notifications` subsystem inside the
current architecture lane. The alerts surface still owns operator-facing alert

View file

@ -162,6 +162,14 @@ key and grace-period metadata in runtime state, but a legacy plaintext
`license.enc` may only serve as migration input. Once it can be read,
canonical persistence must rewrite encrypted storage immediately on load
instead of treating plaintext licensing state as a valid steady-state path.
That same local-persistence boundary also owns the filesystem path contract for
commercial secrets at rest. `pkg/licensing/persistence.go` and
`pkg/licensing/activation_store.go` must normalize the owned config directory
once and resolve only the fixed `.license-key`, `license.enc`, and
`activation.enc` leaves through the shared storage-path helper before any
filesystem read, write, rename, stat, or delete. Future licensing persistence
changes must not bypass that resolver with raw `filepath.Join(configDir, ...)`
joins or introduce caller-controlled persistence filenames.
Hosted entitlement-source loading follows the same rule: `DatabaseSource` must
normalize persisted Cloud/MSP plan aliases and legacy limit keys before runtime
evaluation, but it must not fabricate a canonical `plan_version` from bare

View file

@ -9,6 +9,7 @@ import (
"sync"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
"github.com/rs/zerolog/log"
)
@ -67,26 +68,45 @@ func historyIdentityKey(alert *Alert) string {
return alert.ID
}
func resolveHistoryStoragePaths(dataDir string) (resolvedDataDir string, historyFile string, backupFile string, err error) {
resolvedDataDir, err = securityutil.NormalizeStorageDir(dataDir)
if err != nil {
return "", "", "", fmt.Errorf("resolve history data directory: %w", err)
}
historyFile, err = securityutil.JoinStorageLeaf(resolvedDataDir, HistoryFileName)
if err != nil {
return "", "", "", fmt.Errorf("resolve history file path: %w", err)
}
backupFile, err = securityutil.JoinStorageLeaf(resolvedDataDir, HistoryBackupFileName)
if err != nil {
return "", "", "", fmt.Errorf("resolve history backup file path: %w", err)
}
return resolvedDataDir, historyFile, backupFile, nil
}
// NewHistoryManager creates a new history manager
func NewHistoryManager(dataDir string) *HistoryManager {
if dataDir == "" {
dataDir = utils.GetDataDir()
resolvedDataDir, historyFile, backupFile, err := resolveHistoryStoragePaths(utils.ResolveDataDir(dataDir))
if err != nil {
panic(fmt.Sprintf("invalid alert history storage paths for %q: %v", dataDir, err))
}
hm := &HistoryManager{
dataDir: dataDir,
historyFile: filepath.Join(dataDir, HistoryFileName),
backupFile: filepath.Join(dataDir, HistoryBackupFileName),
dataDir: resolvedDataDir,
historyFile: historyFile,
backupFile: backupFile,
history: make([]HistoryEntry, 0),
saveInterval: 5 * time.Minute,
stopChan: make(chan struct{}),
}
// Ensure data directory exists
if err := os.MkdirAll(dataDir, alertsDirPerm); err != nil {
log.Error().Err(err).Str("dir", dataDir).Msg("Failed to create data directory")
} else if err := os.Chmod(dataDir, alertsDirPerm); err != nil {
log.Warn().Err(err).Str("dir", dataDir).Msg("Failed to harden history directory permissions")
if err := os.MkdirAll(resolvedDataDir, alertsDirPerm); err != nil {
log.Error().Err(err).Str("dir", resolvedDataDir).Msg("Failed to create data directory")
} else if err := os.Chmod(resolvedDataDir, alertsDirPerm); err != nil {
log.Warn().Err(err).Str("dir", resolvedDataDir).Msg("Failed to harden history directory permissions")
}
// Load existing history

View file

@ -14,12 +14,16 @@ func newTestHistoryManager(t *testing.T) *HistoryManager {
t.Helper()
tempDir := t.TempDir()
resolvedDataDir, historyFile, backupFile, err := resolveHistoryStoragePaths(tempDir)
if err != nil {
t.Fatalf("resolveHistoryStoragePaths(%q) error = %v", tempDir, err)
}
// Create minimal HistoryManager without starting goroutines
hm := &HistoryManager{
dataDir: tempDir,
historyFile: filepath.Join(tempDir, HistoryFileName),
backupFile: filepath.Join(tempDir, HistoryBackupFileName),
dataDir: resolvedDataDir,
historyFile: historyFile,
backupFile: backupFile,
history: make([]HistoryEntry, 0),
saveInterval: 5 * time.Minute,
stopChan: make(chan struct{}),
@ -28,6 +32,33 @@ func newTestHistoryManager(t *testing.T) *HistoryManager {
return hm
}
func TestResolveHistoryStoragePathsCanonicalizesDataDir(t *testing.T) {
baseDir := t.TempDir()
inputDir := " " + filepath.Join(baseDir, "nested", "..", "history") + string(os.PathSeparator) + ". "
resolvedDataDir, historyFile, backupFile, err := resolveHistoryStoragePaths(inputDir)
if err != nil {
t.Fatalf("resolveHistoryStoragePaths() error = %v", err)
}
wantDir := filepath.Clean(filepath.Join(baseDir, "nested", "..", "history") + string(os.PathSeparator) + ".")
if resolvedDataDir != wantDir {
t.Fatalf("resolvedDataDir = %q, want %q", resolvedDataDir, wantDir)
}
if historyFile != filepath.Join(wantDir, HistoryFileName) {
t.Fatalf("historyFile = %q, want %q", historyFile, filepath.Join(wantDir, HistoryFileName))
}
if backupFile != filepath.Join(wantDir, HistoryBackupFileName) {
t.Fatalf("backupFile = %q, want %q", backupFile, filepath.Join(wantDir, HistoryBackupFileName))
}
}
func TestResolveHistoryStoragePathsRejectsBlankDir(t *testing.T) {
if _, _, _, err := resolveHistoryStoragePaths(" \t "); err == nil {
t.Fatal("expected blank history data dir to be rejected")
}
}
func TestGetStats_EmptyHistory(t *testing.T) {
// t.Parallel()

View file

@ -14,13 +14,23 @@ func HashedStorageName(id string) string {
return hex.EncodeToString(sum[:])
}
// JoinStorageLeaf joins an already-owned storage directory with a validated leaf filename.
func JoinStorageLeaf(dir, leaf string) (string, error) {
// NormalizeStorageDir trims and canonicalizes an already-owned storage directory path.
func NormalizeStorageDir(dir string) (string, error) {
trimmedDir := strings.TrimSpace(dir)
if trimmedDir == "" {
return "", fmt.Errorf("storage directory is required")
}
return filepath.Clean(trimmedDir), nil
}
// JoinStorageLeaf joins an already-owned storage directory with a validated leaf filename.
func JoinStorageLeaf(dir, leaf string) (string, error) {
normalizedDir, err := NormalizeStorageDir(dir)
if err != nil {
return "", err
}
name := strings.TrimSpace(leaf)
if name == "" {
return "", fmt.Errorf("storage leaf is required")
@ -35,5 +45,5 @@ func JoinStorageLeaf(dir, leaf string) (string, error) {
return "", fmt.Errorf("storage leaf must not contain path separators")
}
return filepath.Join(trimmedDir, name), nil
return filepath.Join(normalizedDir, name), nil
}

View file

@ -1,6 +1,25 @@
package securityutil
import "testing"
import (
"path/filepath"
"testing"
)
func TestNormalizeStorageDir(t *testing.T) {
dir, err := NormalizeStorageDir(" /tmp/pulse/../pulse ")
if err != nil {
t.Fatalf("NormalizeStorageDir() error = %v", err)
}
if dir != filepath.Clean("/tmp/pulse/../pulse") {
t.Fatalf("NormalizeStorageDir() = %q", dir)
}
}
func TestNormalizeStorageDirRejectsBlank(t *testing.T) {
if _, err := NormalizeStorageDir(" \t "); err == nil {
t.Fatal("expected blank storage dir to be rejected")
}
}
func TestJoinStorageLeaf(t *testing.T) {
path, err := JoinStorageLeaf("/tmp/pulse", "session.json")

View file

@ -5,7 +5,6 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
// ActivationStateFileName is the name of the encrypted activation state file.
@ -36,11 +35,18 @@ func (p *Persistence) SaveActivationState(state *ActivationState) error {
return fmt.Errorf("encrypt activation state: %w", err)
}
if err := ensurePersistenceOwnerOnlyDir(p.configDir); err != nil {
configDir, err := normalizePersistenceConfigDir(p.configDir)
if err != nil {
return fmt.Errorf("resolve config directory: %w", err)
}
if err := ensurePersistenceOwnerOnlyDir(configDir); err != nil {
return fmt.Errorf("secure config directory: %w", err)
}
statePath := filepath.Join(p.configDir, ActivationStateFileName)
statePath, err := resolvePersistencePath(configDir, ActivationStateFileName)
if err != nil {
return fmt.Errorf("resolve activation state file path: %w", err)
}
encoded := base64.StdEncoding.EncodeToString(encrypted)
if err := writeOwnerOnlyPersistenceFileAtomic(statePath, []byte(encoded)); err != nil {
@ -53,7 +59,10 @@ func (p *Persistence) SaveActivationState(state *ActivationState) error {
// LoadActivationState reads and decrypts the activation state from disk.
// Returns nil, nil if no activation state file exists.
func (p *Persistence) LoadActivationState() (*ActivationState, error) {
statePath := filepath.Join(p.configDir, ActivationStateFileName)
statePath, err := resolvePersistencePath(p.configDir, ActivationStateFileName)
if err != nil {
return nil, fmt.Errorf("resolve activation state file path: %w", err)
}
encoded, err := readBoundedPersistenceRegularFile(statePath, maxLicenseFileSize)
if err != nil {
@ -99,8 +108,11 @@ func (p *Persistence) LoadActivationState() (*ActivationState, error) {
// ClearActivationState removes the activation state file from disk.
func (p *Persistence) ClearActivationState() error {
statePath := filepath.Join(p.configDir, ActivationStateFileName)
err := os.Remove(statePath)
statePath, err := resolvePersistencePath(p.configDir, ActivationStateFileName)
if err != nil {
return fmt.Errorf("resolve activation state file path: %w", err)
}
err = os.Remove(statePath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("delete activation state file: %w", err)
}
@ -109,7 +121,10 @@ func (p *Persistence) ClearActivationState() error {
// ActivationStateExists checks if an activation state file exists on disk.
func (p *Persistence) ActivationStateExists() bool {
statePath := filepath.Join(p.configDir, ActivationStateFileName)
statePath, err := resolvePersistencePath(p.configDir, ActivationStateFileName)
if err != nil {
return false
}
info, err := os.Lstat(statePath)
if err != nil {
return false

View file

@ -15,6 +15,8 @@ import (
"path/filepath"
"strings"
"syscall"
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
)
const (
@ -40,6 +42,27 @@ func isMissingPersistencePathError(err error) bool {
return errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ENOTDIR)
}
func normalizePersistenceConfigDir(configDir string) (string, error) {
normalizedConfigDir, err := securityutil.NormalizeStorageDir(configDir)
if err != nil {
return "", errors.New("config directory cannot be empty")
}
return normalizedConfigDir, nil
}
func resolvePersistencePath(configDir string, leaf string) (string, error) {
normalizedConfigDir, err := normalizePersistenceConfigDir(configDir)
if err != nil {
return "", err
}
path, err := securityutil.JoinStorageLeaf(normalizedConfigDir, leaf)
if err != nil {
return "", fmt.Errorf("resolve persistence path for %q: %w", leaf, err)
}
return path, nil
}
func ensurePersistenceOwnerOnlyDir(dir string) error {
if err := os.MkdirAll(dir, persistencePrivateDirPerm); err != nil {
return err
@ -133,13 +156,13 @@ type Persistence struct {
// It tries to use a persistent key stored in configDir first, then falls back
// to machine-id for backwards compatibility with existing installations.
func NewPersistence(configDir string) (*Persistence, error) {
configDir = strings.TrimSpace(configDir)
if configDir == "" {
return nil, errors.New("config directory cannot be empty")
normalizedConfigDir, err := normalizePersistenceConfigDir(configDir)
if err != nil {
return nil, err
}
// Try to load persistent key from config directory first
persistentKey, err := loadPersistentKey(configDir)
persistentKey, err := loadPersistentKey(normalizedConfigDir)
if err != nil && !isMissingPersistencePathError(err) {
return nil, fmt.Errorf("failed to load persistent key: %w", err)
}
@ -157,7 +180,7 @@ func NewPersistence(configDir string) (*Persistence, error) {
}
return &Persistence{
configDir: configDir,
configDir: normalizedConfigDir,
encryptionKey: encryptionKey,
machineID: machineID,
}, nil
@ -165,7 +188,10 @@ func NewPersistence(configDir string) (*Persistence, error) {
// loadPersistentKey attempts to load the persistent encryption key from disk.
func loadPersistentKey(configDir string) (string, error) {
keyPath := filepath.Join(configDir, PersistentKeyFileName)
keyPath, err := resolvePersistencePath(configDir, PersistentKeyFileName)
if err != nil {
return "", err
}
data, err := readBoundedPersistenceRegularFile(keyPath, maxPersistentKeyFileSize)
if err != nil {
return "", err
@ -180,9 +206,16 @@ func loadPersistentKey(configDir string) (string, error) {
// ensurePersistentKey creates a persistent encryption key if one doesn't exist.
// Returns the key (existing or newly created).
func (p *Persistence) ensurePersistentKey() (string, error) {
keyPath := filepath.Join(p.configDir, PersistentKeyFileName)
configDir, err := normalizePersistenceConfigDir(p.configDir)
if err != nil {
return "", err
}
keyPath, err := resolvePersistencePath(configDir, PersistentKeyFileName)
if err != nil {
return "", err
}
if err := ensurePersistenceOwnerOnlyDir(p.configDir); err != nil {
if err := ensurePersistenceOwnerOnlyDir(configDir); err != nil {
return "", fmt.Errorf("failed to secure config directory: %w", err)
}
@ -261,11 +294,18 @@ func (p *Persistence) SaveWithGracePeriod(licenseKey string, gracePeriodEnd *int
return fmt.Errorf("failed to encrypt license: %w", err)
}
if err := ensurePersistenceOwnerOnlyDir(p.configDir); err != nil {
configDir, err := normalizePersistenceConfigDir(p.configDir)
if err != nil {
return err
}
if err := ensurePersistenceOwnerOnlyDir(configDir); err != nil {
return fmt.Errorf("failed to secure config directory: %w", err)
}
licensePath := filepath.Join(p.configDir, LicenseFileName)
licensePath, err := resolvePersistencePath(configDir, LicenseFileName)
if err != nil {
return fmt.Errorf("resolve license file path: %w", err)
}
encoded := base64.StdEncoding.EncodeToString(encrypted)
if err := writeOwnerOnlyPersistenceFileAtomic(licensePath, []byte(encoded)); err != nil {
@ -288,7 +328,10 @@ func (p *Persistence) Load() (string, error) {
// It tries to decrypt with the current encryption key first, then falls back
// to machine-id for backwards compatibility with existing installations.
func (p *Persistence) LoadWithMetadata() (PersistedLicense, error) {
licensePath := filepath.Join(p.configDir, LicenseFileName)
licensePath, err := resolvePersistencePath(p.configDir, LicenseFileName)
if err != nil {
return PersistedLicense{}, fmt.Errorf("resolve license file path: %w", err)
}
encoded, err := readBoundedPersistenceRegularFile(licensePath, maxLicenseFileSize)
if err != nil {
@ -340,8 +383,11 @@ func (p *Persistence) LoadWithMetadata() (PersistedLicense, error) {
// Delete removes the saved license file.
func (p *Persistence) Delete() error {
licensePath := filepath.Join(p.configDir, LicenseFileName)
err := os.Remove(licensePath)
licensePath, err := resolvePersistencePath(p.configDir, LicenseFileName)
if err != nil {
return fmt.Errorf("resolve license file path: %w", err)
}
err = os.Remove(licensePath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to delete license file: %w", err)
}
@ -350,7 +396,10 @@ func (p *Persistence) Delete() error {
// Exists checks if a saved license exists.
func (p *Persistence) Exists() bool {
licensePath := filepath.Join(p.configDir, LicenseFileName)
licensePath, err := resolvePersistencePath(p.configDir, LicenseFileName)
if err != nil {
return false
}
info, err := os.Lstat(licensePath)
if err != nil {
return false

View file

@ -289,6 +289,27 @@ func TestPersistenceEnforcesOwnerOnlyPermissions(t *testing.T) {
}
}
func TestResolvePersistencePathCanonicalizesConfigDir(t *testing.T) {
baseDir := t.TempDir()
inputDir := " " + filepath.Join(baseDir, "nested", "..", "licensing") + string(os.PathSeparator) + ". "
path, err := resolvePersistencePath(inputDir, LicenseFileName)
if err != nil {
t.Fatalf("resolvePersistencePath() error = %v", err)
}
wantDir := filepath.Clean(filepath.Join(baseDir, "nested", "..", "licensing") + string(os.PathSeparator) + ".")
if path != filepath.Join(wantDir, LicenseFileName) {
t.Fatalf("resolvePersistencePath() = %q, want %q", path, filepath.Join(wantDir, LicenseFileName))
}
}
func TestResolvePersistencePathRejectsBlankConfigDir(t *testing.T) {
if _, err := resolvePersistencePath(" \t ", LicenseFileName); err == nil {
t.Fatal("expected blank config dir to be rejected")
}
}
func TestNewPersistenceRejectsSymlinkPersistentKey(t *testing.T) {
tmpDir := t.TempDir()
target := filepath.Join(tmpDir, "key-target")