Make api_tokens.json authoritative source for API tokens (fixes #685)

This is the proper architectural fix for #685. The previous commit was a
bandaid that prevented unnecessary .env writes. This commit addresses the
root cause: dual-source-of-truth for API tokens (.env vs api_tokens.json).

Changes:

1. Startup Migration (config.go:896-951):
   - When loading config, if API_TOKEN/API_TOKENS exist in .env but not in
     api_tokens.json, automatically migrate them
   - Migrated tokens are named "Migrated from .env (prefix)" for clarity
   - Logs a deprecation warning: API_TOKEN/API_TOKENS in .env are deprecated
   - Leaves .env untouched (safe for existing deployments)

2. Config Watcher Changes (watcher.go:338-424):
   - Only load tokens from .env if api_tokens.json is EMPTY
   - Once api_tokens.json has records, it becomes the authoritative source
   - .env changes no longer trigger token overwrites when api_tokens.json exists
   - Logs debug message when ignoring env tokens

Result:
- Existing deployments: env tokens automatically migrated to api_tokens.json
- UI-created tokens: never overwritten by .env changes
- Dark mode toggle: no longer triggers token reload from .env
- Backward compatible: fresh installs with API_TOKEN in .env still work
- Migration path: users can safely keep API_TOKEN in .env, it will be ignored

Future improvement: Add UI warning when API_TOKEN/API_TOKENS still present
in .env, prompting users to rotate tokens via the UI.
This commit is contained in:
rcourtman 2025-11-11 00:17:40 +00:00
parent 5d99fc2f2d
commit accecdb50b
2 changed files with 34 additions and 10 deletions

View file

@ -896,6 +896,11 @@ func Load() (*Config, error) {
if len(envTokens) > 0 {
cfg.EnvOverrides["API_TOKEN"] = true
cfg.EnvOverrides["API_TOKENS"] = true
// Track if we migrated any new tokens from env to persistence
migratedCount := 0
needsPersist := false
for _, tokenValue := range envTokens {
if tokenValue == "" {
continue
@ -909,18 +914,18 @@ func Load() (*Config, error) {
hashed = auth.HashAPIToken(tokenValue)
prefix = tokenPrefix(tokenValue)
suffix = tokenSuffix(tokenValue)
log.Info().Msg("Auto-hashed plain text API token from environment variable")
} else {
log.Debug().Msg("Loaded pre-hashed API token from env var")
log.Debug().Msg("Auto-hashed plain text API token from environment variable")
}
// Check if this token already exists in api_tokens.json
if cfg.HasAPITokenHash(hashed) {
continue
}
// Migrate env token to api_tokens.json
record := APITokenRecord{
ID: uuid.NewString(),
Name: "Environment token",
Name: "Migrated from .env (" + prefix + ")",
Hash: hashed,
Prefix: prefix,
Suffix: suffix,
@ -928,8 +933,22 @@ func Load() (*Config, error) {
Scopes: []string{ScopeWildcard},
}
cfg.APITokens = append(cfg.APITokens, record)
migratedCount++
needsPersist = true
}
cfg.SortAPITokens()
// Persist migrated tokens to api_tokens.json
if needsPersist && persistence != nil {
if err := persistence.SaveAPITokens(cfg.APITokens); err != nil {
log.Error().Err(err).Msg("Failed to persist migrated API tokens from environment")
} else {
log.Warn().
Int("count", migratedCount).
Msg("Migrated API tokens from .env to api_tokens.json - API_TOKEN/API_TOKENS in .env are deprecated and will be ignored in future releases. Manage tokens via the UI instead.")
}
}
}
// Check if API token is enabled

View file

@ -335,7 +335,8 @@ func (cw *ConfigWatcher) reloadConfig() {
}
}
// Apply API tokens if present in .env (legacy support)
// Legacy env token support: only process if api_tokens.json is empty
// This prevents .env changes from overwriting UI-managed tokens (fixes #685)
rawTokens := make([]string, 0, 4)
if raw, ok := envMap["API_TOKENS"]; ok {
raw = strings.Trim(raw, "'\"")
@ -347,17 +348,19 @@ func (cw *ConfigWatcher) reloadConfig() {
rawTokens = append(rawTokens, token)
}
}
} else {
// Explicit empty list clears tokens
rawTokens = []string{}
}
}
if raw, ok := envMap["API_TOKEN"]; ok {
raw = strings.Trim(raw, "'\"")
rawTokens = append(rawTokens, raw)
if raw != "" {
rawTokens = append(rawTokens, raw)
}
}
if len(rawTokens) > 0 {
// Only reload tokens from .env if NO tokens exist in api_tokens.json
// This makes api_tokens.json the authoritative source once it has records
if len(rawTokens) > 0 && len(cw.config.APITokens) == 0 {
log.Debug().Msg("No existing API tokens found - loading from .env (legacy)")
seen := make(map[string]struct{}, len(rawTokens))
newRecords := make([]APITokenRecord, 0, len(rawTokens))
for _, tokenValue := range rawTokens {
@ -416,6 +419,8 @@ func (cw *ConfigWatcher) reloadConfig() {
}
}
}
} else if len(rawTokens) > 0 && len(cw.config.APITokens) > 0 {
log.Debug().Msg("Ignoring API_TOKEN/API_TOKENS from .env - api_tokens.json is authoritative")
}
// REMOVED: POLLING_INTERVAL from .env - now ONLY in system.json