fix(authx): prevent deadlock in dynamic auth fetching

resolve deadlock that occurs when dynamic auth
templates trigger recursive auth requests during
execution.

RCA:
1. `GetStrategies()` calls `Fetch()` to retrieve
   auth creds.
2. `Fetch()` executes auth template via cb.
3. template exec triggers HTTP requests requiring
   auth.
4. recursive calls `GetStrategies()` → `Fetch()`
   cause deadlock on mutex.

notable changes:
* add `fetching` flag to `Dynamic` struct to track
  fetch-in-progress state.
* modify `GetStrategies()` to return empty
  strategies if already fetching.
* update `Fetch()` method with proper recursive
  call prevention.
* use mutex-protected flag reads to ensure thread
  safety.
* refactor `GetStrategies()` with local function
  for code reuse.

this prevents infinite recursion during auth
template execution while maintaining proper sync
and err handling.

fixes goroutine deadlocks in auth system when
using dynamic secrets with templates that require
auth.

Signed-off-by: Dwi Siswanto <git@dw1.io>
This commit is contained in:
Dwi Siswanto 2025-09-04 18:30:44 +07:00
parent 5ef37da8f4
commit e7968de431
No known key found for this signature in database
GPG Key ID: 3BB198907EF44CED

View File

@ -32,6 +32,7 @@ type Dynamic struct {
fetchCallback LazyFetchSecret `json:"-" yaml:"-"`
m *sync.Mutex `json:"-" yaml:"-"` // mutex for lazy fetch
fetched bool `json:"-" yaml:"-"` // flag to check if the secret has been fetched
fetching bool `json:"-" yaml:"-"` // flag to check if we're currently fetching (prevents recursion)
error error `json:"-" yaml:"-"` // error if any
}
@ -184,34 +185,74 @@ func (d *Dynamic) applyValuesToSecret(secret *Secret) error {
// GetStrategy returns the auth strategies for the dynamic secret
func (d *Dynamic) GetStrategies() []AuthStrategy {
if !d.fetched {
_ = d.Fetch(true)
}
if d.error != nil {
return nil
}
getStrategies := func() []AuthStrategy {
var strategies []AuthStrategy
if d.Secret != nil {
strategies = append(strategies, d.GetStrategy())
}
for _, secret := range d.Secrets {
strategies = append(strategies, secret.GetStrategy())
}
return strategies
}
d.m.Lock()
isFetched := d.fetched
isFetching := d.fetching
d.m.Unlock()
if isFetched {
if d.error != nil {
return nil
}
return getStrategies()
}
if isFetching {
// NOTE(dwisiswant0): Bail out w/ empty here, or we will yeet into
// recursion hell. See line 242-245.
return nil
}
_ = d.Fetch(true)
if d.error != nil {
return nil
}
return getStrategies()
}
// Fetch fetches the dynamic secret
// if isFatal is true, it will stop the execution if the secret could not be fetched
func (d *Dynamic) Fetch(isFatal bool) error {
d.m.Lock()
defer d.m.Unlock()
if d.fetched {
return d.error
}
if d.fetching {
return nil
}
d.fetching = true
defer func() {
d.fetching = false
}()
d.error = d.fetchCallback(d)
if d.error != nil && isFatal {
if d.error != nil {
if isFatal {
gologger.Fatal().Msgf("Could not fetch dynamic secret: %s\n", d.error)
}
} else {
d.fetched = true
}
return d.error
}