mirror of
https://github.com/goauthentik/authentik
synced 2026-04-27 18:07:15 +02:00
* 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>
290 lines
8.2 KiB
Go
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)
|
|
)
|