From 3285486f5550e36e59222d60003b959f105d4f4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Garamv=C3=B6lgyi?= Date: Mon, 20 Jul 2026 18:18:38 +0200 Subject: [PATCH] feat(database): IAM Auth --- go.mod | 15 +++- go.sum | 30 +++++++ internal/utils/database/README.md | 51 ++++++++++++ internal/utils/database/config.go | 7 ++ internal/utils/database/db.go | 29 ++++++- internal/utils/database/iam.go | 104 +++++++++++++++++++++++++ internal/utils/database/iam_metrics.go | 41 ++++++++++ internal/utils/database/iam_test.go | 91 ++++++++++++++++++++++ internal/utils/version.go | 2 +- 9 files changed, 367 insertions(+), 3 deletions(-) create mode 100644 internal/utils/database/README.md create mode 100644 internal/utils/database/iam.go create mode 100644 internal/utils/database/iam_metrics.go create mode 100644 internal/utils/database/iam_test.go diff --git a/go.mod b/go.mod index d901d8e3..741d1b68 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,14 @@ module github.com/scroll-tech/chain-monitor go 1.19 require ( + github.com/aws/aws-sdk-go-v2 v1.21.2 + github.com/aws/aws-sdk-go-v2/config v1.18.45 + github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.2.21 github.com/bits-and-blooms/bitset v1.11.0 github.com/gin-contrib/pprof v1.4.0 github.com/gin-gonic/gin v1.9.1 github.com/go-resty/resty/v2 v2.10.0 + github.com/jackc/pgx/v5 v5.4.3 github.com/mattn/go-colorable v0.1.13 github.com/mattn/go-isatty v0.0.19 github.com/pressly/goose/v3 v3.15.0 @@ -30,6 +34,16 @@ require ( github.com/Microsoft/hcsshim v0.11.1 // indirect github.com/StackExchange/wmi v1.2.1 // indirect github.com/VictoriaMetrics/fastcache v1.12.1 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.13.43 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.13 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.43 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.37 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.3.45 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.37 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.15.2 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.17.3 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.23.2 // indirect + github.com/aws/smithy-go v1.15.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/btcsuite/btcd v0.20.1-beta // indirect github.com/bytedance/sonic v1.9.1 // indirect @@ -75,7 +89,6 @@ require ( github.com/influxdata/line-protocol v0.0.0-20210311194329-9aa0e372d097 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect - github.com/jackc/pgx/v5 v5.4.3 // indirect github.com/jackpal/go-nat-pmp v1.0.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect diff --git a/go.sum b/go.sum index a534476e..782cae7c 100644 --- a/go.sum +++ b/go.sum @@ -43,6 +43,32 @@ github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156 h1:eMwmnE/GDgah github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/apache/arrow/go/arrow v0.0.0-20191024131854-af6fa24be0db/go.mod h1:VTxUBvSJ3s3eHAg65PNgrsn5BtqCRPdmyXh6rAfdxN0= +github.com/aws/aws-sdk-go-v2 v1.21.2 h1:+LXZ0sgo8quN9UOKXXzAWRT3FWd4NxeXWOZom9pE7GA= +github.com/aws/aws-sdk-go-v2 v1.21.2/go.mod h1:ErQhvNuEMhJjweavOYhxVkn2RUx7kQXVATHrjKtxIpM= +github.com/aws/aws-sdk-go-v2/config v1.18.45 h1:Aka9bI7n8ysuwPeFdm77nfbyHCAKQ3z9ghB3S/38zes= +github.com/aws/aws-sdk-go-v2/config v1.18.45/go.mod h1:ZwDUgFnQgsazQTnWfeLWk5GjeqTQTL8lMkoE1UXzxdE= +github.com/aws/aws-sdk-go-v2/credentials v1.13.43 h1:LU8vo40zBlo3R7bAvBVy/ku4nxGEyZe9N8MqAeFTzF8= +github.com/aws/aws-sdk-go-v2/credentials v1.13.43/go.mod h1:zWJBz1Yf1ZtX5NGax9ZdNjhhI4rgjfgsyk6vTY1yfVg= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.13 h1:PIktER+hwIG286DqXyvVENjgLTAwGgoeriLDD5C+YlQ= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.13/go.mod h1:f/Ib/qYjhV2/qdsf79H3QP/eRE4AkVyEf6sk7XfZ1tg= +github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.2.21 h1:m/oetLggG4HFTcU0CkY1uR18uKRNTm+V1XocGd3Wcxk= +github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.2.21/go.mod h1:XoCNC17AXoRDfkX2bsFsGsn036fch7ATgchnAy+PsOQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.43 h1:nFBQlGtkbPzp/NjZLuFxRqmT91rLJkgvsEQs68h962Y= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.43/go.mod h1:auo+PiyLl0n1l8A0e8RIeR8tOzYPfZZH/JNlrJ8igTQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.37 h1:JRVhO25+r3ar2mKGP7E0LDl8K9/G36gjlqca5iQbaqc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.37/go.mod h1:Qe+2KtKml+FEsQF/DHmDV+xjtche/hwoF75EG4UlHW8= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.45 h1:hze8YsjSh8Wl1rYa1CJpRmXP21BvOBuc76YhW0HsuQ4= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.45/go.mod h1:lD5M20o09/LCuQ2mE62Mb/iSdSlCNuj6H5ci7tW7OsE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.37 h1:WWZA/I2K4ptBS1kg0kV1JbBtG/umed0vwHRrmcr9z7k= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.37/go.mod h1:vBmDnwWXWxNPFRMmG2m/3MKOe+xEcMDo1tanpaWCcck= +github.com/aws/aws-sdk-go-v2/service/sso v1.15.2 h1:JuPGc7IkOP4AaqcZSIcyqLpFSqBWK32rM9+a1g6u73k= +github.com/aws/aws-sdk-go-v2/service/sso v1.15.2/go.mod h1:gsL4keucRCgW+xA85ALBpRFfdSLH4kHOVSnLMSuBECo= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.17.3 h1:HFiiRkf1SdaAmV3/BHOFZ9DjFynPHj8G/UIO1lQS+fk= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.17.3/go.mod h1:a7bHA82fyUXOm+ZSWKU6PIoBxrjSprdLoM8xPYvzYVg= +github.com/aws/aws-sdk-go-v2/service/sts v1.23.2 h1:0BkLfgeDjfZnZ+MhB3ONb01u9pwFYTCZVhlsSSBvlbU= +github.com/aws/aws-sdk-go-v2/service/sts v1.23.2/go.mod h1:Eows6e1uQEsc4ZaHANmsPRzAKcVDrcmjjWiih2+HUUQ= +github.com/aws/smithy-go v1.15.0 h1:PS/durmlzvAFpQHDs4wi4sNNP9ExsqZh6IlfdHXgKK8= +github.com/aws/smithy-go v1.15.0/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -210,6 +236,7 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -272,6 +299,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -808,6 +837,7 @@ gopkg.in/urfave/cli.v1 v1.20.0/go.mod h1:vuBzUtMdQeixQj8LVd+/98pzhxNGQoyuPBlsXHO gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/internal/utils/database/README.md b/internal/utils/database/README.md new file mode 100644 index 00000000..524b172c --- /dev/null +++ b/internal/utils/database/README.md @@ -0,0 +1,51 @@ +# database + +Connection helpers for the chain-monitor Postgres database (GORM + pgx). + +## AWS RDS IAM authentication + +`InitDB` can authenticate to AWS RDS/Aurora PostgreSQL using short-lived IAM +auth tokens instead of a DSN password. This removes the need to rotate database +passwords: access is granted via IAM and tokens are regenerated automatically +(they expire every 15 minutes) for each new connection. + +Enable it in the `db_config` block: + +```json +{ + "driver_name": "postgres", + "dsn": "postgres://svc_user@mydb.abc123.us-east-1.rds.amazonaws.com:5432/scroll?sslmode=require", + "maxOpenNum": 200, + "maxIdleNum": 20, + "useIAMAuth": true, + "awsRegion": "us-east-1" +} +``` + +RDS IAM tokens are signed per `(host, port, db-user)`: the DSN's DB user must be +granted `rds_iam`, and the workload's IAM role must have `rds-db:connect` on the +corresponding `dbuser` resource. + +Notes: + +- Omit the password from the `dsn`; it is supplied by the generated IAM token. +- `awsRegion` is optional — when empty it is resolved from the default AWS + config chain (e.g. the `AWS_REGION` environment variable). AWS credentials come + from the default chain too (env vars, EKS IRSA / EC2 instance role), which + self-refreshes. +- IAM auth requires TLS, so the DSN must set `sslmode=require` or higher. + `sslmode=disable`/`allow`/`prefer` (including an unset `sslmode`, which + defaults to `prefer`) are rejected at startup rather than silently sending the + token over a connection that may fall back to plaintext. `require` encrypts + but does not verify the server certificate; for that, use `sslmode=verify-full` + with `sslrootcert` pointing at the + [RDS CA bundle](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html). +- Leaving `useIAMAuth` unset preserves the previous password-based behavior. + +### Metrics + +When IAM auth is active the following Prometheus metrics are exported: + +- `database_rds_iam_token_total` — tokens generated (one per new connection). +- `database_rds_iam_token_failure_total` — token generation failures. +- `database_rds_iam_token_duration_seconds` — token generation latency. diff --git a/internal/utils/database/config.go b/internal/utils/database/config.go index 0a99a6b1..887f46ee 100644 --- a/internal/utils/database/config.go +++ b/internal/utils/database/config.go @@ -8,4 +8,11 @@ type Config struct { MaxOpenNum int `json:"maxOpenNum"` MaxIdleNum int `json:"maxIdleNum"` + + // UseIAMAuth authenticates to AWS RDS/Aurora with short-lived IAM tokens + // instead of a DSN password. The DSN should omit the password and enable TLS. + UseIAMAuth bool `json:"useIAMAuth"` + // AWSRegion signs the IAM tokens. Optional; falls back to the default AWS + // config chain (e.g. AWS_REGION) when empty. + AWSRegion string `json:"awsRegion"` } diff --git a/internal/utils/database/db.go b/internal/utils/database/db.go index af275721..18ed4838 100644 --- a/internal/utils/database/db.go +++ b/internal/utils/database/db.go @@ -49,7 +49,12 @@ func InitDB(config *Config) (*gorm.DB, error) { tmpGormLogger := gormLogger{ gethLogger: log.Root(), } - db, err := gorm.Open(postgres.Open(config.DSN), &gorm.Config{ + dialector, err := newDialector(config) + if err != nil { + return nil, err + } + + db, err := gorm.Open(dialector, &gorm.Config{ Logger: &tmpGormLogger, NowFunc: func() time.Time { // why set time to UTC. @@ -69,12 +74,34 @@ func InitDB(config *Config) (*gorm.DB, error) { return nil, pingErr } + if config.UseIAMAuth { + // IAM tokens expire after 15 minutes and a fresh one is minted per new + // connection, so recycle connections well within that window. + sqlDB.SetConnMaxLifetime(time.Minute * 10) + sqlDB.SetConnMaxIdleTime(time.Minute * 5) + } + sqlDB.SetMaxOpenConns(config.MaxOpenNum) sqlDB.SetMaxIdleConns(config.MaxIdleNum) return db, nil } +// newDialector builds the gorm postgres dialector. With IAM auth it wraps a +// token-refreshing connector in a *sql.DB; otherwise it opens the DSN directly. +func newDialector(config *Config) (gorm.Dialector, error) { + if !config.UseIAMAuth { + return postgres.Open(config.DSN), nil + } + + log.Info("connecting to database with AWS RDS IAM auth", "region", config.AWSRegion) + connector, err := NewRDSIAMConnector(context.Background(), config.DSN, config.AWSRegion) + if err != nil { + return nil, err + } + return postgres.New(postgres.Config{Conn: sql.OpenDB(connector)}), nil +} + // CloseDB close the db handler. notice the db handler only can close when then program exit. func CloseDB(db *gorm.DB) error { sqlDB, err := db.DB() diff --git a/internal/utils/database/iam.go b/internal/utils/database/iam.go new file mode 100644 index 00000000..ab95943c --- /dev/null +++ b/internal/utils/database/iam.go @@ -0,0 +1,104 @@ +package database + +import ( + "context" + "database/sql/driver" + "fmt" + "net" + "strconv" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/feature/rds/auth" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/stdlib" +) + +// rdsIAMConnector is a driver.Connector that mints a fresh AWS RDS IAM auth +// token for every new connection (tokens expire after 15 minutes; already-open +// connections keep working since the token is only checked at connect time). +type rdsIAMConnector struct { + base *pgx.ConnConfig // parsed DSN; Password is set to the token per-connect + endpoint string // host:port the token is signed for + region string // AWS region the token is signed for + creds aws.CredentialsProvider // refreshes credentials internally + metrics *rdsIAMMetrics +} + +// NewRDSIAMConnector returns a connector that injects a fresh RDS IAM token as +// the password on each new connection. region falls back to the default AWS +// config chain (e.g. AWS_REGION) when empty. +// +// IAM auth requires TLS, so a DSN with sslmode=disable is rejected rather than +// silently downgraded; sslmode=verify-full (+ sslrootcert) is recommended. +func NewRDSIAMConnector(ctx context.Context, dsn, region string) (driver.Connector, error) { + base, err := pgx.ParseConfig(dsn) + if err != nil { + return nil, fmt.Errorf("rds iam: parse dsn: %w", err) + } + if base.User == "" { + return nil, fmt.Errorf("rds iam: dsn must specify a database user") + } + + var opts []func(*awsconfig.LoadOptions) error + if region != "" { + opts = append(opts, awsconfig.WithRegion(region)) + } + awsCfg, err := awsconfig.LoadDefaultConfig(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("rds iam: load aws config: %w", err) + } + if region == "" { + region = awsCfg.Region + } + if region == "" { + return nil, fmt.Errorf("rds iam: aws region is not set (configure awsRegion or AWS_REGION)") + } + + // Fail closed on any path that could send the token (which is the password) + // in cleartext or to the wrong host. The token is scoped to a single + // host:port, so reject: + // - TLS-less DSNs: sslmode=disable/allow leave the primary TLSConfig nil; + // - plaintext fallbacks: sslmode=prefer (the pgx default) keeps a non-TLS + // entry in Fallbacks that pgx retries on if SSL negotiation fails; + // - multi-host DSNs: a fallback host the single token is not signed for. + if base.TLSConfig == nil { + return nil, fmt.Errorf("rds iam: TLS is required, set sslmode=require or higher (verify-full recommended) in the dsn") + } + for _, fb := range base.Fallbacks { + if fb.TLSConfig == nil { + return nil, fmt.Errorf("rds iam: dsn allows a plaintext fallback (sslmode=prefer); set sslmode=require or higher") + } + if fb.Host != base.Host || fb.Port != base.Port { + return nil, fmt.Errorf("rds iam: multi-host dsn is not supported; the IAM token is scoped to a single host:port") + } + } + + return &rdsIAMConnector{ + base: base, + endpoint: net.JoinHostPort(base.Host, strconv.Itoa(int(base.Port))), + region: region, + creds: awsCfg.Credentials, + metrics: initRDSIAMMetrics(), + }, nil +} + +// Connect generates a fresh IAM auth token and opens a new connection with it. +func (c *rdsIAMConnector) Connect(ctx context.Context) (driver.Conn, error) { + start := time.Now() + token, err := auth.BuildAuthToken(ctx, c.endpoint, c.region, c.base.User, c.creds) + c.metrics.tokenDuration.Observe(time.Since(start).Seconds()) + if err != nil { + c.metrics.tokenFailureTotal.Inc() + return nil, fmt.Errorf("rds iam: build auth token: %w", err) + } + c.metrics.tokenTotal.Inc() + + cfg := c.base.Copy() + cfg.Password = token + return stdlib.GetConnector(*cfg).Connect(ctx) +} + +// Driver returns the underlying pgx stdlib driver. +func (c *rdsIAMConnector) Driver() driver.Driver { return stdlib.GetDefaultDriver() } diff --git a/internal/utils/database/iam_metrics.go b/internal/utils/database/iam_metrics.go new file mode 100644 index 00000000..7e1abccc --- /dev/null +++ b/internal/utils/database/iam_metrics.go @@ -0,0 +1,41 @@ +package database + +import ( + "sync" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +type rdsIAMMetrics struct { + tokenTotal prometheus.Counter + tokenFailureTotal prometheus.Counter + tokenDuration prometheus.Histogram +} + +var ( + initRDSIAMMetricsOnce sync.Once + rdsIAMMetric *rdsIAMMetrics +) + +func initRDSIAMMetrics() *rdsIAMMetrics { + initRDSIAMMetricsOnce.Do(func() { + reg := prometheus.DefaultRegisterer + rdsIAMMetric = &rdsIAMMetrics{ + tokenTotal: promauto.With(reg).NewCounter(prometheus.CounterOpts{ + Name: "database_rds_iam_token_total", + Help: "Total number of AWS RDS IAM auth tokens generated (one per new connection).", + }), + tokenFailureTotal: promauto.With(reg).NewCounter(prometheus.CounterOpts{ + Name: "database_rds_iam_token_failure_total", + Help: "Total number of AWS RDS IAM auth token generation failures.", + }), + tokenDuration: promauto.With(reg).NewHistogram(prometheus.HistogramOpts{ + Name: "database_rds_iam_token_duration_seconds", + Help: "Latency of AWS RDS IAM auth token generation; spikes indicate a credential refresh.", + Buckets: []float64{.0001, .001, .005, .01, .05, .1, .5, 1, 5}, + }), + } + }) + return rdsIAMMetric +} diff --git a/internal/utils/database/iam_test.go b/internal/utils/database/iam_test.go new file mode 100644 index 00000000..eba9e639 --- /dev/null +++ b/internal/utils/database/iam_test.go @@ -0,0 +1,91 @@ +package database + +import ( + "context" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewRDSIAMConnector(t *testing.T) { + ctx := context.Background() + + t.Run("parses endpoint, user and region", func(t *testing.T) { + c, err := NewRDSIAMConnector(ctx, + "postgres://svc_user@mydb.abc123.us-east-1.rds.amazonaws.com:5432/scroll?sslmode=verify-full", + "us-east-1") + require.NoError(t, err) + + conn, ok := c.(*rdsIAMConnector) + require.True(t, ok) + assert.Equal(t, "mydb.abc123.us-east-1.rds.amazonaws.com:5432", conn.endpoint) + assert.Equal(t, "svc_user", conn.base.User) + assert.Equal(t, "us-east-1", conn.region) + // TLS requested in the DSN must be preserved. + require.NotNil(t, conn.base.TLSConfig) + }) + + t.Run("defaults port to 5432", func(t *testing.T) { + c, err := NewRDSIAMConnector(ctx, + "postgres://svc_user@mydb.example.rds.amazonaws.com/scroll?sslmode=require", + "eu-west-1") + require.NoError(t, err) + assert.Equal(t, "mydb.example.rds.amazonaws.com:5432", c.(*rdsIAMConnector).endpoint) + }) + + t.Run("rejects sslmode=disable", func(t *testing.T) { + // IAM auth requires TLS; a plaintext DSN must fail closed rather than + // silently sending the token in cleartext. + _, err := NewRDSIAMConnector(ctx, + "postgres://svc_user@mydb.example.rds.amazonaws.com:5432/scroll?sslmode=disable", + "us-east-1") + assert.ErrorContains(t, err, "TLS") + }) + + t.Run("rejects sslmode=prefer (plaintext fallback)", func(t *testing.T) { + // prefer keeps a non-TLS entry in Fallbacks that pgx would retry on, + // which would leak the token in cleartext. + _, err := NewRDSIAMConnector(ctx, + "postgres://svc_user@mydb.example.rds.amazonaws.com:5432/scroll?sslmode=prefer", + "us-east-1") + assert.ErrorContains(t, err, "plaintext") + }) + + t.Run("rejects sslmode=allow (plaintext primary)", func(t *testing.T) { + _, err := NewRDSIAMConnector(ctx, + "postgres://svc_user@mydb.example.rds.amazonaws.com:5432/scroll?sslmode=allow", + "us-east-1") + assert.Error(t, err) + }) + + t.Run("rejects multi-host dsn", func(t *testing.T) { + // The token is signed for a single host:port, so failover would fail auth. + _, err := NewRDSIAMConnector(ctx, + "host=host1.rds.amazonaws.com,host2.rds.amazonaws.com port=5432 user=svc_user dbname=scroll sslmode=require", + "us-east-1") + assert.ErrorContains(t, err, "multi-host") + }) + + t.Run("region falls back to explicit empty error when unresolved", func(t *testing.T) { + // With no region argument, no AWS_REGION in the environment, and no + // shared config file, region resolution must fail rather than silently + // signing with an empty region. + t.Setenv("AWS_REGION", "") + t.Setenv("AWS_DEFAULT_REGION", "") + // Isolate from any ~/.aws/config on the dev/CI machine, which would + // otherwise resolve a default region and make this test nondeterministic. + t.Setenv("AWS_CONFIG_FILE", filepath.Join(t.TempDir(), "no-config")) + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", filepath.Join(t.TempDir(), "no-creds")) + _, err := NewRDSIAMConnector(ctx, + "postgres://svc_user@mydb.example.rds.amazonaws.com:5432/scroll?sslmode=require", + "") + assert.ErrorContains(t, err, "region") + }) + + t.Run("rejects invalid dsn", func(t *testing.T) { + _, err := NewRDSIAMConnector(ctx, "::not a dsn::", "us-east-1") + assert.Error(t, err) + }) +} diff --git a/internal/utils/version.go b/internal/utils/version.go index 432fade2..b2acfc68 100644 --- a/internal/utils/version.go +++ b/internal/utils/version.go @@ -5,7 +5,7 @@ import ( "runtime/debug" ) -var tag = "v1.2.1" +var tag = "v1.2.2" var commit = func() string { if info, ok := debug.ReadBuildInfo(); ok {