diff --git a/CHANGELOG.md b/CHANGELOG.md index b2c15522..6e684410 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ + +## 1.9.0 (Unreleased) + +FEATURES: +* `postgresql_grant_role`: Non-authoritative. Grant role to another role. + ## 1.8.1 (November 26, 2020) BUG FIXES: diff --git a/postgresql/provider.go b/postgresql/provider.go index 6eb8c21e..3cf3b89b 100644 --- a/postgresql/provider.go +++ b/postgresql/provider.go @@ -130,6 +130,7 @@ func Provider() terraform.ResourceProvider { "postgresql_default_privileges": resourcePostgreSQLDefaultPrivileges(), "postgresql_extension": resourcePostgreSQLExtension(), "postgresql_grant": resourcePostgreSQLGrant(), + "postgresql_grant_role": resourcePostgreSQLGrantRole(), "postgresql_schema": resourcePostgreSQLSchema(), "postgresql_role": resourcePostgreSQLRole(), }, diff --git a/postgresql/resource_postgresql_grant_role.go b/postgresql/resource_postgresql_grant_role.go new file mode 100644 index 00000000..902a4621 --- /dev/null +++ b/postgresql/resource_postgresql_grant_role.go @@ -0,0 +1,218 @@ +package postgresql + +import ( + "database/sql" + "fmt" + "log" + "strconv" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "github.com/lib/pq" +) + +const ( + // This returns the role membership for role, grant_role + getGrantRoleQuery = ` +SELECT + pg_get_userbyid(member) as role, + pg_get_userbyid(roleid) as grant_role, + admin_option +FROM + pg_auth_members +WHERE + pg_get_userbyid(member) = $1 AND + pg_get_userbyid(roleid) = $2; +` +) + +func resourcePostgreSQLGrantRole() *schema.Resource { + return &schema.Resource{ + Create: resourcePostgreSQLGrantRoleCreate, + Read: resourcePostgreSQLGrantRoleRead, + Delete: resourcePostgreSQLGrantRoleDelete, + + Schema: map[string]*schema.Schema{ + "role": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The name of the role to grant grant_role", + }, + "grant_role": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The name of the role that is granted to role", + }, + "with_admin_option": { + Type: schema.TypeBool, + Optional: true, + ForceNew: true, + Default: false, + Description: "Permit the grant recipient to grant it to others", + }, + }, + } +} + +func resourcePostgreSQLGrantRoleRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Client) + + if !client.featureSupported(featurePrivileges) { + return fmt.Errorf( + "postgresql_grant_role resource is not supported for this Postgres version (%s)", + client.version, + ) + } + + client.catalogLock.RLock() + defer client.catalogLock.RUnlock() + + return readGrantRole(client.DB(), d) +} + +func resourcePostgreSQLGrantRoleCreate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Client) + + if !client.featureSupported(featurePrivileges) { + return fmt.Errorf( + "postgresql_grant_role resource is not supported for this Postgres version (%s)", + client.version, + ) + } + + client.catalogLock.Lock() + defer client.catalogLock.Unlock() + + txn, err := startTransaction(client, "") + if err != nil { + return err + } + defer deferredRollback(txn) + + // Revoke the granted roles before granting them again. + if err = revokeRole(txn, d); err != nil { + return err + } + + if err = grantRole(txn, d); err != nil { + return err + } + + if err = txn.Commit(); err != nil { + return fmt.Errorf("could not commit transaction: %w", err) + } + + d.SetId(generateGrantRoleID(d)) + + return readGrantRole(client.DB(), d) +} + +func resourcePostgreSQLGrantRoleDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Client) + + if !client.featureSupported(featurePrivileges) { + return fmt.Errorf( + "postgresql_grant_role resource is not supported for this Postgres version (%s)", + client.version, + ) + } + + client.catalogLock.Lock() + defer client.catalogLock.Unlock() + + txn, err := startTransaction(client, "") + if err != nil { + return err + } + defer deferredRollback(txn) + + if err = revokeRole(txn, d); err != nil { + return err + } + + if err = txn.Commit(); err != nil { + return fmt.Errorf("could not commit transaction: %w", err) + } + + return nil +} + +func readGrantRole(db QueryAble, d *schema.ResourceData) error { + var roleName, grantRoleName string + var withAdminOption bool + + grantRoleID := d.Id() + + values := []interface{}{ + &roleName, + &grantRoleName, + &withAdminOption, + } + + err := db.QueryRow(getGrantRoleQuery, d.Get("role"), d.Get("grant_role")).Scan(values...) + switch { + case err == sql.ErrNoRows: + log.Printf("[WARN] PostgreSQL grant role (%q) not found", grantRoleID) + d.SetId("") + return nil + case err != nil: + return fmt.Errorf("Error reading grant role: %w", err) + } + + d.Set("role", roleName) + d.Set("grant_role", grantRoleName) + d.Set("with_admin_option", withAdminOption) + + d.SetId(generateGrantRoleID(d)) + + return nil +} + +func createGrantRoleQuery(d *schema.ResourceData) string { + grantRole, _ := d.Get("grant_role").(string) + role, _ := d.Get("role").(string) + + query := fmt.Sprintf( + "GRANT %s TO %s", + pq.QuoteIdentifier(grantRole), + pq.QuoteIdentifier(role), + ) + if wao, _ := d.Get("with_admin_option").(bool); wao { + query = query + " WITH ADMIN OPTION" + } + + return query +} + +func createRevokeRoleQuery(d *schema.ResourceData) string { + grantRole, _ := d.Get("grant_role").(string) + role, _ := d.Get("role").(string) + + return fmt.Sprintf( + "REVOKE %s FROM %s", + pq.QuoteIdentifier(grantRole), + pq.QuoteIdentifier(role), + ) +} + +func grantRole(txn *sql.Tx, d *schema.ResourceData) error { + query := createGrantRoleQuery(d) + if _, err := txn.Exec(query); err != nil { + return fmt.Errorf("could not execute grant query: %w", err) + } + return nil +} + +func revokeRole(txn *sql.Tx, d *schema.ResourceData) error { + query := createRevokeRoleQuery(d) + if _, err := txn.Exec(query); err != nil { + return fmt.Errorf("could not execute revoke query: %w", err) + } + return nil +} + +func generateGrantRoleID(d *schema.ResourceData) string { + return strings.Join([]string{d.Get("role").(string), d.Get("grant_role").(string), strconv.FormatBool(d.Get("with_admin_option").(bool))}, "_") +} diff --git a/postgresql/resource_postgresql_grant_role_test.go b/postgresql/resource_postgresql_grant_role_test.go new file mode 100644 index 00000000..3f444f8c --- /dev/null +++ b/postgresql/resource_postgresql_grant_role_test.go @@ -0,0 +1,172 @@ +package postgresql + +import ( + "database/sql" + "fmt" + "strconv" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/terraform" + "github.com/lib/pq" +) + +func TestCreateGrantRoleQuery(t *testing.T) { + var roleName = "foo" + var grantRoleName = "bar" + + cases := []struct { + resource map[string]interface{} + expected string + }{ + { + resource: map[string]interface{}{ + "role": roleName, + "grant_role": grantRoleName, + }, + expected: fmt.Sprintf("GRANT %s TO %s", pq.QuoteIdentifier(grantRoleName), pq.QuoteIdentifier(roleName)), + }, + { + resource: map[string]interface{}{ + "role": roleName, + "grant_role": grantRoleName, + "with_admin_option": false, + }, + expected: fmt.Sprintf("GRANT %s TO %s", pq.QuoteIdentifier(grantRoleName), pq.QuoteIdentifier(roleName)), + }, + { + resource: map[string]interface{}{ + "role": roleName, + "grant_role": grantRoleName, + "with_admin_option": true, + }, + expected: fmt.Sprintf("GRANT %s TO %s WITH ADMIN OPTION", pq.QuoteIdentifier(grantRoleName), pq.QuoteIdentifier(roleName)), + }, + } + + for _, c := range cases { + out := createGrantRoleQuery(schema.TestResourceDataRaw(t, resourcePostgreSQLGrantRole().Schema, c.resource)) + if out != c.expected { + t.Fatalf("Error matching output and expected: %#v vs %#v", out, c.expected) + } + } +} + +func TestRevokeRoleQuery(t *testing.T) { + var roleName = "foo" + var grantRoleName = "bar" + + expected := fmt.Sprintf("REVOKE %s FROM %s", pq.QuoteIdentifier(grantRoleName), pq.QuoteIdentifier(roleName)) + + cases := []struct { + resource map[string]interface{} + }{ + { + resource: map[string]interface{}{ + "role": roleName, + "grant_role": grantRoleName, + }, + }, + { + resource: map[string]interface{}{ + "role": roleName, + "grant_role": grantRoleName, + "with_admin_option": false, + }, + }, + { + resource: map[string]interface{}{ + "role": roleName, + "grant_role": grantRoleName, + "with_admin_option": true, + }, + }, + } + + for _, c := range cases { + out := createRevokeRoleQuery(schema.TestResourceDataRaw(t, resourcePostgreSQLGrantRole().Schema, c.resource)) + if out != expected { + t.Fatalf("Error matching output and expected: %#v vs %#v", out, expected) + } + } +} + +func TestAccPostgresqlGrantRole(t *testing.T) { + skipIfNotAcc(t) + + config := getTestConfig(t) + dsn := config.connStr("postgres") + + dbSuffix, teardown := setupTestDatabase(t, false, true) + defer teardown() + + _, roleName := getTestDBNames(dbSuffix) + + grantedRoleName := "foo" + + testAccPostgresqlGrantRoleResources := fmt.Sprintf(` + resource postgresql_role "grant" { + name = "%s" + } + resource postgresql_grant_role "grant_role" { + role = "%s" + grant_role = postgresql_role.grant.name + with_admin_option = true + } + `, grantedRoleName, roleName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + testCheckCompatibleVersion(t, featurePrivileges) + }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccPostgresqlGrantRoleResources, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "postgresql_grant_role.grant_role", "role", roleName), + resource.TestCheckResourceAttr( + "postgresql_grant_role.grant_role", "grant_role", grantedRoleName), + resource.TestCheckResourceAttr( + "postgresql_grant_role.grant_role", "with_admin_option", strconv.FormatBool(true)), + checkGrantRole(t, dsn, roleName, grantedRoleName, true), + ), + }, + }, + }) +} + +func checkGrantRole(t *testing.T, dsn, role string, grantRole string, withAdmin bool) resource.TestCheckFunc { + return func(s *terraform.State) error { + db, err := sql.Open("postgres", dsn) + if err != nil { + t.Fatalf("could to create connection pool: %v", err) + } + defer db.Close() + + var _rez int + err = db.QueryRow(` + SELECT 1 + FROM pg_auth_members + WHERE pg_get_userbyid(member) = $1 + AND pg_get_userbyid(roleid) = $2 + AND admin_option = $3; + `, role, grantRole, withAdmin).Scan(&_rez) + + switch { + case err == sql.ErrNoRows: + return fmt.Errorf( + "Role %s is not a member of %s", + role, grantRole, + ) + + case err != nil: + t.Fatalf("could not check granted role: %v", err) + } + + return nil + } +} diff --git a/postgresql/utils_test.go b/postgresql/utils_test.go index 38d06ff9..0e6a7e52 100644 --- a/postgresql/utils_test.go +++ b/postgresql/utils_test.go @@ -114,6 +114,21 @@ func setupTestDatabase(t *testing.T, createDB, createRole bool) (string, func()) } } +// createTestRole creates a role before executing a terraform test +// and provides the teardown function to delete all these resources. +func createTestRole(t *testing.T, roleName string) func() { + config := getTestConfig(t) + + dbExecute(t, config.connStr("postgres"), fmt.Sprintf( + "CREATE ROLE %s LOGIN ENCRYPTED PASSWORD '%s'", + roleName, testRolePassword, + )) + + return func() { + dbExecute(t, config.connStr("postgres"), fmt.Sprintf("DROP ROLE IF EXISTS %s", roleName)) + } +} + func createTestTables(t *testing.T, dbSuffix string, tables []string, owner string) func() { config := getTestConfig(t) dbName, _ := getTestDBNames(dbSuffix) diff --git a/website/docs/r/postgresql_grant_role.html.markdown b/website/docs/r/postgresql_grant_role.html.markdown new file mode 100644 index 00000000..c06e5cc9 --- /dev/null +++ b/website/docs/r/postgresql_grant_role.html.markdown @@ -0,0 +1,33 @@ +--- +layout: "postgresql" +page_title: "PostgreSQL: postgresql_grant_role" +sidebar_current: "docs-postgresql-resource-postgresql_grant_role" +description: |- + Creates and manages membership in a role to one or more other roles. +--- + +# postgresql\_grant\_role + +The ``postgresql_grant_role`` resource creates and manages membership in a role to one or more other roles in a non-authoritative way. + +When using ``postgresql_grant_role`` resource it is likely because the PostgreSQL role you are modifying was created outside of this provider. + +~> **Note:** This resource needs PostgreSQL version 9 or above. + +~> **Note:** `postgresql_grant_role` **cannot** be used in conjunction with `postgresql_role` or they will fight over what your role grants should be. + +## Usage + +```hcl +resource "postgresql_grant_role" "grant_root" { + role = "root" + grant_role = "application" + with_admin_option = true +} +``` + +## Argument Reference + +* `role` - (Required) The name of the role that is granted a new membership. +* `grant_role` - (Required) The name of the role that is added to `role`. +* `with_admin_option` - (Optional) Giving ability to grant membership to others or not for `role`. (Default: false) diff --git a/website/postgresql.erb b/website/postgresql.erb index 3c137827..bce73e5e 100644 --- a/website/postgresql.erb +++ b/website/postgresql.erb @@ -25,6 +25,9 @@