diff --git a/postgresql/provider.go b/postgresql/provider.go index 8bc7546d..1559701f 100644 --- a/postgresql/provider.go +++ b/postgresql/provider.go @@ -213,6 +213,7 @@ func Provider() *schema.Provider { "postgresql_physical_replication_slot": resourcePostgreSQLPhysicalReplicationSlot(), "postgresql_schema": resourcePostgreSQLSchema(), "postgresql_role": resourcePostgreSQLRole(), + "postgresql_role_attribute": resourcePostgreSQLRoleAttribute(), "postgresql_function": resourcePostgreSQLFunction(), "postgresql_server": resourcePostgreSQLServer(), "postgresql_user_mapping": resourcePostgreSQLUserMapping(), diff --git a/postgresql/resource_postgresql_role_attribute.go b/postgresql/resource_postgresql_role_attribute.go new file mode 100644 index 00000000..65fadae3 --- /dev/null +++ b/postgresql/resource_postgresql_role_attribute.go @@ -0,0 +1,405 @@ +package postgresql + +import ( + "database/sql" + "fmt" + "log" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/lib/pq" +) + +func resourcePostgreSQLRoleAttribute() *schema.Resource { + return &schema.Resource{ + Create: PGResourceFunc(resourcePostgreSQLRoleAttributeCreate), + Read: PGResourceFunc(resourcePostgreSQLRoleAttributeRead), + Update: PGResourceFunc(resourcePostgreSQLRoleAttributeUpdate), + Delete: PGResourceFunc(resourcePostgreSQLRoleAttributeDelete), + Exists: PGResourceExistsFunc(resourcePostgreSQLRoleAttributeExists), + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + roleNameAttr: { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The name of the role", + }, + roleBypassRLSAttr: { + Type: schema.TypeBool, + Optional: true, + Description: "Determine whether a role bypasses every row-level security (RLS) policy", + }, + roleConnLimitAttr: { + Type: schema.TypeInt, + Optional: true, + Description: "How many concurrent connections can be made with this role", + ValidateFunc: validation.IntAtLeast(-1), + }, + roleCreateDBAttr: { + Type: schema.TypeBool, + Optional: true, + Description: "Define a role's ability to create databases", + }, + roleCreateRoleAttr: { + Type: schema.TypeBool, + Optional: true, + Description: "Determine whether this role will be permitted to create new roles", + }, + roleIdleInTransactionSessionTimeoutAttr: { + Type: schema.TypeInt, + Optional: true, + Description: "Terminate any session with an open transaction that has been idle for longer than the specified duration in milliseconds", + ValidateFunc: validation.IntAtLeast(0), + }, + roleInheritAttr: { + Type: schema.TypeBool, + Optional: true, + Description: `Determine whether a role "inherits" the privileges of roles it is a member of`, + }, + roleLoginAttr: { + Type: schema.TypeBool, + Optional: true, + Description: "Determine whether a role is allowed to log in", + }, + rolePasswordAttr: { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + Description: "Sets the role's password", + }, + roleEncryptedPassAttr: { + Type: schema.TypeBool, + Optional: true, + Default: true, + Description: "Control whether the password is stored encrypted in the system catalogs", + }, + roleReplicationAttr: { + Type: schema.TypeBool, + Optional: true, + Description: "Determine whether a role is allowed to initiate streaming replication or put the system in and out of backup mode", + }, + roleSuperuserAttr: { + Type: schema.TypeBool, + Optional: true, + Description: `Determine whether the new role is a "superuser"`, + }, + roleValidUntilAttr: { + Type: schema.TypeString, + Optional: true, + Description: "Sets a date and time after which the role's password is no longer valid", + }, + roleSearchPathAttr: { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + MinItems: 0, + Description: "Sets the role's search path", + }, + roleStatementTimeoutAttr: { + Type: schema.TypeInt, + Optional: true, + Description: "Abort any statement that takes more than the specified number of milliseconds", + ValidateFunc: validation.IntAtLeast(0), + }, + roleAssumeRoleAttr: { + Type: schema.TypeString, + Optional: true, + Description: "Role to switch to at login", + }, + }, + } +} + +func resourcePostgreSQLRoleAttributeCreate(db *DBConnection, d *schema.ResourceData) error { + txn, err := startTransaction(db.client, "") + if err != nil { + return err + } + defer deferredRollback(txn) + + roleName := d.Get(roleNameAttr).(string) + + // Check if role exists + var exists bool + err = txn.QueryRow("SELECT true FROM pg_catalog.pg_roles WHERE rolname=$1", roleName).Scan(&exists) + switch { + case err == sql.ErrNoRows: + return fmt.Errorf("role %s does not exist", roleName) + case err != nil: + return fmt.Errorf("error checking role existence: %w", err) + } + + // Set role attributes + if err = setRoleAttributes(txn, db, d); err != nil { + return err + } + + if err = txn.Commit(); err != nil { + return fmt.Errorf("could not commit transaction: %w", err) + } + + d.SetId(roleName) + + return resourcePostgreSQLRoleAttributeReadImpl(db, d) +} + +func resourcePostgreSQLRoleAttributeRead(db *DBConnection, d *schema.ResourceData) error { + return resourcePostgreSQLRoleAttributeReadImpl(db, d) +} + +func resourcePostgreSQLRoleAttributeReadImpl(db *DBConnection, d *schema.ResourceData) error { + var roleSuperuser, roleInherit, roleCreateRole, roleCreateDB, roleCanLogin, roleReplication, roleBypassRLS bool + var roleConnLimit int + var roleName, roleValidUntil string + var roleConfig pq.ByteaArray + + roleID := d.Id() + + columns := []string{ + "rolname", + "rolsuper", + "rolinherit", + "rolcreaterole", + "rolcreatedb", + "rolcanlogin", + "rolconnlimit", + `COALESCE(rolvaliduntil::TEXT, 'infinity')`, + "rolconfig", + } + + values := []interface{}{ + &roleName, + &roleSuperuser, + &roleInherit, + &roleCreateRole, + &roleCreateDB, + &roleCanLogin, + &roleConnLimit, + &roleValidUntil, + &roleConfig, + } + + if db.featureSupported(featureReplication) { + columns = append(columns, "rolreplication") + values = append(values, &roleReplication) + } + + if db.featureSupported(featureRLS) { + columns = append(columns, "rolbypassrls") + values = append(values, &roleBypassRLS) + } + + roleSQL := fmt.Sprintf(`SELECT %s FROM pg_catalog.pg_roles WHERE rolname=$1`, + // select columns + strings.Join(columns, ", "), + ) + + err := db.QueryRow(roleSQL, roleID).Scan(values...) + + switch { + case err == sql.ErrNoRows: + log.Printf("[WARN] PostgreSQL role (%s) not found", roleID) + d.SetId("") + return nil + case err != nil: + return fmt.Errorf("Error reading role: %w", err) + } + + d.Set(roleNameAttr, roleName) + + // Only set parameters in the state for attributes that were actually set in the config + if _, ok := d.GetOk(roleBypassRLSAttr); ok { + d.Set(roleBypassRLSAttr, roleBypassRLS) + } + if _, ok := d.GetOk(roleConnLimitAttr); ok { + d.Set(roleConnLimitAttr, roleConnLimit) + } + if _, ok := d.GetOk(roleCreateDBAttr); ok { + d.Set(roleCreateDBAttr, roleCreateDB) + } + if _, ok := d.GetOk(roleCreateRoleAttr); ok { + d.Set(roleCreateRoleAttr, roleCreateRole) + } + if _, ok := d.GetOk(roleInheritAttr); ok { + d.Set(roleInheritAttr, roleInherit) + } + if _, ok := d.GetOk(roleLoginAttr); ok { + d.Set(roleLoginAttr, roleCanLogin) + } + if _, ok := d.GetOk(roleSuperuserAttr); ok { + d.Set(roleSuperuserAttr, roleSuperuser) + } + if _, ok := d.GetOk(roleReplicationAttr); ok { + d.Set(roleReplicationAttr, roleReplication) + } + if _, ok := d.GetOk(roleValidUntilAttr); ok { + d.Set(roleValidUntilAttr, roleValidUntil) + } + if _, ok := d.GetOk(roleSearchPathAttr); ok { + d.Set(roleSearchPathAttr, readSearchPath(roleConfig)) + } + if _, ok := d.GetOk(roleAssumeRoleAttr); ok { + d.Set(roleAssumeRoleAttr, readAssumeRole(roleConfig)) + } + + if _, ok := d.GetOk(roleStatementTimeoutAttr); ok { + statementTimeout, err := readStatementTimeout(roleConfig) + if err != nil { + return err + } + d.Set(roleStatementTimeoutAttr, statementTimeout) + } + + if _, ok := d.GetOk(roleIdleInTransactionSessionTimeoutAttr); ok { + idleInTransactionSessionTimeout, err := readIdleInTransactionSessionTimeout(roleConfig) + if err != nil { + return err + } + d.Set(roleIdleInTransactionSessionTimeoutAttr, idleInTransactionSessionTimeout) + } + + if _, ok := d.GetOk(rolePasswordAttr); ok { + password, err := readRolePassword(db, d, roleCanLogin) + if err != nil { + return err + } + d.Set(rolePasswordAttr, password) + } + + return nil +} + +func resourcePostgreSQLRoleAttributeUpdate(db *DBConnection, d *schema.ResourceData) error { + txn, err := startTransaction(db.client, "") + if err != nil { + return err + } + defer deferredRollback(txn) + + if err := setRoleAttributes(txn, db, d); err != nil { + return err + } + + if err = txn.Commit(); err != nil { + return fmt.Errorf("could not commit transaction: %w", err) + } + + return resourcePostgreSQLRoleAttributeReadImpl(db, d) +} + +func resourcePostgreSQLRoleAttributeDelete(db *DBConnection, d *schema.ResourceData) error { + // No-op: we don't want to remove the role when this resource is destroyed + // since this resource only manages specific attributes of the role. + // It is hard to tell what the role's original attributes were, hence + // destroy is a no-op and we just forget about it. + d.SetId("") + return nil +} + +func resourcePostgreSQLRoleAttributeExists(db *DBConnection, d *schema.ResourceData) (bool, error) { + var roleName string + err := db.QueryRow("SELECT rolname FROM pg_catalog.pg_roles WHERE rolname=$1", d.Id()).Scan(&roleName) + switch { + case err == sql.ErrNoRows: + return false, nil + case err != nil: + return false, err + } + + return true, nil +} + +func setRoleAttributes(txn *sql.Tx, db *DBConnection, d *schema.ResourceData) error { + + // Set each attribute if it's in the config + if d.HasChange(roleBypassRLSAttr) { + if err := setRoleBypassRLS(db, txn, d); err != nil { + return err + } + } + + if d.HasChange(roleConnLimitAttr) { + if err := setRoleConnLimit(txn, d); err != nil { + return err + } + } + + if d.HasChange(roleCreateDBAttr) { + if err := setRoleCreateDB(txn, d); err != nil { + return err + } + } + + if d.HasChange(roleCreateRoleAttr) { + if err := setRoleCreateRole(txn, d); err != nil { + return err + } + } + + if d.HasChange(roleInheritAttr) { + if err := setRoleInherit(txn, d); err != nil { + return err + } + } + + if d.HasChange(roleLoginAttr) { + if err := setRoleLogin(txn, d); err != nil { + return err + } + } + + if d.HasChange(rolePasswordAttr) { + if err := setRolePassword(txn, d); err != nil { + return err + } + } + + if d.HasChange(roleReplicationAttr) { + if err := setRoleReplication(txn, d); err != nil { + return err + } + } + + if d.HasChange(roleSuperuserAttr) { + if err := setRoleSuperuser(txn, d); err != nil { + return err + } + } + + if d.HasChange(roleValidUntilAttr) { + if err := setRoleValidUntil(txn, d); err != nil { + return err + } + } + + if d.HasChange(roleSearchPathAttr) { + if err := alterSearchPath(txn, d); err != nil { + return err + } + } + + if d.HasChange(roleStatementTimeoutAttr) { + if err := setStatementTimeout(txn, d); err != nil { + return err + } + } + + if d.HasChange(roleIdleInTransactionSessionTimeoutAttr) { + if err := setIdleInTransactionSessionTimeout(txn, d); err != nil { + return err + } + } + + if d.HasChange(roleAssumeRoleAttr) { + if err := setAssumeRole(txn, d); err != nil { + return err + } + } + + return nil +} diff --git a/postgresql/resource_postgresql_role_attribute_test.go b/postgresql/resource_postgresql_role_attribute_test.go new file mode 100644 index 00000000..ea3159f5 --- /dev/null +++ b/postgresql/resource_postgresql_role_attribute_test.go @@ -0,0 +1,200 @@ +package postgresql + +import ( + "database/sql" + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/lib/pq" +) + +// testEnsureTestRoleExists creates the test role directly using DB connection +func testEnsureTestRoleExists(t *testing.T, roleName string) { + client := testAccProvider.Meta().(*Client) + db, err := client.Connect() + if err != nil { + t.Fatalf("Error connecting to postgres: %s", err) + } + + // Check if role already exists + var exists int + err = db.QueryRow("SELECT 1 FROM pg_roles WHERE rolname = $1", roleName).Scan(&exists) + if err != nil && err != sql.ErrNoRows { + t.Fatalf("Error checking if role exists: %s", err) + } + + // If role doesn't exist, create it + if err == sql.ErrNoRows { + _, err = db.Exec(fmt.Sprintf("CREATE ROLE %s", pq.QuoteIdentifier(roleName))) + if err != nil { + t.Fatalf("Error creating role %s: %s", roleName, err) + } + t.Logf("Created test role: %s", roleName) + } +} + +func testEnsureTestRoleDestroyed(t *testing.T, roleName string) { + client := testAccProvider.Meta().(*Client) + db, err := client.Connect() + if err != nil { + t.Fatalf("Error connecting to postgres: %s", err) + } + + // Check if role exists + var exists int + err = db.QueryRow("SELECT 1 FROM pg_roles WHERE rolname = $1", roleName).Scan(&exists) + if err != nil && err != sql.ErrNoRows { + t.Fatalf("Error checking if role exists: %s", err) + } + + // If role exists, drop it + if err == nil { + _, err = db.Exec(fmt.Sprintf("DROP ROLE %s", pq.QuoteIdentifier(roleName))) + if err != nil { + t.Fatalf("Error dropping role %s: %s", roleName, err) + } + t.Logf("Dropped test role: %s", roleName) + } +} + +func TestAccPostgresqlRoleAttribute_Basic(t *testing.T) { + roleName := fmt.Sprintf("tf_test_role_%s", t.Name()) + + configCreate := fmt.Sprintf(` +resource "postgresql_role_attribute" "test" { + name = "%s" + connection_limit = 10 + bypass_row_level_security = true +}`, roleName) + + configUpdate := fmt.Sprintf(` +resource "postgresql_role_attribute" "test" { + name = "%s" + connection_limit = 100 + bypass_row_level_security = false + create_database = true +}`, roleName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + testCheckCompatibleVersion(t, featurePrivileges) + testEnsureTestRoleExists(t, roleName) + }, + Providers: testAccProviders, + CheckDestroy: func() resource.TestCheckFunc { + // there is nothing to check, just clean up the created role + return func(s *terraform.State) error { + testEnsureTestRoleDestroyed(t, roleName) + return nil + } + }(), + Steps: []resource.TestStep{ + { + Config: configCreate, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("postgresql_role_attribute.test", "name", roleName), + resource.TestCheckResourceAttr("postgresql_role_attribute.test", "connection_limit", "10"), + resource.TestCheckResourceAttr("postgresql_role_attribute.test", "bypass_row_level_security", "true"), + withDBClient(t, func(client *Client) error { + return checkConnLimit(client, roleName, 10) + }), + withDBClient(t, func(client *Client) error { + return checkBypassRLS(client, roleName, true) + }), + ), + }, + { + Config: configUpdate, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("postgresql_role_attribute.test", "name", roleName), + resource.TestCheckResourceAttr("postgresql_role_attribute.test", "connection_limit", "100"), + resource.TestCheckResourceAttr("postgresql_role_attribute.test", "bypass_row_level_security", "false"), + resource.TestCheckResourceAttr("postgresql_role_attribute.test", "create_database", "true"), + withDBClient(t, func(client *Client) error { + return checkConnLimit(client, roleName, 100) + }), + withDBClient(t, func(client *Client) error { + return checkBypassRLS(client, roleName, false) + }), + withDBClient(t, func(client *Client) error { + return checkCreateDB(client, roleName, true) + }), + ), + }, + }, + }) +} + +func withDBClient(t *testing.T, f func(*Client) error) resource.TestCheckFunc { + return func(s *terraform.State) error { + client := testAccProvider.Meta().(*Client) + if client == nil { + t.Logf("Client is nil") + t.FailNow() + } + if err := f(client); err != nil { + return fmt.Errorf("Error: %s", err) + } + return nil + } +} + +func checkConnLimit(client *Client, roleName string, expectedConnLimit int) error { + db, err := client.Connect() + if err != nil { + return fmt.Errorf("Error connecting to postgres: %s", err) + } + + var connLimit int + err = db.QueryRow("SELECT rolconnlimit FROM pg_catalog.pg_roles WHERE rolname = $1", roleName).Scan(&connLimit) + if err != nil { + return fmt.Errorf("Error querying role: %s", err) + } + + if connLimit != expectedConnLimit { + return fmt.Errorf("Expected connection limit to be %d, got %d", expectedConnLimit, connLimit) + } + + return nil +} + +func checkBypassRLS(client *Client, roleName string, expectedBypassRLS bool) error { + db, err := client.Connect() + if err != nil { + return fmt.Errorf("Error connecting to postgres: %s", err) + } + + var bypassRLS bool + err = db.QueryRow("SELECT rolbypassrls FROM pg_catalog.pg_roles WHERE rolname = $1", roleName).Scan(&bypassRLS) + if err != nil { + return fmt.Errorf("Error querying role: %s", err) + } + + if bypassRLS != expectedBypassRLS { + return fmt.Errorf("Expected bypass row level security to be %t, got %t", expectedBypassRLS, bypassRLS) + } + + return nil +} + +func checkCreateDB(client *Client, roleName string, expectedCreateDB bool) error { + db, err := client.Connect() + if err != nil { + return fmt.Errorf("Error connecting to postgres: %s", err) + } + + var createDB bool + err = db.QueryRow("SELECT rolcreatedb FROM pg_catalog.pg_roles WHERE rolname = $1", roleName).Scan(&createDB) + if err != nil { + return fmt.Errorf("Error querying role: %s", err) + } + + if createDB != expectedCreateDB { + return fmt.Errorf("Expected create database to be %t, got %t", expectedCreateDB, createDB) + } + + return nil +} diff --git a/website/docs/r/role_attribute.html.markdown b/website/docs/r/role_attribute.html.markdown new file mode 100644 index 00000000..32c15b79 --- /dev/null +++ b/website/docs/r/role_attribute.html.markdown @@ -0,0 +1,60 @@ +--- +layout: "postgresql" +page_title: "PostgreSQL: postgresql_role_attribute" +sidebar_current: "docs-postgresql-resource-role-attribute" +description: >- + Creates and manages attributes of a role in a PostgreSQL database. +--- + +# postgresql_role_attribute + +The `postgresql_role_attribute` resource creates and manages attributes of a role in a PostgreSQL database. Unlike the `postgresql_role` resource which manages the entire role in an authoritative manner, this resource allows managing specific attributes of a role without controlling the entire role definition. It is useful when the creation of role is managed by another provider, but they don't support managing specific attributes. Additionally, this resource does not remove the role attributes on destroy. + +~> **NOTE:** If you're already using `postgresql_role` to manage the role, you should not use `postgresql_role_attribute` to modify the role's attributes. This resource is intended for cases where the role is managed by another provider or when you need to modify specific attributes without affecting the entire role definition. + +## Example Usage + +```hcl +# Create a create with the Google terraform provider +resource "google_sql_user" "app_iam_service_account" { + # Note: for Postgres only, GCP requires omitting the ".gserviceaccount.com" suffix + # from the service account email due to length limits on database usernames. + name = trimsuffix(google_service_account.service_account.email, ".gserviceaccount.com") + instance = google_sql_database_instance.main.name + type = "CLOUD_IAM_SERVICE_ACCOUNT" +} + +// Manage the role attributes using the PostgreSQL provider +// that are impossible to manage with the Google provider +resource "postgresql_role_attribute" "app_iam_service_account_role_attrs" { + name = postgresql_role.app_iam_service_account.name + bypass_row_level_security = true +} +``` + +## Argument Reference + +* `name` - (Required) The name of the role. This role must already exist. +* `bypass_row_level_security` - (Optional) Determine whether this role bypasses every row-level security (RLS) policy. +* `connection_limit` - (Optional) If this role can log in, this specifies how many concurrent connections the role can establish. `-1` (the default) means no limit. +* `create_database` - (Optional) Define whether this role is allowed to create databases. +* `create_role` - (Optional) Determine whether this role will be permitted to create new roles. +* `idle_in_transaction_session_timeout` - (Optional) Terminate any session with an open transaction that has been idle for longer than the specified duration in milliseconds. +* `inherit` - (Optional) Determine whether a role inherits the privileges of roles it is a member of. +* `login` - (Optional) Determine whether a role is allowed to log in. +* `password` - (Optional) Sets the role's password. +* `encrypted_password` - (Optional) Control whether the password is stored encrypted in the system catalogs. Default is `true`. +* `replication` - (Optional) Determine whether a role is allowed to initiate streaming replication or put the system in and out of backup mode. +* `superuser` - (Optional) Determine whether the new role is a superuser. +* `valid_until` - (Optional) Sets a date and time after which the role's password is no longer valid. +* `search_path` - (Optional) Sets the role's search path. +* `statement_timeout` - (Optional) Abort any statement that takes more than the specified number of milliseconds. +* `assume_role` - (Optional) Role to switch to at login. + +## Import + +PostgreSQL roles can be imported using the `name`, e.g. + +``` +$ terraform import postgresql_role_attribute.admin_attributes admin +``` \ No newline at end of file diff --git a/website/postgresql.erb b/website/postgresql.erb index b48358f2..d6a3a3a6 100644 --- a/website/postgresql.erb +++ b/website/postgresql.erb @@ -40,6 +40,9 @@