Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions postgres/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,19 @@ func NewAuthToken(ctx context.Context, cfg *Config, user string) (string, error)
if cfg.DynamicAuth == nil {
return "", nil
}
if cfg.DynamicAuth.AWSRDSIAM != nil {
if err := singleDynamicAuthBackend(cfg.DynamicAuth); err != nil {
return "", err
}
switch {
case cfg.DynamicAuth.AWSRDSIAM != nil:
return awsRDSIAMToken(ctx, cfg, user)
case cfg.DynamicAuth.AzureAD != nil:
return azureADToken(ctx)
case cfg.DynamicAuth.GCPCloudSQLIAM != nil:
return gcpCloudSQLIAMToken(ctx)
default:
return "", errors.New("unreachable: singleDynamicAuthBackend guarantees exactly one backend is set")
}
return "", errors.New("dynamicAuth is set but no supported auth method (e.g., awsRdsIam) is configured")
}

// NewDynamicAuthFunc returns a BeforeConnect hook that resolves a fresh
Expand All @@ -52,10 +61,19 @@ func NewDynamicAuthFunc(ctx context.Context, cfg *Config, user string) (BeforeCo
if cfg.DynamicAuth == nil {
return nil, errors.New("dynamic authentication is not configured")
}
if cfg.DynamicAuth.AWSRDSIAM != nil {
if err := singleDynamicAuthBackend(cfg.DynamicAuth); err != nil {
return nil, err
}
switch {
case cfg.DynamicAuth.AWSRDSIAM != nil:
return awsRDSIAMBeforeConnect(ctx, cfg, user)
case cfg.DynamicAuth.AzureAD != nil:
return azureADBeforeConnect()
case cfg.DynamicAuth.GCPCloudSQLIAM != nil:
return gcpCloudSQLIAMBeforeConnect()
default:
return nil, errors.New("unreachable: singleDynamicAuthBackend guarantees exactly one backend is set")
}
return nil, errors.New("dynamicAuth is set but no supported auth method (e.g., awsRdsIam) is configured")
}

// wrapAuthError prefixes dynamic-auth errors with a consistent label so they
Expand Down
22 changes: 22 additions & 0 deletions postgres/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,17 @@ func TestNewAuthToken(t *testing.T) {
},
wantErr: testErrRegionMissing,
},
{
name: testCaseMultipleBackend,
cfg: &Config{
Host: "h", Port: 5432, User: "u", Database: "d",
DynamicAuth: &DynamicAuthConfig{
AWSRDSIAM: &DynamicAuthAWSRDSIAM{Region: testRegion},
GCPCloudSQLIAM: &DynamicAuthGCPCloudSQLIAM{},
},
},
wantErr: testErrMultipleBackend,
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -102,6 +113,17 @@ func TestNewDynamicAuthFunc(t *testing.T) {
},
wantErr: testErrRegionMissing,
},
{
name: testCaseMultipleBackend,
cfg: &Config{
Host: "h", Port: 5432, User: "u", Database: "d",
DynamicAuth: &DynamicAuthConfig{
AzureAD: &DynamicAuthAzureAD{},
GCPCloudSQLIAM: &DynamicAuthGCPCloudSQLIAM{},
},
},
wantErr: testErrMultipleBackend,
},
}

for _, tt := range tests {
Expand Down
66 changes: 66 additions & 0 deletions postgres/azuread.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package postgres

import (
"context"
"fmt"

"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/jackc/pgx/v5"
)

// azureADScope is the OAuth2 scope Azure Database for PostgreSQL Flexible
// Server (and Single Server) requires for Entra ID (formerly Azure AD)
// token-based authentication.
const azureADScope = "https://ossrdbms-aad.database.windows.net/.default"

// azureADToken returns a single Entra ID access token usable as a PostgreSQL
// password for Azure Database for PostgreSQL.
func azureADToken(ctx context.Context) (string, error) {
cred, err := newAzureCredential()
if err != nil {
return "", wrapAuthError("azureAd", err)
}
token, err := cred.GetToken(ctx, policy.TokenRequestOptions{Scopes: []string{azureADScope}})
if err != nil {
return "", wrapAuthError("azureAd", fmt.Errorf("failed to acquire Entra ID token: %w", err))
}
return token.Token, nil
}

// azureADBeforeConnect returns a BeforeConnect hook that generates a fresh
// Entra ID token before each connection attempt. The credential is
// constructed once, at hook-construction time; azidentity caches and
// refreshes the underlying token internally, so per-connection cost is a
// single (usually cached) token acquisition.
func azureADBeforeConnect() (BeforeConnectFn, error) {
cred, err := newAzureCredential()
if err != nil {
return nil, wrapAuthError("azureAd", err)
}
return func(ctx context.Context, conn *pgx.ConnConfig) error {
token, err := cred.GetToken(ctx, policy.TokenRequestOptions{Scopes: []string{azureADScope}})
if err != nil {
return wrapAuthError("azureAd", fmt.Errorf("failed to acquire Entra ID token: %w", err))
}
conn.Password = token.Token
return nil
}, nil
}

// newAzureCredential builds the credential used to acquire Entra ID tokens.
// Construction never contacts Azure — DefaultAzureCredential resolves
// lazily, on the first GetToken call, following its normal chain
// (environment variables — including AZURE_CLIENT_ID to select a
// user-assigned managed identity — workload identity, managed identity,
// Azure CLI, ...).
func newAzureCredential() (*azidentity.DefaultAzureCredential, error) {
cred, err := azidentity.NewDefaultAzureCredential(nil)
if err != nil {
return nil, fmt.Errorf("failed to construct Azure credential: %w", err)
}
return cred, nil
}
36 changes: 36 additions & 0 deletions postgres/azuread_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package postgres

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestAzureADBeforeConnect_ReturnsHookWithoutContactingAzure verifies the
// constructor returns a non-nil hook with no error, deterministically and
// without any ambient Azure credentials: unlike GCP's
// google.DefaultTokenSource (see gcpiam.go's doc comment), azidentity's
// DefaultAzureCredential resolves lazily, on the first GetToken call, so
// construction alone never contacts Azure or requires credentials to be
// present. Actually invoking the returned hook would require real Azure
// credentials and is out of scope for unit tests — same posture as
// TestAwsRDSIAMBeforeConnect_ReturnsHookForStaticRegion.
func TestAzureADBeforeConnect_ReturnsHookWithoutContactingAzure(t *testing.T) {
t.Parallel()
fn, err := azureADBeforeConnect()
require.NoError(t, err)
assert.NotNil(t, fn)
}

// No equivalent GCP constructor test exists: unlike AWS and Azure,
// google.DefaultTokenSource (gcpCloudSQLIAMBeforeConnect's first call)
// resolves Application Default Credentials eagerly and returns an error
// immediately when none are found, rather than deferring resolution to the
// first token fetch. That makes even "does the constructor return a non-nil
// hook" environment-dependent — a machine with real GCP credentials
// configured would get a different, equally valid result. See gcpiam.go's
// newGCPTokenSource doc comment.
64 changes: 61 additions & 3 deletions postgres/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,19 @@ type Config struct {
type DynamicAuthConfig struct {
// AWSRDSIAM enables AWS RDS IAM authentication tokens.
AWSRDSIAM *DynamicAuthAWSRDSIAM

// AzureAD enables Azure Entra ID (formerly Azure AD) authentication
// tokens for Azure Database for PostgreSQL.
AzureAD *DynamicAuthAzureAD

// GCPCloudSQLIAM enables GCP Cloud SQL IAM database authentication
// tokens over a direct TCP+TLS connection. This is NOT the Cloud SQL Go
// connector (cloudsqlconn): it requires the instance to have a reachable
// IP (public, or private with direct network routing) and does not get
// Cloud SQL's automatic mTLS tunnel. Instances reachable only through
// the connector need a different integration this package does not
// provide.
GCPCloudSQLIAM *DynamicAuthGCPCloudSQLIAM
}

// DynamicAuthAWSRDSIAM configures AWS RDS IAM dynamic authentication.
Expand All @@ -104,6 +117,51 @@ type DynamicAuthAWSRDSIAM struct {
Region string
}

// DynamicAuthAzureAD configures Azure Entra ID (formerly Azure AD)
// authentication for Azure Database for PostgreSQL. It has no fields: the
// token is minted from DefaultAzureCredential's normal resolution order
// (environment variables — including AZURE_CLIENT_ID to select a
// user-assigned managed identity — workload identity, system-assigned
// managed identity, Azure CLI, ...).
type DynamicAuthAzureAD struct{}

// DynamicAuthGCPCloudSQLIAM configures GCP Cloud SQL IAM database
// authentication. It has no fields: the token is minted from ambient
// Application Default Credentials, scoped for Cloud SQL login.
type DynamicAuthGCPCloudSQLIAM struct{}

// countDynamicAuthBackends returns how many backend fields on da are set.
func countDynamicAuthBackends(da *DynamicAuthConfig) int {
n := 0
if da.AWSRDSIAM != nil {
n++
}
if da.AzureAD != nil {
n++
}
if da.GCPCloudSQLIAM != nil {
n++
}
return n
}

// singleDynamicAuthBackend rejects a DynamicAuthConfig with zero or more than
// one backend configured. Config.Validate calls this, and so do NewAuthToken
// and NewDynamicAuthFunc: a caller may build a Config and mint a one-shot
// token (for example, a migration tool) without going through Validate
// first, so the dispatchers re-check rather than assuming an ambiguous
// configuration was already caught upstream.
func singleDynamicAuthBackend(da *DynamicAuthConfig) error {
switch n := countDynamicAuthBackends(da); {
case n == 0:
return errors.New("dynamicAuth is set but no supported auth method (e.g., awsRdsIam, azureAd, gcpCloudSqlIam) is configured")
case n > 1:
return errors.New("dynamicAuth must configure exactly one auth method, but more than one is set")
default:
return nil
}
}

// Validate checks Config for required-field and consistency errors and
// returns the first violation encountered.
func (c *Config) Validate() error {
Expand All @@ -129,10 +187,10 @@ func (c *Config) Validate() error {
return fmt.Errorf("database must not contain any of %q or whitespace", databaseForbiddenChars)
}
if c.DynamicAuth != nil {
if c.DynamicAuth.AWSRDSIAM == nil {
return errors.New("dynamicAuth is set but no supported auth method (e.g., awsRdsIam) is configured")
if err := singleDynamicAuthBackend(c.DynamicAuth); err != nil {
return err
}
if c.DynamicAuth.AWSRDSIAM.Region == "" {
if c.DynamicAuth.AWSRDSIAM != nil && c.DynamicAuth.AWSRDSIAM.Region == "" {
return errors.New("dynamicAuth.awsRdsIam.region is required")
}
}
Expand Down
29 changes: 29 additions & 0 deletions postgres/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,17 @@ func TestConfig_Validate(t *testing.T) {
},
wantErr: testErrRegionConfigured,
},
{
name: testCaseMultipleBackend,
cfg: &Config{
Host: "h", Port: 5432, User: "u", Database: "d",
DynamicAuth: &DynamicAuthConfig{
AWSRDSIAM: &DynamicAuthAWSRDSIAM{Region: testRegion},
AzureAD: &DynamicAuthAzureAD{},
},
},
wantErr: testErrMultipleBackend,
},
{
name: "valid minimal config",
cfg: validConfig(),
Expand All @@ -108,6 +119,24 @@ func TestConfig_Validate(t *testing.T) {
},
},
},
{
name: "valid with Azure AD",
cfg: &Config{
Host: "h", Port: 5432, User: "u", Database: "d",
DynamicAuth: &DynamicAuthConfig{
AzureAD: &DynamicAuthAzureAD{},
},
},
},
{
name: "valid with GCP Cloud SQL IAM",
cfg: &Config{
Host: "h", Port: 5432, User: "u", Database: "d",
DynamicAuth: &DynamicAuthConfig{
GCPCloudSQLIAM: &DynamicAuthGCPCloudSQLIAM{},
},
},
},
}

for _, tt := range tests {
Expand Down
24 changes: 22 additions & 2 deletions postgres/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,40 @@ remains the caller's responsibility.
# Dynamic Authentication

Setting Config.DynamicAuth causes NewPool to install a BeforeConnect hook
that resolves a fresh credential before every connection attempt.
that resolves a fresh credential before every connection attempt. Exactly
one backend must be configured.

Currently supported backends:

- AWS RDS IAM — short-lived tokens signed with the workload's ambient
AWS credentials (env vars, EC2 instance profile, EKS web identity, …).
Region "detect" auto-discovers the region via IMDS.

Example:
- Azure Entra ID (formerly Azure AD) — OAuth2 tokens for Azure Database
for PostgreSQL, acquired via DefaultAzureCredential's normal chain
(environment variables, workload identity, managed identity, Azure
CLI, …). AZURE_CLIENT_ID selects a user-assigned managed identity.

- GCP Cloud SQL IAM — OAuth2 tokens minted from ambient Application
Default Credentials, connecting over a direct TCP+TLS connection. This
is NOT the Cloud SQL Go connector (cloudsqlconn): it requires the
instance to have a reachable IP (public, or private with direct
network routing) and does not get Cloud SQL's automatic mTLS tunnel.

Examples:

cfg.DynamicAuth = &postgres.DynamicAuthConfig{
AWSRDSIAM: &postgres.DynamicAuthAWSRDSIAM{Region: "us-east-1"},
}

cfg.DynamicAuth = &postgres.DynamicAuthConfig{
AzureAD: &postgres.DynamicAuthAzureAD{},
}

cfg.DynamicAuth = &postgres.DynamicAuthConfig{
GCPCloudSQLIAM: &postgres.DynamicAuthGCPCloudSQLIAM{},
}

For short-lived connections that cannot use a pool hook (for example
golang-migrate's one-shot migration connection), call NewAuthToken to
materialize a single token, then embed it via BuildConnectionStringWithAuth:
Expand Down
Loading