diff --git a/postgresql/helpers.go b/postgresql/helpers.go index eee22efb..ab2cd66e 100644 --- a/postgresql/helpers.go +++ b/postgresql/helpers.go @@ -238,6 +238,35 @@ func withRolesGranted(txn *sql.Tx, roles []string, fn func() error) error { return nil } +// listDatabases returns the list of all databases accessible for REASSIGN OWNED operations. +// It excludes template databases and databases that don't allow connections. +func listDatabases(db QueryAble) ([]string, error) { + rows, err := db.Query("SELECT datname FROM pg_database WHERE datallowconn = true AND NOT datistemplate AND has_database_privilege(current_user, datname, 'CONNECT')") + if err != nil { + return nil, fmt.Errorf("could not list databases: %w", err) + } + defer func() { + if closeErr := rows.Close(); closeErr != nil { + log.Printf("[WARN] could not close rows: %v", closeErr) + } + }() + + var databases []string + for rows.Next() { + var dbName string + if err := rows.Scan(&dbName); err != nil { + return nil, fmt.Errorf("could not scan database name: %w", err) + } + databases = append(databases, dbName) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating database rows: %w", err) + } + + return databases, nil +} + func sliceContainsStr(haystack []string, needle string) bool { for _, s := range haystack { if s == needle { diff --git a/postgresql/resource_postgresql_role.go b/postgresql/resource_postgresql_role.go index 38bfb9e9..b2b0b338 100644 --- a/postgresql/resource_postgresql_role.go +++ b/postgresql/resource_postgresql_role.go @@ -32,6 +32,7 @@ const ( roleReplicationAttr = "replication" roleSkipDropRoleAttr = "skip_drop_role" roleSkipReassignOwnedAttr = "skip_reassign_owned" + roleReassignOwnedToAttr = "reassign_owned_to" roleSuperuserAttr = "superuser" roleValidUntilAttr = "valid_until" roleRolesAttr = "roles" @@ -182,6 +183,11 @@ func resourcePostgreSQLRole() *schema.Resource { Default: false, Description: "Skip actually running the REASSIGN OWNED command when removing a role from PostgreSQL", }, + roleReassignOwnedToAttr: { + Type: schema.TypeString, + Optional: true, + Description: "Role to reassign owned objects to when removing a role from PostgreSQL. If not specified, defaults to the current user", + }, roleStatementTimeoutAttr: { Type: schema.TypeInt, Optional: true, @@ -393,20 +399,51 @@ func resourcePostgreSQLRoleDelete(db *DBConnection, d *schema.ResourceData) erro } if !d.Get(roleSkipReassignOwnedAttr).(bool) { - if err := withRolesGranted(txn, []string{roleName}, func() error { - currentUser := db.client.config.getDatabaseUsername() - if _, err := txn.Exec(fmt.Sprintf("REASSIGN OWNED BY %s TO %s", pq.QuoteIdentifier(roleName), pq.QuoteIdentifier(currentUser))); err != nil { - return fmt.Errorf("could not reassign owned by role %s to %s: %w", roleName, currentUser, err) + // Use the specified reassign_owned_to user, or fall back to current user + reassignTo := d.Get(roleReassignOwnedToAttr).(string) + if reassignTo == "" { + reassignTo = db.client.config.getDatabaseUsername() + } + + // Get list of all databases to run REASSIGN OWNED in each one + databases, err := listDatabases(txn) + if err != nil { + return fmt.Errorf("error committing initial transaction: %w", err) + } + + // Execute REASSIGN OWNED and DROP OWNED in each database + for _, dbName := range databases { + log.Printf("[DEBUG] Running REASSIGN OWNED in database %s", dbName) + + dbTxn, err := startTransaction(db.client, dbName) + if err != nil { + return fmt.Errorf("could not start transaction in database %s: %w", dbName, err) } - if _, err := txn.Exec(fmt.Sprintf("DROP OWNED BY %s", pq.QuoteIdentifier(roleName))); err != nil { - return fmt.Errorf("could not drop owned by role %s: %w", roleName, err) + if err := withRolesGranted(dbTxn, []string{roleName, reassignTo}, func() error { + log.Printf("reassignOwned: reassign owned by role %s to %s in database %s", roleName, reassignTo, dbName) + + if _, err := dbTxn.Exec(fmt.Sprintf("REASSIGN OWNED BY %s TO %s", pq.QuoteIdentifier(roleName), pq.QuoteIdentifier(reassignTo))); err != nil { + return fmt.Errorf("could not reassign owned by role %s to %s in database %s: %w", roleName, reassignTo, dbName, err) + } + + if _, err := dbTxn.Exec(fmt.Sprintf("DROP OWNED BY %s", pq.QuoteIdentifier(roleName))); err != nil { + return fmt.Errorf("could not drop owned by role %s in database %s: %w", roleName, dbName, err) + } + return nil + }); err != nil { + if rollbackErr := dbTxn.Rollback(); rollbackErr != nil { + log.Printf("[WARN] could not rollback transaction in database %s: %v", dbName, rollbackErr) + } + return err + } + + if err := dbTxn.Commit(); err != nil { + return fmt.Errorf("error committing transaction in database %s: %w", dbName, err) } - return nil - }); err != nil { - return err } } + if !d.Get(roleSkipDropRoleAttr).(bool) { if _, err := txn.Exec(fmt.Sprintf("DROP ROLE %s", pq.QuoteIdentifier(roleName))); err != nil { return fmt.Errorf("could not delete role %s: %w", roleName, err) @@ -414,7 +451,7 @@ func resourcePostgreSQLRoleDelete(db *DBConnection, d *schema.ResourceData) erro } if err := txn.Commit(); err != nil { - return fmt.Errorf("error committing schema: %w", err) + return fmt.Errorf("error committing final transaction: %w", err) } d.SetId("") @@ -509,6 +546,7 @@ func resourcePostgreSQLRoleReadImpl(db *DBConnection, d *schema.ResourceData) er d.Set(roleLoginAttr, roleCanLogin) d.Set(roleSkipDropRoleAttr, d.Get(roleSkipDropRoleAttr).(bool)) d.Set(roleSkipReassignOwnedAttr, d.Get(roleSkipReassignOwnedAttr).(bool)) + d.Set(roleReassignOwnedToAttr, d.Get(roleReassignOwnedToAttr).(string)) d.Set(roleSuperuserAttr, roleSuperuser) d.Set(roleValidUntilAttr, roleValidUntil) d.Set(roleReplicationAttr, roleReplication) diff --git a/postgresql/resource_postgresql_role_test.go b/postgresql/resource_postgresql_role_test.go index 9910ca0f..17bbac2b 100644 --- a/postgresql/resource_postgresql_role_test.go +++ b/postgresql/resource_postgresql_role_test.go @@ -45,6 +45,7 @@ func TestAccPostgresqlRole_Basic(t *testing.T) { resource.TestCheckResourceAttr("postgresql_role.role_with_defaults", "valid_until", "infinity"), resource.TestCheckResourceAttr("postgresql_role.role_with_defaults", "skip_drop_role", "false"), resource.TestCheckResourceAttr("postgresql_role.role_with_defaults", "skip_reassign_owned", "false"), + resource.TestCheckResourceAttr("postgresql_role.role_with_defaults", "reassign_owned_to", ""), resource.TestCheckResourceAttr("postgresql_role.role_with_defaults", "statement_timeout", "0"), resource.TestCheckResourceAttr("postgresql_role.role_with_defaults", "idle_in_transaction_session_timeout", "0"), resource.TestCheckResourceAttr("postgresql_role.role_with_defaults", "assume_role", ""), @@ -238,6 +239,43 @@ resource "postgresql_role" "test_role" { }) } +// Test reassign_owned_to parameter +func TestAccPostgresqlRole_ReassignOwnedTo(t *testing.T) { + // Get the admin user (usually postgres) who will receive the reassigned objects + admin := os.Getenv("PGUSER") + if admin == "" { + admin = "postgres" + } + + config := fmt.Sprintf(` +resource "postgresql_role" "temp_role" { + name = "temp_role" + login = true + password = "temppass" + reassign_owned_to = "%s" +} +`, admin) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + testCheckCompatibleVersion(t, featurePrivileges) + }, + Providers: testAccProviders, + CheckDestroy: testAccCheckPostgresqlRoleDestroy, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + testAccCheckPostgresqlRoleExists("temp_role", nil, nil), + resource.TestCheckResourceAttr("postgresql_role.temp_role", "name", "temp_role"), + resource.TestCheckResourceAttr("postgresql_role.temp_role", "reassign_owned_to", admin), + ), + }, + }, + }) +} + func testAccCheckPostgresqlRoleDestroy(s *terraform.State) error { client := testAccProvider.Meta().(*Client) diff --git a/website/docs/r/postgresql_role.html.markdown b/website/docs/r/postgresql_role.html.markdown index f986d333..7e589c2c 100644 --- a/website/docs/r/postgresql_role.html.markdown +++ b/website/docs/r/postgresql_role.html.markdown @@ -52,6 +52,14 @@ resource "postgresql_role" "secure_role" { password_wo = "secure_password_123" password_wo_version = "1" } + +# Example with reassign_owned_to +resource "postgresql_role" "temp_role" { + name = "temp_role" + login = true + password = "temppass" + reassign_owned_to = "admin_role" # Objects will be reassigned to admin_role when this role is dropped +} ``` ## Write-Only Password Management @@ -166,6 +174,10 @@ resource "postgresql_role" "app_user" { an implicit [`DROP OWNED`](https://www.postgresql.org/docs/current/static/sql-drop-owned.html)). +* `reassign_owned_to` - (Optional) Specifies the role to which objects owned by the role being + dropped should be reassigned. If not specified, defaults to the current database user + (the user configured in the provider). This is only used when `skip_reassign_owned` is `false`. + * `statement_timeout` - (Optional) Defines [`statement_timeout`](https://www.postgresql.org/docs/current/runtime-config-client.html#RUNTIME-CONFIG-CLIENT-STATEMENT) setting for this role which allows to abort any statement that takes more than the specified amount of time. * `assume_role` - (Optional) Defines the role to switch to at login via [`SET ROLE`](https://www.postgresql.org/docs/current/sql-set-role.html).