diff --git a/README.rst b/README.rst index b468b09c99..395f16c2c5 100644 --- a/README.rst +++ b/README.rst @@ -217,6 +217,10 @@ The following options can be configured on the server: storage.session.redis.sentinel.username Username for authenticating to Redis Sentinels. storage.session.redis.tls.truststorefile PEM file containing the trusted CA certificate(s) for authenticating remote Redis session servers. Can only be used when connecting over TLS (use 'rediss://' as scheme in address). storage.sql.connection Connection string for the SQL database. If not set it, defaults to a SQLite database stored inside the configured data directory. Note: using SQLite is not recommended in production environments. If using SQLite anyways, remember to enable foreign keys ('_foreign_keys=on') and the write-ahead-log ('_journal_mode=WAL'). + storage.sql.rdsiam.enabled false Enable AWS RDS IAM authentication for the SQL database connection. When enabled, the node will use temporary IAM tokens instead of passwords. Requires the connection string to be a PostgreSQL or MySQL RDS endpoint without a password. + storage.sql.rdsiam.region AWS region where the RDS instance is located (e.g., 'us-east-1). Required when RDS IAM authentication is enabled. + storage.sql.rdsiam.dbuser Database username for IAM authentication. If not specified, the username from the connection string will be used. The database user must be created with IAM authentication enabled. + storage.sql.rdsiam.tokenrefreshinterval 14m0s Interval at which to refresh the IAM authentication token. RDS tokens are valid for 15 minutes, so the default is 14 minutes to ensure tokens are refreshed before expiry. Specified as Golang duration (e.g. 10m, 1h). **policy** policy.directory ./config/policy Directory to read policy files from. Policy files are JSON files that contain a scope to PresentationDefinition mapping. ======================================== =================================================================================================================================================================================================================================================================================================================================================================================================================================================================== ============================================================================================================================================================================================================================================================================================================================================ diff --git a/docs/pages/deployment/storage.rst b/docs/pages/deployment/storage.rst index ef83aa4b3b..0d3e4c572c 100644 --- a/docs/pages/deployment/storage.rst +++ b/docs/pages/deployment/storage.rst @@ -44,6 +44,42 @@ Refer to the documentation of the driver for the database you are using for the Usage of SQLite is not recommended for production environments. Connections to a SQLite DB are restricted to 1, which will lead to severe performance reduction. +AWS RDS IAM authentication +========================== + +Nuts supports AWS RDS IAM database authentication for SQL storage. +This replaces static database passwords with short-lived IAM auth tokens generated by the AWS SDK. + +Support scope: + +- Supported SQL connection schemes: ``postgres://`` and ``mysql://``. +- Not supported for RDS IAM: ``sqlite:``, ``sqlserver://`` and ``azuresql://``. +- If ``storage.sql.rdsiam.dbuser`` is set, that database user is used; otherwise the username from ``storage.sql.connection`` is used. + +Configuration in the Nuts node: + +.. code-block:: yaml + + storage: + sql: + connection: "postgres://dbuser@mydb.eu-west-1.rds.amazonaws.com:5432/nuts?sslmode=require" + rdsiam: + enabled: true + region: "eu-west-1" + dbuser: "dbuser" # optional + +Configuration keys: + +- ``storage.sql.rdsiam.enabled``: enables RDS IAM authentication. +- ``storage.sql.rdsiam.region``: AWS region of the RDS instance. +- ``storage.sql.rdsiam.dbuser``: optional IAM-enabled DB user to use. +- ``storage.sql.rdsiam.tokenrefreshinterval``: optional token refresh interval, defaults to ``14m``. + +When enabled, the node automatically refreshes IAM tokens and uses new tokens for fresh SQL connections. + +For generic AWS RDS IAM setup (enabling IAM DB auth on the instance, IAM policies, and DB user grants), +refer to the AWS documentation: `IAM database authentication for MariaDB, MySQL, and PostgreSQL `_. + Session storage *************** diff --git a/go.mod b/go.mod index 5ca679a74c..7265207b8c 100644 --- a/go.mod +++ b/go.mod @@ -199,6 +199,8 @@ require ( ) require ( + github.com/aws/aws-sdk-go-v2/config v1.32.7 + github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.17 github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 github.com/daangn/minimemcached v1.2.0 github.com/eko/gocache/lib/v4 v4.2.3 @@ -219,6 +221,19 @@ require ( ) require ( + github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.7 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect + github.com/aws/smithy-go v1.24.0 // indirect github.com/benbjohnson/clock v1.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect diff --git a/go.sum b/go.sum index 6c70c6d5a5..818922e473 100644 --- a/go.sum +++ b/go.sum @@ -47,6 +47,36 @@ github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7D github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/avast/retry-go/v4 v4.7.0 h1:yjDs35SlGvKwRNSykujfjdMxMhMQQM0TnIjJaHB+Zio= github.com/avast/retry-go/v4 v4.7.0/go.mod h1:ZMPDa3sY2bKgpLtap9JRUgk2yTAba7cgiFhqxY2Sg6Q= +github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= +github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY= +github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= +github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.17 h1:BTFAHrUqHRo9KRVXojX/uU/ht9tyYH2TN0NfPiyLfqA= +github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.17/go.mod h1:8Xhnm3tJUGk9ernojWk4VOgEsPhDkeNOrY+IVRL6eqY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= diff --git a/storage/cmd/cmd.go b/storage/cmd/cmd.go index bba34f4b91..70505f0752 100644 --- a/storage/cmd/cmd.go +++ b/storage/cmd/cmd.go @@ -47,6 +47,17 @@ func FlagSet() *pflag.FlagSet { "If not set it, defaults to a SQLite database stored inside the configured data directory. "+ "Note: using SQLite is not recommended in production environments. "+ "If using SQLite anyways, remember to enable foreign keys ('_foreign_keys=on') and the write-ahead-log ('_journal_mode=WAL').") + flagSet.Bool("storage.sql.rdsiam.enabled", defs.SQL.RDSIAM.Enabled, "Enable AWS RDS IAM authentication for the SQL database connection. "+ + "When enabled, the node will use temporary IAM tokens instead of passwords. "+ + "Requires the connection string to be a PostgreSQL or MySQL RDS endpoint without a password.") + flagSet.String("storage.sql.rdsiam.region", defs.SQL.RDSIAM.Region, "AWS region where the RDS instance is located (e.g., 'us-east-1). "+ + "Required when RDS IAM authentication is enabled.") + flagSet.String("storage.sql.rdsiam.dbuser", defs.SQL.RDSIAM.DBUser, "Database username for IAM authentication. "+ + "If not specified, the username from the connection string will be used. "+ + "The database user must be created with IAM authentication enabled.") + flagSet.Duration("storage.sql.rdsiam.tokenrefreshinterval", defs.SQL.RDSIAM.TokenRefreshInterval, "Interval at which to refresh the IAM authentication token. "+ + "RDS tokens are valid for 15 minutes, so set this to ensure tokens are refreshed before expiry. "+ + "Specified as Golang duration (e.g. 10m, 1h).") // session flagSet.StringSlice("storage.session.memcached.address", defs.Session.Memcached.Address, "List of Memcached server addresses. These can be a simple 'host:port' or a Memcached connection URL with scheme, auth and other options.") diff --git a/storage/config.go b/storage/config.go index 49ff4859e7..b4c617da70 100644 --- a/storage/config.go +++ b/storage/config.go @@ -18,6 +18,8 @@ package storage +import "time" + // Config specifies config for the storage engine. type Config struct { BBolt BBoltConfig `koanf:"bbolt"` @@ -28,7 +30,13 @@ type Config struct { // DefaultConfig returns the default configuration for the module. func DefaultConfig() Config { - return Config{} + return Config{ + SQL: SQLConfig{ + RDSIAM: RDSIAMConfig{ + TokenRefreshInterval: 14 * time.Minute, + }, + }, + } } // SQLConfig specifies config for the SQL storage engine. @@ -36,6 +44,23 @@ type SQLConfig struct { // ConnectionString is the connection string for the SQL database. // This string may contain secrets (user:password), so should never be logged. ConnectionString string `koanf:"connection"` + // RDSIAM specifies AWS RDS IAM authentication configuration. + RDSIAM RDSIAMConfig `koanf:"rdsiam"` +} + +// RDSIAMConfig specifies config for AWS RDS IAM authentication. +type RDSIAMConfig struct { + // Enabled determines whether to use AWS IAM authentication for RDS. + Enabled bool `koanf:"enabled"` + // Region is the AWS region where the RDS instance is located. + // If not specified, it will be loaded from the AWS SDK default configuration. + Region string `koanf:"region"` + // DBUser is the database user for IAM authentication. + // If not specified, the user from the connection string will be used. + DBUser string `koanf:"dbuser"` + // TokenRefreshInterval is how often to refresh the IAM token (default: 14 minutes). + // RDS tokens are valid for 15 minutes, so we refresh before expiry. + TokenRefreshInterval time.Duration `koanf:"tokenrefreshinterval"` } // SessionConfig specifies config for the session storage engine. diff --git a/storage/config_test.go b/storage/config_test.go new file mode 100644 index 0000000000..becd37fa1f --- /dev/null +++ b/storage/config_test.go @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2026 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package storage + +import ( + "strings" + "testing" + "time" + + "github.com/knadh/koanf/providers/env" + "github.com/knadh/koanf/v2" + "github.com/stretchr/testify/assert" +) + +func TestRDSIAMConfig_EnvironmentVariables(t *testing.T) { + t.Run("environment variables with dots notation", func(t *testing.T) { + // Set environment variables using the NUTS_ prefix with underscores + // These should map to dot notation in the config + t.Setenv("NUTS_STORAGE_SQL_RDSIAM_ENABLED", "true") + t.Setenv("NUTS_STORAGE_SQL_RDSIAM_REGION", "us-east-1") + t.Setenv("NUTS_STORAGE_SQL_RDSIAM_DBUSER", "test-user") + + // Load config using the same pattern as core/config.go + configMap := koanf.New(".") + e := env.ProviderWithValue("NUTS_", ".", func(rawKey string, rawValue string) (string, interface{}) { + key := strings.Replace(strings.ToLower(strings.TrimPrefix(rawKey, "NUTS_")), "_", ".", -1) + return key, rawValue + }) + err := configMap.Load(e, nil) + assert.NoError(t, err) + + // Debug: print all keys + t.Logf("Keys in configMap: %v", configMap.Keys()) + + // Verify the raw keys are correct + assert.Equal(t, true, configMap.Bool("storage.sql.rdsiam.enabled")) + assert.Equal(t, "us-east-1", configMap.String("storage.sql.rdsiam.region")) + assert.Equal(t, "test-user", configMap.String("storage.sql.rdsiam.dbuser")) + + // Unmarshal into config struct + var config Config + err = configMap.UnmarshalWithConf("storage", &config, koanf.UnmarshalConf{ + FlatPaths: false, + }) + assert.NoError(t, err) + + // Verify the values are correctly loaded + assert.True(t, config.SQL.RDSIAM.Enabled) + assert.Equal(t, "us-east-1", config.SQL.RDSIAM.Region) + assert.Equal(t, "test-user", config.SQL.RDSIAM.DBUser) + }) + + t.Run("environment variables should map to correct config keys", func(t *testing.T) { + // This test verifies that the koanf tags in the structs are correct + // and that environment variables map properly to the config structure + t.Setenv("NUTS_STORAGE_SQL_CONNECTION", "postgres://user@host:5432/db") + t.Setenv("NUTS_STORAGE_SQL_RDSIAM_ENABLED", "true") + t.Setenv("NUTS_STORAGE_SQL_RDSIAM_REGION", "eu-west-1") + t.Setenv("NUTS_STORAGE_SQL_RDSIAM_DBUSER", "nuts-node") + + configMap := koanf.New(".") + e := env.ProviderWithValue("NUTS_", ".", func(rawKey string, rawValue string) (string, interface{}) { + key := strings.Replace(strings.ToLower(strings.TrimPrefix(rawKey, "NUTS_")), "_", ".", -1) + return key, rawValue + }) + err := configMap.Load(e, nil) + assert.NoError(t, err) + + var config Config + err = configMap.UnmarshalWithConf("storage", &config, koanf.UnmarshalConf{ + FlatPaths: false, + }) + assert.NoError(t, err) + + assert.Equal(t, "postgres://user@host:5432/db", config.SQL.ConnectionString) + assert.True(t, config.SQL.RDSIAM.Enabled) + assert.Equal(t, "eu-west-1", config.SQL.RDSIAM.Region) + assert.Equal(t, "nuts-node", config.SQL.RDSIAM.DBUser) + }) +} + +func TestDefaultConfig_RDSIAMTokenRefreshInterval(t *testing.T) { + config := DefaultConfig() + assert.Equal(t, 14*time.Minute, config.SQL.RDSIAM.TokenRefreshInterval) +} diff --git a/storage/engine.go b/storage/engine.go index 0636d493b1..dd63b4fd53 100644 --- a/storage/engine.go +++ b/storage/engine.go @@ -20,6 +20,7 @@ package storage import ( "context" + "database/sql" "errors" "fmt" "os" @@ -71,6 +72,7 @@ type engine struct { sqlDB *gorm.DB config Config sqlMigrationLogger goose.Logger + rdsIAMAuth *rdsIAMAuthenticator } func (e *engine) Config() interface{} { @@ -117,6 +119,10 @@ func (e *engine) CheckHealth() map[string]core.Health { } func (e *engine) Start() error { + // Start background token refresh for RDS IAM if enabled + if e.rdsIAMAuth != nil { + go e.refreshRDSIAMTokenPeriodically() + } return nil } @@ -248,19 +254,57 @@ func (e *engine) initSQLDatabase(strictmode bool) error { connectionString = sqliteConnectionString(e.datadir) } + // Handle RDS IAM authentication if enabled + var err error + if e.config.SQL.RDSIAM.Enabled { + var authenticator *rdsIAMAuthenticator + connectionString, authenticator, err = modifyConnectionStringForRDSIAM(context.Background(), connectionString, e.config.SQL.RDSIAM) + if err != nil { + return fmt.Errorf("failed to configure RDS IAM authentication: %w", err) + } + e.rdsIAMAuth = authenticator + log.Logger().Info("AWS RDS IAM authentication enabled for SQL database") + } + // Find right SQL adapter for ORM and migrations dbType := strings.Split(connectionString, ":")[0] - if dbType == "sqlite" { + + switch dbType { + case "sqlite": connectionString = connectionString[strings.Index(connectionString, ":")+1:] - } else if dbType == "mysql" || dbType == "azuresql" { + case "mysql", "azuresql": // These drivers need their connection string without the driver:// prefix. idx := strings.Index(connectionString, "://") connectionString = connectionString[idx+3:] } - db, err := goose.OpenDBWithDriver(dbType, connectionString) - if err != nil { - return err + // For postgres, keep the full URL format (postgres://user:password@host...) + + var db *sql.DB + if e.rdsIAMAuth != nil { + // For RDS IAM, use a custom connector that refreshes tokens automatically + connector, err := createRDSIAMConnector(dbType, connectionString, e.rdsIAMAuth) + if err != nil { + return fmt.Errorf("failed to create RDS IAM connector: %w", err) + } + db = sql.OpenDB(connector) + } else { + // Normal connection without RDS IAM + var err error + db, err = goose.OpenDBWithDriver(dbType, connectionString) + if err != nil { + return err + } } + + // For RDS IAM authentication, set connection lifetime to be shorter than token refresh interval + // This ensures connections are closed and reopened with fresh tokens + if e.rdsIAMAuth != nil { + // Set max connection lifetime to 13 minutes (token refreshes every 14 minutes, valid for 15 minutes) + // This ensures all connections are closed before the token expires + maxLifetime := e.rdsIAMAuth.config.TokenRefreshInterval - time.Minute + db.SetConnMaxLifetime(maxLifetime) + } + var dialect goose.Dialect gormConfig := &gorm.Config{ TranslateError: true, @@ -453,3 +497,23 @@ func (m logrusInfoLogWriter) Printf(format string, v ...interface{}) { func (m logrusInfoLogWriter) Fatalf(format string, v ...interface{}) { log.Logger().Errorf(format, v...) } + +// refreshRDSIAMTokenPeriodically runs in the background and periodically refreshes the RDS IAM token +func (e *engine) refreshRDSIAMTokenPeriodically() { + if e.rdsIAMAuth == nil { + return + } + + ticker := time.NewTicker(e.rdsIAMAuth.config.TokenRefreshInterval) + defer ticker.Stop() + + for range ticker.C { + // Refresh the token in the authenticator + // The connector will automatically use the fresh token for new connections + if err := e.rdsIAMAuth.refreshToken(context.Background()); err != nil { + log.Logger().WithError(err).Error("Failed to refresh RDS IAM token") + } else { + log.Logger().Debug("Successfully refreshed RDS IAM token") + } + } +} diff --git a/storage/rds_iam.go b/storage/rds_iam.go new file mode 100644 index 0000000000..54ae89b0f1 --- /dev/null +++ b/storage/rds_iam.go @@ -0,0 +1,254 @@ +/* + * Copyright (C) 2026 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package storage + +import ( + "context" + "database/sql" + "database/sql/driver" + "fmt" + "net/url" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/feature/rds/auth" + _ "github.com/jackc/pgx/v5/stdlib" // Import postgres driver for sql.Open + "github.com/nuts-foundation/nuts-node/storage/log" +) + +var loadAWSConfigForRegion = func(ctx context.Context, region string) (aws.Config, error) { + return config.LoadDefaultConfig(ctx, config.WithRegion(region)) +} + +var buildRDSAuthToken = func(ctx context.Context, endpoint, region, dbUser string, credentials aws.CredentialsProvider) (string, error) { + return auth.BuildAuthToken(ctx, endpoint, region, dbUser, credentials) +} + +// rdsIAMAuthenticator handles AWS RDS IAM authentication +type rdsIAMAuthenticator struct { + config RDSIAMConfig + endpoint string + currentToken string + lastRefresh time.Time + baseConnectionString string // Connection string without password +} + +// newRDSIAMAuthenticator creates a new RDS IAM authenticator +func newRDSIAMAuthenticator(cfg RDSIAMConfig, endpoint, baseConnStr string) *rdsIAMAuthenticator { + return &rdsIAMAuthenticator{ + config: cfg, + endpoint: endpoint, + baseConnectionString: baseConnStr, + } +} + +// getToken retrieves or refreshes the IAM authentication token +func (a *rdsIAMAuthenticator) getToken(ctx context.Context) (string, error) { + // Refresh token if needed + if time.Since(a.lastRefresh) > a.config.TokenRefreshInterval { + if err := a.refreshToken(ctx); err != nil { + return "", fmt.Errorf("failed to refresh RDS IAM token: %w", err) + } + } + return a.currentToken, nil +} + +// refreshToken generates a new IAM authentication token +func (a *rdsIAMAuthenticator) refreshToken(ctx context.Context) error { + // Load AWS configuration + cfg, err := loadAWSConfigForRegion(ctx, a.config.Region) + if err != nil { + return fmt.Errorf("failed to load AWS config: %w", err) + } + + // Build authentication token + authToken, err := buildRDSAuthToken(ctx, a.endpoint, a.config.Region, a.config.DBUser, cfg.Credentials) + if err != nil { + return fmt.Errorf("failed to build auth token: %w", err) + } + + a.currentToken = authToken + a.lastRefresh = time.Now() + + return nil +} + +// modifyConnectionStringForRDSIAM modifies the connection string to use AWS RDS IAM authentication +// It extracts the endpoint, removes password if present, and sets up the IAM authenticator +func modifyConnectionStringForRDSIAM(ctx context.Context, connectionString string, iamConfig RDSIAMConfig) (string, *rdsIAMAuthenticator, error) { + if !iamConfig.Enabled { + return connectionString, nil, nil + } + + // Parse connection string to extract endpoint + // Support both postgres:// and mysql:// formats + var endpoint, modifiedConnectionString string + var err error + + if strings.HasPrefix(connectionString, "postgres://") { + endpoint, modifiedConnectionString, err = parseConnectionStringForRDSIAM(connectionString, iamConfig) + } else if strings.HasPrefix(connectionString, "mysql://") { + endpoint, modifiedConnectionString, err = parseConnectionStringForRDSIAM(connectionString, iamConfig) + } else { + return "", nil, fmt.Errorf("RDS IAM authentication is only supported for postgres:// and mysql:// connection strings") + } + + if err != nil { + return "", nil, err + } + + // Create authenticator + authenticator := newRDSIAMAuthenticator(iamConfig, endpoint, modifiedConnectionString) + + // Generate initial token + if err := authenticator.refreshToken(ctx); err != nil { + return "", nil, fmt.Errorf("failed to generate initial RDS IAM token: %w", err) + } + + // Inject token into connection string + modifiedConnectionString, err = injectPasswordIntoConnectionString(modifiedConnectionString, authenticator.currentToken) + if err != nil { + return "", nil, fmt.Errorf("failed to inject RDS IAM token into connection string: %w", err) + } + + return modifiedConnectionString, authenticator, nil +} + +// getCurrentConnectionString returns the connection string with the current (fresh) token +func (a *rdsIAMAuthenticator) getCurrentConnectionString(ctx context.Context) (string, error) { + // Refresh token if needed + if time.Since(a.lastRefresh) > a.config.TokenRefreshInterval { + if err := a.refreshToken(ctx); err != nil { + return "", fmt.Errorf("failed to refresh RDS IAM token: %w", err) + } + } + + // Inject current token into connection string + connectionString, err := injectPasswordIntoConnectionString(a.baseConnectionString, a.currentToken) + if err != nil { + return "", fmt.Errorf("failed to inject RDS IAM token into connection string: %w", err) + } + + return connectionString, nil +} + +// parseConnectionStringForRDSIAM parses a connection string, extracts the endpoint and normalizes username/password for IAM usage. +func parseConnectionStringForRDSIAM(connectionString string, iamConfig RDSIAMConfig) (endpoint, modified string, err error) { + var username *string + if iamConfig.DBUser != "" { + username = &iamConfig.DBUser + } + + modified, endpoint, err = updateConnectionStringCredentials(connectionString, username, nil) + if err != nil { + return "", "", fmt.Errorf("failed to parse connection string: %w", err) + } + + return endpoint, modified, nil +} + +// updateConnectionStringCredentials parses and updates username/password while preserving URL semantics. +func updateConnectionStringCredentials(connectionString string, username *string, password *string) (modified, endpoint string, err error) { + u, err := url.Parse(connectionString) + if err != nil { + return "", "", err + } + + endpoint = u.Host + + finalUsername := "" + if username != nil { + finalUsername = *username + } else if u.User != nil { + finalUsername = u.User.Username() + } + + if password != nil { + u.User = url.UserPassword(finalUsername, *password) + } else if finalUsername != "" || u.User != nil { + u.User = url.User(finalUsername) + } + + return u.String(), endpoint, nil +} + +// injectPasswordIntoConnectionString injects the password (token) into a connection string +func injectPasswordIntoConnectionString(connectionString, password string) (string, error) { + modified, _, err := updateConnectionStringCredentials(connectionString, nil, &password) + if err != nil { + log.Logger().Errorf("Failed to parse connection string for password injection: %v", err) + return connectionString, err + } + + return modified, nil +} + +// rdsIAMConnector wraps a driver.Connector and refreshes IAM tokens before opening connections +type rdsIAMConnector struct { + driver.Connector + authenticator *rdsIAMAuthenticator + underlyingDriver driver.Driver +} + +// Connect implements driver.Connector +func (c *rdsIAMConnector) Connect(ctx context.Context) (driver.Conn, error) { + // Get fresh connection string with current token + connStr, err := c.authenticator.getCurrentConnectionString(ctx) + if err != nil { + return nil, err + } + + // Open connection with updated credentials + return c.underlyingDriver.Open(connStr) +} + +// Driver implements driver.Connector +func (c *rdsIAMConnector) Driver() driver.Driver { + return c.underlyingDriver +} + +// createRDSIAMConnector creates a database connector that automatically refreshes RDS IAM tokens +func createRDSIAMConnector(driverName, connectionString string, authenticator *rdsIAMAuthenticator) (driver.Connector, error) { + // Map connection string prefix to actual SQL driver name + // "postgres://" uses the "pgx" driver from github.com/jackc/pgx/v5/stdlib + actualDriverName := driverName + if driverName == "postgres" { + actualDriverName = "pgx" + } + + // Get the underlying driver + db, err := sql.Open(actualDriverName, connectionString) + if err != nil { + return nil, err + } + defer db.Close() + + // Get the driver from the opened connection + underlyingDriver := db.Driver() + + // Create our connector that will inject fresh tokens + connector := &rdsIAMConnector{ + authenticator: authenticator, + underlyingDriver: underlyingDriver, + } + + return connector, nil +} diff --git a/storage/rds_iam_example_config.yaml b/storage/rds_iam_example_config.yaml new file mode 100644 index 0000000000..0f9342ee2b --- /dev/null +++ b/storage/rds_iam_example_config.yaml @@ -0,0 +1,44 @@ +# Example Nuts Node Configuration with AWS RDS IAM Authentication + +# PostgreSQL RDS with IAM Authentication +storage: + sql: + # Connection string without password - IAM tokens will be generated automatically + connection: "postgres://nutsuser@nuts-db.abc123.us-east-1.rds.amazonaws.com:5432/nuts_production" + + # AWS RDS IAM authentication configuration + rds_iam: + # Enable IAM authentication + enabled: true + + # AWS region where your RDS instance is located + region: "us-east-1" + + # Database username for IAM authentication + # This user must be created in the database with IAM authentication enabled + db_user: "nutsuser" + + # Optional: Token refresh interval (default is 14 minutes) + # RDS tokens expire after 15 minutes, so we refresh slightly before expiry + # token_refresh_interval: 14m + +# MySQL RDS with IAM Authentication (alternative example) +# storage: +# sql: +# connection: "mysql://nutsuser@nuts-db.abc123.us-east-1.rds.amazonaws.com:3306/nuts_production" +# rds_iam: +# enabled: true +# region: "us-east-1" +# db_user: "nutsuser" + +# Note: AWS credentials should be provided via: +# 1. Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION) +# 2. EC2 Instance Profile (recommended for production) +# 3. AWS config file (~/.aws/credentials) +# +# The IAM role/user needs the following permission: +# { +# "Effect": "Allow", +# "Action": "rds-db:connect", +# "Resource": "arn:aws:rds-db:REGION:ACCOUNT_ID:dbuser:RESOURCE_ID/USERNAME" +# } diff --git a/storage/rds_iam_test.go b/storage/rds_iam_test.go new file mode 100644 index 0000000000..f5617e530e --- /dev/null +++ b/storage/rds_iam_test.go @@ -0,0 +1,238 @@ +/* + * Copyright (C) 2026 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package storage + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseConnectionStringForRDSIAM(t *testing.T) { + t.Run("extracts endpoint correctly", func(t *testing.T) { + connStr := "postgres://user:password@mydb.123456789012.us-east-1.rds.amazonaws.com:5432/mydb" + config := RDSIAMConfig{ + Enabled: true, + Region: "us-east-1", + DBUser: "iamuser", + } + + endpoint, modified, err := parseConnectionStringForRDSIAM(connStr, config) + require.NoError(t, err) + assert.Equal(t, "mydb.123456789012.us-east-1.rds.amazonaws.com:5432", endpoint) + assert.Contains(t, modified, "iamuser") + assert.NotContains(t, modified, "password") + }) + + t.Run("uses existing user if DBUser not specified", func(t *testing.T) { + connStr := "postgres://existinguser:password@mydb.amazonaws.com:5432/mydb" + config := RDSIAMConfig{ + Enabled: true, + Region: "us-east-1", + } + + endpoint, modified, err := parseConnectionStringForRDSIAM(connStr, config) + require.NoError(t, err) + assert.Equal(t, "mydb.amazonaws.com:5432", endpoint) + assert.Contains(t, modified, "existinguser") + assert.NotContains(t, modified, "password") + }) + + t.Run("extracts endpoint correctly", func(t *testing.T) { + connStr := "mysql://user:password@mydb.123456789012.us-west-2.rds.amazonaws.com:3306/mydb" + config := RDSIAMConfig{ + Enabled: true, + Region: "us-west-2", + DBUser: "iamuser", + } + + endpoint, modified, err := parseConnectionStringForRDSIAM(connStr, config) + require.NoError(t, err) + assert.Equal(t, "mydb.123456789012.us-west-2.rds.amazonaws.com:3306", endpoint) + assert.Contains(t, modified, "iamuser") + assert.NotContains(t, modified, "password") + }) +} + +func TestInjectPasswordIntoConnectionString(t *testing.T) { + t.Run("injects password into postgres connection string", func(t *testing.T) { + connStr := "postgres://user@mydb.amazonaws.com:5432/mydb" + token := "generatedtoken123" + + result, err := injectPasswordIntoConnectionString(connStr, token) + require.NoError(t, err) + assert.Contains(t, result, "user:generatedtoken123") + }) + + t.Run("replaces existing password", func(t *testing.T) { + connStr := "postgres://user:oldpassword@mydb.amazonaws.com:5432/mydb" + token := "newtoken456" + + result, err := injectPasswordIntoConnectionString(connStr, token) + require.NoError(t, err) + assert.Contains(t, result, "user:newtoken456") + assert.NotContains(t, result, "oldpassword") + }) + + t.Run("returns error for malformed connection string", func(t *testing.T) { + connStr := "%" + token := "newtoken456" + + _, err := injectPasswordIntoConnectionString(connStr, token) + require.Error(t, err) + }) +} + +func TestModifyConnectionStringForRDSIAM(t *testing.T) { + t.Run("disabled config returns original string", func(t *testing.T) { + connStr := "postgres://user:password@localhost:5432/db" + config := RDSIAMConfig{ + Enabled: false, + } + + modified, auth, err := modifyConnectionStringForRDSIAM(context.Background(), connStr, config) + require.NoError(t, err) + assert.Equal(t, connStr, modified) + assert.Nil(t, auth) + }) + + t.Run("unsupported connection string returns error", func(t *testing.T) { + connStr := "sqlite:file:test.db" + config := RDSIAMConfig{ + Enabled: true, + Region: "us-east-1", + } + + _, _, err := modifyConnectionStringForRDSIAM(context.Background(), connStr, config) + assert.Error(t, err) + assert.Contains(t, err.Error(), "only supported for postgres:// and mysql://") + }) +} + +func TestNewRDSIAMAuthenticator(t *testing.T) { + t.Run("uses configured token refresh interval", func(t *testing.T) { + config := RDSIAMConfig{ + Enabled: true, + Region: "us-east-1", + DBUser: "testuser", + TokenRefreshInterval: 14 * time.Minute, + } + + auth := newRDSIAMAuthenticator(config, "localhost:5432", "postgres://testuser@localhost:5432/testdb") + assert.Equal(t, 14*time.Minute, auth.config.TokenRefreshInterval) + }) + + t.Run("uses custom token refresh interval", func(t *testing.T) { + config := RDSIAMConfig{ + Enabled: true, + Region: "us-east-1", + DBUser: "testuser", + TokenRefreshInterval: 5 * time.Minute, + } + + auth := newRDSIAMAuthenticator(config, "localhost:5432", "postgres://testuser@localhost:5432/testdb") + assert.Equal(t, 5*time.Minute, auth.config.TokenRefreshInterval) + }) +} + +func TestRDSIAMAuthenticator_GetToken(t *testing.T) { + t.Run("refreshes token when needed", func(t *testing.T) { + originalLoadAWSConfigForRegion := loadAWSConfigForRegion + originalBuildRDSAuthToken := buildRDSAuthToken + t.Cleanup(func() { + loadAWSConfigForRegion = originalLoadAWSConfigForRegion + buildRDSAuthToken = originalBuildRDSAuthToken + }) + + loadAWSConfigForRegion = func(ctx context.Context, region string) (aws.Config, error) { + return aws.Config{}, nil + } + + buildCalls := 0 + buildRDSAuthToken = func(ctx context.Context, endpoint, region, dbUser string, credentials aws.CredentialsProvider) (string, error) { + buildCalls++ + return fmt.Sprintf("token-%d", buildCalls), nil + } + + config := RDSIAMConfig{ + Enabled: true, + Region: "us-east-1", + DBUser: "testuser", + TokenRefreshInterval: 1 * time.Millisecond, + } + + auth := newRDSIAMAuthenticator(config, "localhost:5432", "postgres://testuser@localhost:5432/testdb") + // Set an old refresh time to trigger refresh + auth.lastRefresh = time.Now().Add(-2 * time.Millisecond) + auth.currentToken = "oldtoken" + + token, err := auth.getToken(context.Background()) + require.NoError(t, err) + assert.Equal(t, "token-1", token) + assert.Equal(t, 1, buildCalls) + }) +} + +func TestModifyConnectionStringForRDSIAM_WithStubbedAWS(t *testing.T) { + originalLoadAWSConfigForRegion := loadAWSConfigForRegion + originalBuildRDSAuthToken := buildRDSAuthToken + t.Cleanup(func() { + loadAWSConfigForRegion = originalLoadAWSConfigForRegion + buildRDSAuthToken = originalBuildRDSAuthToken + }) + + loadAWSConfigForRegion = func(ctx context.Context, region string) (aws.Config, error) { + return aws.Config{}, nil + } + + buildCalls := 0 + buildRDSAuthToken = func(ctx context.Context, endpoint, region, dbUser string, credentials aws.CredentialsProvider) (string, error) { + buildCalls++ + assert.Equal(t, "mydb.example.com:5432", endpoint) + assert.Equal(t, "eu-west-1", region) + assert.Equal(t, "iam-user", dbUser) + return fmt.Sprintf("stub-token-%d", buildCalls), nil + } + + connStr := "postgres://legacy:old-password@mydb.example.com:5432/nuts" + config := RDSIAMConfig{ + Enabled: true, + Region: "eu-west-1", + DBUser: "iam-user", + TokenRefreshInterval: 1 * time.Millisecond, + } + + modified, authenticator, err := modifyConnectionStringForRDSIAM(context.Background(), connStr, config) + require.NoError(t, err) + require.NotNil(t, authenticator) + assert.Equal(t, 1, buildCalls) + assert.Contains(t, modified, "iam-user:stub-token-1") + assert.NotContains(t, modified, "old-password") + + authenticator.lastRefresh = time.Now().Add(-2 * time.Millisecond) + next, err := authenticator.getCurrentConnectionString(context.Background()) + require.NoError(t, err) + assert.Equal(t, 2, buildCalls) + assert.Contains(t, next, "iam-user:stub-token-2") +}