diff --git a/redshift/helpers.go b/redshift/helpers.go index 82df96b..5f71a32 100644 --- a/redshift/helpers.go +++ b/redshift/helpers.go @@ -170,8 +170,16 @@ func validatePrivileges(privileges []string, objectType string) bool { default: return false } + case "DATABASE": + switch strings.ToUpper(p) { + case "CREATE", "TEMPORARY": + continue + default: + return false + } + default: + return false } - } return true @@ -182,3 +190,16 @@ func appendIfTrue(condition bool, item string, list *[]string) { *list = append(*list, item) } } + +func setToPgIdentList(identifiers *schema.Set, prefix string) string { + quoted := make([]string, identifiers.Len()) + for i, identifier := range identifiers.List() { + if prefix == "" { + quoted[i] = pq.QuoteIdentifier(identifier.(string)) + } else { + quoted[i] = fmt.Sprintf("%s.%s", pq.QuoteIdentifier(prefix), pq.QuoteIdentifier(identifier.(string))) + } + } + + return strings.Join(quoted, ",") +} diff --git a/redshift/provider.go b/redshift/provider.go index 53d94d9..652ce06 100644 --- a/redshift/provider.go +++ b/redshift/provider.go @@ -121,6 +121,7 @@ func Provider() *schema.Provider { "redshift_schema": redshiftSchema(), "redshift_privilege": redshiftPrivilege(), "redshift_default_privileges": redshiftDefaultPrivileges(), + "redshift_grant": redshiftGrant(), "redshift_database": redshiftDatabase(), "redshift_datashare": redshiftDatashare(), "redshift_datashare_privilege": redshiftDatasharePrivilege(), diff --git a/redshift/resource_redshift_grant.go b/redshift/resource_redshift_grant.go new file mode 100644 index 0000000..87997e1 --- /dev/null +++ b/redshift/resource_redshift_grant.go @@ -0,0 +1,420 @@ +package redshift + +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" +) + +const ( + grantGroupAttr = "group" + grantSchemaAttr = "schema" + grantObjectTypeAttr = "object_type" + grantObjectsAttr = "objects" + grantPrivilegesAttr = "privileges" +) + +var grantAllowedObjectTypes = []string{ + "table", + "schema", + "database", +} + +var grantObjectTypesCodes = map[string]string{ + "table": "r", +} + +func redshiftGrant() *schema.Resource { + return &schema.Resource{ + Description: ` +Defines access privileges for user group. Privileges include access options such as being able to read data in tables and views, write data, create tables, and drop tables. Use this command to give specific privileges for a table, database, schema, function, procedure, language, or column. +`, + Read: RedshiftResourceFunc(resourceRedshiftGrantRead), + Create: RedshiftResourceFunc( + RedshiftResourceRetryOnPQErrors(resourceRedshiftGrantCreate), + ), + Delete: RedshiftResourceFunc( + RedshiftResourceRetryOnPQErrors(resourceRedshiftGrantDelete), + ), + + // Since we revoke all when creating, we can use create as update + Update: RedshiftResourceFunc( + RedshiftResourceRetryOnPQErrors(resourceRedshiftGrantCreate), + ), + + Schema: map[string]*schema.Schema{ + grantGroupAttr: { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The name of the group to grant privileges on.", + }, + grantSchemaAttr: { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Description: "The database schema to grant privileges on for this group.", + }, + grantObjectTypeAttr: { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.StringInSlice(grantAllowedObjectTypes, false), + Description: "The Redshift object type to grant privileges on (one of: " + strings.Join(grantAllowedObjectTypes, ", ") + ").", + }, + grantObjectsAttr: { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + StateFunc: func(val interface{}) string { + return strings.ToLower(val.(string)) + }, + }, + Set: schema.HashString, + Description: "The objects upon which to grant the privileges. An empty list (the default) means to grant permissions on all objects of the specified type. Only has effect if `object_type` is set to `table`.", + }, + grantPrivilegesAttr: { + Type: schema.TypeSet, + Required: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + StateFunc: func(val interface{}) string { + return strings.ToLower(val.(string)) + }, + }, + Set: schema.HashString, + Description: "The list of privileges to apply as default privileges. See [GRANT command documentation](https://docs.aws.amazon.com/redshift/latest/dg/r_GRANT.html) to see what privileges are available to which object type. An empty list could be provided to revoke all privileges for this group", + }, + }, + } +} + +func resourceRedshiftGrantCreate(db *DBConnection, d *schema.ResourceData) error { + objectType := d.Get(grantObjectTypeAttr).(string) + schemaName := d.Get(grantSchemaAttr).(string) + objects := d.Get(grantObjectsAttr).(*schema.Set).List() + + privileges := []string{} + for _, p := range d.Get(grantPrivilegesAttr).(*schema.Set).List() { + privileges = append(privileges, p.(string)) + } + + // validate parameters + if objectType == "table" && schemaName == "" { + return fmt.Errorf("parameter `%s` is required for objects of type table", grantSchemaAttr) + } + + if (objectType == "database" || objectType == "schema") && len(objects) > 0 { + return fmt.Errorf("cannot specify `%s` when `%s` is `database` or `schema`", grantObjectsAttr, grantObjectTypeAttr) + } + + if !validatePrivileges(privileges, objectType) { + return fmt.Errorf("Invalid privileges list %v for object of type %s", privileges, objectType) + } + + tx, err := startTransaction(db.client, "") + if err != nil { + return err + } + defer deferredRollback(tx) + + if err := revokeGroupGrants(tx, db.client.databaseName, d); err != nil { + return err + } + + if err := createGroupGrants(tx, db.client.databaseName, d); err != nil { + return err + } + + if err = tx.Commit(); err != nil { + return fmt.Errorf("could not commit transaction: %w", err) + } + + d.SetId(generateGrantID(d)) + + return resourceRedshiftGrantReadImpl(db, d) +} + +func resourceRedshiftGrantDelete(db *DBConnection, d *schema.ResourceData) error { + tx, err := startTransaction(db.client, "") + if err != nil { + return err + } + defer deferredRollback(tx) + + if err := revokeGroupGrants(tx, db.client.databaseName, d); err != nil { + return err + } + + if err = tx.Commit(); err != nil { + return fmt.Errorf("could not commit transaction: %w", err) + } + + return nil +} + +func resourceRedshiftGrantRead(db *DBConnection, d *schema.ResourceData) error { + return resourceRedshiftGrantReadImpl(db, d) +} + +func resourceRedshiftGrantReadImpl(db *DBConnection, d *schema.ResourceData) error { + objectType := d.Get(grantObjectTypeAttr).(string) + + switch objectType { + case "database": + return readGroupDatabaseGrants(db, d) + case "schema": + return readGroupSchemaGrants(db, d) + case "table": + return readGroupTableGrants(db, d) + default: + return fmt.Errorf("Unsupported %s %s", grantObjectTypeAttr, objectType) + } +} + +func readGroupDatabaseGrants(db *DBConnection, d *schema.ResourceData) error { + groupName := d.Get(grantGroupAttr).(string) + var databaseCreate, databaseTemp bool + + query := ` + SELECT + decode(charindex('C',split_part(split_part(array_to_string(db.datacl, '|'),gr.groname,2 ) ,'/',1)), 0,0,1) as create, + decode(charindex('T',split_part(split_part(array_to_string(db.datacl, '|'),gr.groname,2 ) ,'/',1)), 0,0,1) as temporary + FROM pg_database db, pg_group gr + WHERE + db.datname=$1 + AND gr.groname=$2 +` + + if err := db.QueryRow(query, db.client.databaseName, groupName).Scan(&databaseCreate, &databaseTemp); err != nil { + return err + } + + privileges := []string{} + appendIfTrue(databaseCreate, "create", &privileges) + appendIfTrue(databaseTemp, "temporary", &privileges) + + log.Printf("[DEBUG] Collected database '%s' privileges for group %s: %v", db.client.databaseName, groupName, privileges) + + d.Set(grantPrivilegesAttr, privileges) + + return nil +} + +func readGroupSchemaGrants(db *DBConnection, d *schema.ResourceData) error { + groupName := d.Get(grantGroupAttr).(string) + schemaName := d.Get(grantSchemaAttr).(string) + + var schemaCreate, schemaUsage bool + + query := ` + SELECT + decode(charindex('C',split_part(split_part(array_to_string(ns.nspacl, '|'),gr.groname,2 ) ,'/',1)), 0,0,1) as create, + decode(charindex('U',split_part(split_part(array_to_string(ns.nspacl, '|'),gr.groname,2 ) ,'/',1)), 0,0,1) as usage + FROM pg_namespace ns, pg_group gr + WHERE + ns.nspname=$1 + AND gr.groname=$2 +` + + if err := db.QueryRow(query, schemaName, groupName).Scan(&schemaCreate, &schemaUsage); err != nil { + return err + } + + privileges := []string{} + appendIfTrue(schemaCreate, "create", &privileges) + appendIfTrue(schemaUsage, "usage", &privileges) + + log.Printf("[DEBUG] Collected schema '%s' privileges for group %s: %v", schemaName, groupName, privileges) + + d.Set(grantPrivilegesAttr, privileges) + + return nil +} + +func readGroupTableGrants(db *DBConnection, d *schema.ResourceData) error { + query := ` + SELECT + relname, + decode(charindex('r',split_part(split_part(array_to_string(relacl, '|'),gr.groname,2 ) ,'/',1)), 0,0,1) as select, + decode(charindex('w',split_part(split_part(array_to_string(relacl, '|'),gr.groname,2 ) ,'/',1)), 0,0,1) as update, + decode(charindex('a',split_part(split_part(array_to_string(relacl, '|'),gr.groname,2 ) ,'/',1)), 0,0,1) as insert, + decode(charindex('d',split_part(split_part(array_to_string(relacl, '|'),gr.groname,2 ) ,'/',1)), 0,0,1) as delete, + decode(charindex('x',split_part(split_part(array_to_string(relacl, '|'),gr.groname,2 ) ,'/',1)), 0,0,1) as references + FROM pg_group gr, pg_class cl + JOIN pg_namespace nsp ON nsp.oid = cl.relnamespace + WHERE + cl.relkind = $1 + AND gr.groname=$2 + AND nsp.nspname=$3 +` + + groupName := d.Get(grantGroupAttr).(string) + schemaName := d.Get(grantSchemaAttr).(string) + objects := d.Get(grantObjectsAttr).(*schema.Set) + + rows, err := db.Query(query, grantObjectTypesCodes["table"], groupName, schemaName) + if err != nil { + return err + } + + for rows.Next() { + var objName string + var tableSelect, tableUpdate, tableInsert, tableDelete, tableReferences bool + + if err := rows.Scan(&objName, &tableSelect, &tableUpdate, &tableInsert, &tableDelete, &tableReferences); err != nil { + return err + } + + if objects.Len() > 0 && !objects.Contains(objName) { + continue + } + + privilegesSet := schema.NewSet(schema.HashString, nil) + if tableSelect { + privilegesSet.Add("select") + } + if tableUpdate { + privilegesSet.Add("update") + } + if tableInsert { + privilegesSet.Add("insert") + } + if tableDelete { + privilegesSet.Add("delete") + } + if tableReferences { + privilegesSet.Add("references") + } + + if !privilegesSet.Equal(d.Get(grantPrivilegesAttr).(*schema.Set)) { + d.Set(grantPrivilegesAttr, privilegesSet) + break + } + } + + return nil +} + +func revokeGroupGrants(tx *sql.Tx, databaseName string, d *schema.ResourceData) error { + query := createGroupRevokeQuery(d, databaseName) + _, err := tx.Exec(query) + return err +} + +func createGroupGrants(tx *sql.Tx, databaseName string, d *schema.ResourceData) error { + if d.Get(grantPrivilegesAttr).(*schema.Set).Len() == 0 { + log.Printf("[DEBUG] no privileges to grant for group %s", d.Get(grantGroupAttr).(string)) + return nil + } + + query := createGroupGrantQuery(d, databaseName) + _, err := tx.Exec(query) + return err +} + +func createGroupRevokeQuery(d *schema.ResourceData, databaseName string) string { + var query string + + switch strings.ToUpper(d.Get(grantObjectTypeAttr).(string)) { + case "DATABASE": + query = fmt.Sprintf( + "REVOKE ALL PRIVILEGES ON DATABASE %s FROM GROUP %s", + pq.QuoteIdentifier(databaseName), + pq.QuoteIdentifier(d.Get(grantGroupAttr).(string)), + ) + case "SCHEMA": + query = fmt.Sprintf( + "REVOKE ALL PRIVILEGES ON SCHEMA %s FROM GROUP %s", + pq.QuoteIdentifier(d.Get(grantSchemaAttr).(string)), + pq.QuoteIdentifier(d.Get(grantGroupAttr).(string)), + ) + case "TABLE": + objects := d.Get(grantObjectsAttr).(*schema.Set) + if objects.Len() > 0 { + query = fmt.Sprintf( + "REVOKE ALL PRIVILEGES ON %s %s FROM GROUP %s", + strings.ToUpper(d.Get(grantObjectTypeAttr).(string)), + setToPgIdentList(objects, d.Get(grantSchemaAttr).(string)), + pq.QuoteIdentifier(d.Get(grantGroupAttr).(string)), + ) + } else { + query = fmt.Sprintf( + "REVOKE ALL PRIVILEGES ON ALL %sS IN SCHEMA %s FROM GROUP %s", + strings.ToUpper(d.Get(grantObjectTypeAttr).(string)), + pq.QuoteIdentifier(d.Get(grantSchemaAttr).(string)), + pq.QuoteIdentifier(d.Get(grantGroupAttr).(string)), + ) + } + } + + return query +} + +func createGroupGrantQuery(d *schema.ResourceData, databaseName string) string { + var query string + privileges := []string{} + for _, p := range d.Get(grantPrivilegesAttr).(*schema.Set).List() { + privileges = append(privileges, p.(string)) + } + + switch strings.ToUpper(d.Get(grantObjectTypeAttr).(string)) { + case "DATABASE": + query = fmt.Sprintf( + "GRANT %s ON DATABASE %s TO GROUP %s", + strings.Join(privileges, ","), + pq.QuoteIdentifier(databaseName), + pq.QuoteIdentifier(d.Get(grantGroupAttr).(string)), + ) + case "SCHEMA": + query = fmt.Sprintf( + "GRANT %s ON SCHEMA %s TO GROUP %s", + strings.Join(privileges, ","), + pq.QuoteIdentifier(d.Get(grantSchemaAttr).(string)), + pq.QuoteIdentifier(d.Get(grantGroupAttr).(string)), + ) + case "TABLE": + objects := d.Get(grantObjectsAttr).(*schema.Set) + if objects.Len() > 0 { + query = fmt.Sprintf( + "GRANT %s ON %s %s TO GROUP %s", + strings.Join(privileges, ","), + strings.ToUpper(d.Get(grantObjectTypeAttr).(string)), + setToPgIdentList(objects, d.Get(grantSchemaAttr).(string)), + pq.QuoteIdentifier(d.Get(grantGroupAttr).(string)), + ) + } else { + query = fmt.Sprintf( + "GRANT %s ON ALL %sS IN SCHEMA %s TO GROUP %s", + strings.Join(privileges, ","), + strings.ToUpper(d.Get(grantObjectTypeAttr).(string)), + pq.QuoteIdentifier(d.Get(grantSchemaAttr).(string)), + pq.QuoteIdentifier(d.Get(grantGroupAttr).(string)), + ) + } + } + + return query +} + +func generateGrantID(d *schema.ResourceData) string { + groupName := d.Get(defaultPrivilegesGroupAttr).(string) + objectType := d.Get(defaultPrivilegesObjectTypeAttr).(string) + parts := []string{groupName, objectType} + + if objectType != "database" { + parts = append(parts, d.Get(grantSchemaAttr).(string)) + } + + for _, object := range d.Get(grantObjectsAttr).(*schema.Set).List() { + parts = append(parts, object.(string)) + } + + return strings.Join(parts, "_") +} diff --git a/redshift/resource_redshift_grant_test.go b/redshift/resource_redshift_grant_test.go new file mode 100644 index 0000000..ed28172 --- /dev/null +++ b/redshift/resource_redshift_grant_test.go @@ -0,0 +1,139 @@ +package redshift + +import ( + "fmt" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccRedshiftGrant_BasicDatabase(t *testing.T) { + groupName := strings.ReplaceAll(acctest.RandomWithPrefix("tf_acc_group_basic"), "-", "_") + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: func(s *terraform.State) error { return nil }, + Steps: []resource.TestStep{ + { + Config: testAccRedshiftGrantConfig_BasicDatabase(groupName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("redshift_grant.grant", "id", fmt.Sprintf("%s_database", groupName)), + resource.TestCheckResourceAttr("redshift_grant.grant", "group", groupName), + resource.TestCheckResourceAttr("redshift_grant.grant", "object_type", "database"), + resource.TestCheckResourceAttr("redshift_grant.grant", "privileges.#", "2"), + resource.TestCheckTypeSetElemAttr("redshift_grant.grant", "privileges.*", "create"), + resource.TestCheckTypeSetElemAttr("redshift_grant.grant", "privileges.*", "temporary"), + ), + }, + }, + }) +} + +func testAccRedshiftGrantConfig_BasicDatabase(groupName string) string { + return fmt.Sprintf(` +resource "redshift_group" "group" { + name = %[1]q +} + +resource "redshift_grant" "grant" { + group = redshift_group.group.name + object_type = "database" + privileges = ["create", "temporary"] +}`, groupName) +} + +func TestAccRedshiftGrant_BasicSchema(t *testing.T) { + userName := strings.ReplaceAll(acctest.RandomWithPrefix("tf_acc_user_basic"), "-", "_") + groupName := strings.ReplaceAll(acctest.RandomWithPrefix("tf_acc_group_basic"), "-", "_") + schemaName := strings.ReplaceAll(acctest.RandomWithPrefix("tf_acc_schema_basic"), "-", "_") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: func(s *terraform.State) error { return nil }, + Steps: []resource.TestStep{ + { + Config: testAccRedshiftGrantConfig_BasicSchema(userName, groupName, schemaName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("redshift_grant.grant", "id", fmt.Sprintf("%s_schema_%s", groupName, schemaName)), + resource.TestCheckResourceAttr("redshift_grant.grant", "group", groupName), + resource.TestCheckResourceAttr("redshift_grant.grant", "object_type", "schema"), + resource.TestCheckResourceAttr("redshift_grant.grant", "privileges.#", "2"), + resource.TestCheckTypeSetElemAttr("redshift_grant.grant", "privileges.*", "create"), + resource.TestCheckTypeSetElemAttr("redshift_grant.grant", "privileges.*", "usage"), + ), + }, + }, + }) +} + +func testAccRedshiftGrantConfig_BasicSchema(userName, groupName, schemaName string) string { + return fmt.Sprintf(` +resource "redshift_user" "user" { + name = %[1]q +} + +resource "redshift_group" "group" { + name = %[2]q +} + +resource "redshift_schema" "schema" { + name = %[3]q + + owner = redshift_user.user.name +} + +resource "redshift_grant" "grant" { + group = redshift_group.group.name + schema = redshift_schema.schema.name + + object_type = "schema" + privileges = ["create", "usage"] +} +`, userName, groupName, schemaName) +} + +func TestAccRedshiftGrant_BasicTable(t *testing.T) { + groupName := strings.ReplaceAll(acctest.RandomWithPrefix("tf_acc_group_basic"), "-", "_") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: func(s *terraform.State) error { return nil }, + Steps: []resource.TestStep{ + { + Config: testAccRedshiftGrantConfig_BasicTable(groupName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("redshift_grant.grant", "id", fmt.Sprintf("%s_table_pg_catalog_pg_user_info", groupName)), + resource.TestCheckResourceAttr("redshift_grant.grant", "group", groupName), + resource.TestCheckResourceAttr("redshift_grant.grant", "schema", "pg_catalog"), + resource.TestCheckResourceAttr("redshift_grant.grant", "object_type", "table"), + resource.TestCheckResourceAttr("redshift_grant.grant", "objects.#", "1"), + resource.TestCheckTypeSetElemAttr("redshift_grant.grant", "objects.*", "pg_user_info"), + resource.TestCheckResourceAttr("redshift_grant.grant", "privileges.#", "1"), + resource.TestCheckTypeSetElemAttr("redshift_grant.grant", "privileges.*", "select"), + ), + }, + }, + }) +} + +func testAccRedshiftGrantConfig_BasicTable(groupName string) string { + return fmt.Sprintf(` +resource "redshift_group" "group" { + name = %[1]q +} + +resource "redshift_grant" "grant" { + group = redshift_group.group.name + schema = "pg_catalog" + + object_type = "table" + objects = ["pg_user_info"] + privileges = ["select"] +} +`, groupName) +}