Files
authentik/internal/outpost/proxyv2/postgresstore/connpool.go
dependabot[bot] 122cee049a core: bump library/golang from 1.25.5-trixie to 1.26.0-trixie in /lifecycle/container (#20381)
* core: bump library/golang in /lifecycle/container

Bumps library/golang from 1.25.5-trixie to 1.26.0-trixie.

---
updated-dependencies:
- dependency-name: library/golang
  dependency-version: 1.26.0-trixie
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* bump & fix

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* bump docs too

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2026-02-19 12:35:00 +00:00

290 lines
8.2 KiB
Go

package postgresstore
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"sync"
"time"
"github.com/jackc/pgx/v5/pgconn"
log "github.com/sirupsen/logrus"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"goauthentik.io/internal/config"
)
// RefreshableConnPool wraps sql.DB and refreshes PostgreSQL credentials on authentication errors
// This implements gorm.ConnPool interface to allow credential rotation
type RefreshableConnPool struct {
mu sync.RWMutex
db *sql.DB
log *log.Entry
currentDSN string
gormConfig *gorm.Config
// Connection pool settings (stored for reapplication after reconnection)
maxIdleConns int
maxOpenConns int
connMaxLifetime time.Duration
// Reconnection management
reconnecting sync.Mutex // Prevent concurrent reconnections
}
// NewRefreshableConnPool creates a new connection pool that refreshes credentials from config
func NewRefreshableConnPool(initialDSN string, gormConfig *gorm.Config, maxIdleConns, maxOpenConns int, connMaxLifetime time.Duration) (*RefreshableConnPool, error) {
db, err := sql.Open("pgx", initialDSN)
if err != nil {
return nil, err
}
// Apply connection pool settings
db.SetMaxIdleConns(maxIdleConns)
db.SetMaxOpenConns(maxOpenConns)
db.SetConnMaxLifetime(connMaxLifetime)
pool := &RefreshableConnPool{
db: db,
log: log.WithField("logger", "authentik.outpost.proxyv2.postgresstore.connpool"),
currentDSN: initialDSN,
gormConfig: gormConfig,
maxIdleConns: maxIdleConns,
maxOpenConns: maxOpenConns,
connMaxLifetime: connMaxLifetime,
}
return pool, nil
}
// isAuthError checks if an error is a PostgreSQL authentication error
func isAuthError(err error) bool {
if err == nil {
return false
}
// Unwrap the error to find the underlying pgconn.PgError
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
// Check for any PostgreSQL error code in Class 28 (Invalid Authorization Specification)
// See https://www.postgresql.org/docs/current/errcodes-appendix.html
return len(pgErr.Code) >= 2 && pgErr.Code[:2] == "28"
}
return false
}
// refreshCredentials checks if credentials have changed and reconnects if needed
func (p *RefreshableConnPool) refreshCredentials(ctx context.Context) error {
// Prevent concurrent reconnections
p.reconnecting.Lock()
defer p.reconnecting.Unlock()
// Get fresh config
cfg := config.Get().RefreshPostgreSQLConfig()
newDSN, err := BuildDSN(cfg)
if err != nil {
p.log.WithError(err).Warn("Failed to build DSN with refreshed credentials")
return err
}
p.mu.RLock()
dsnChanged := newDSN != p.currentDSN
p.mu.RUnlock()
if !dsnChanged {
p.log.Debug("Credentials unchanged, skipping reconnection")
return nil
}
p.mu.Lock()
defer p.mu.Unlock()
// Double-check after acquiring write lock
if newDSN == p.currentDSN {
return nil
}
p.log.Info("PostgreSQL credentials changed, reconnecting...")
// Open new connection with fresh credentials
newDB, err := sql.Open("pgx", newDSN)
if err != nil {
p.log.WithError(err).Error("Failed to open new database connection with refreshed credentials")
return err
}
// Reapply connection pool settings
newDB.SetMaxIdleConns(p.maxIdleConns)
newDB.SetMaxOpenConns(p.maxOpenConns)
newDB.SetConnMaxLifetime(p.connMaxLifetime)
// Verify the connection works BEFORE closing old connection
if err := newDB.PingContext(ctx); err != nil {
p.log.WithError(err).Error("Failed to ping database with new credentials")
_ = newDB.Close()
// Old connection remains active, pool is still functional
return err
}
// Only after successful verification, swap connections
oldDB := p.db
p.db = newDB
p.currentDSN = newDSN
// Close old connection after swap
if oldDB != nil {
if err := oldDB.Close(); err != nil {
p.log.WithError(err).Warn("Failed to close old database connection")
// Not fatal cause new connection is already active
}
}
p.log.Info("Successfully reconnected with new PostgreSQL credentials")
return nil
}
// tryWithRefresh attempts an operation, and if it fails with an auth error, refreshes credentials and retries
func (p *RefreshableConnPool) tryWithRefresh(ctx context.Context, op func() error) error {
err := op()
if err != nil && isAuthError(err) {
p.log.WithError(err).Info("Authentication error detected, attempting to refresh credentials")
if refreshErr := p.refreshCredentials(ctx); refreshErr == nil {
// Retry the operation once after successful refresh
p.log.Debug("Retrying operation after credential refresh")
return op()
} else {
p.log.WithError(refreshErr).Warn("Failed to refresh credentials, returning original error")
}
}
return err
}
// PrepareContext implements gorm.ConnPool interface
func (p *RefreshableConnPool) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) {
var stmt *sql.Stmt
err := p.tryWithRefresh(ctx, func() error {
p.mu.RLock()
defer p.mu.RUnlock()
var err error
stmt, err = p.db.PrepareContext(ctx, query)
return err
})
return stmt, err
}
// ExecContext implements gorm.ConnPool interface
func (p *RefreshableConnPool) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
var result sql.Result
err := p.tryWithRefresh(ctx, func() error {
p.mu.RLock()
defer p.mu.RUnlock()
var err error
result, err = p.db.ExecContext(ctx, query, args...)
return err
})
return result, err
}
// QueryContext implements gorm.ConnPool interface
func (p *RefreshableConnPool) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
var rows *sql.Rows
err := p.tryWithRefresh(ctx, func() error {
p.mu.RLock()
defer p.mu.RUnlock()
var err error
rows, err = p.db.QueryContext(ctx, query, args...)
return err
})
return rows, err
}
// QueryRowContext implements gorm.ConnPool interface
func (p *RefreshableConnPool) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row {
// Note: sql.Row doesn't return errors until Scan() is called, so we can't detect auth errors here
// The error will be caught in higher-level GORM operations
p.mu.RLock()
defer p.mu.RUnlock()
return p.db.QueryRowContext(ctx, query, args...)
}
// BeginTx implements gorm.TxBeginner and gorm.ConnPoolBeginner interfaces
func (p *RefreshableConnPool) BeginTx(ctx context.Context, opts *sql.TxOptions) (gorm.ConnPool, error) {
var tx *sql.Tx
err := p.tryWithRefresh(ctx, func() error {
p.mu.RLock()
defer p.mu.RUnlock()
var err error
tx, err = p.db.BeginTx(ctx, opts)
return err
})
if err != nil {
return nil, err
}
return &refreshableTx{Tx: tx, pool: p}, nil
}
// refreshableTx wraps sql.Tx to implement gorm.ConnPool
type refreshableTx struct {
*sql.Tx
pool *RefreshableConnPool
}
func (tx *refreshableTx) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) {
return tx.Tx.PrepareContext(ctx, query)
}
func (tx *refreshableTx) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
return tx.Tx.ExecContext(ctx, query, args...)
}
func (tx *refreshableTx) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
return tx.Tx.QueryContext(ctx, query, args...)
}
func (tx *refreshableTx) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row {
return tx.Tx.QueryRowContext(ctx, query, args...)
}
// Close closes the underlying database connection
func (p *RefreshableConnPool) Close() error {
p.mu.Lock()
defer p.mu.Unlock()
if p.db != nil {
return p.db.Close()
}
return nil
}
// Ping verifies the connection is alive
func (p *RefreshableConnPool) Ping(ctx context.Context) error {
p.mu.RLock()
defer p.mu.RUnlock()
return p.db.PingContext(ctx)
}
// GetDB returns the underlying sql.DB for connection pool configuration
func (p *RefreshableConnPool) GetDB() *sql.DB {
p.mu.RLock()
defer p.mu.RUnlock()
return p.db
}
// NewGORMDB creates a GORM DB instance using the refreshable connection pool
func (p *RefreshableConnPool) NewGORMDB() (*gorm.DB, error) {
dialector := postgres.New(postgres.Config{
Conn: p,
})
return gorm.Open(dialector, p.gormConfig)
}
// Ensure RefreshableConnPool implements required interfaces
var (
_ gorm.ConnPool = (*RefreshableConnPool)(nil)
_ gorm.ConnPoolBeginner = (*RefreshableConnPool)(nil)
_ driver.Pinger = (*RefreshableConnPool)(nil)
)