Repository: chanzuckerberg/terraform-provider-snowflake
Pull request #524 (2021-04-23 15:48:35)

[feature] masking policy grant resource
<!-- Feel free to delete comments as you fill this in -->

<!-- summary of changes -->

Adds resource to create grants for specific masking policies. 

## Test Plan
<!-- detail ways in which this PR has been tested or needs to be tested -->
* [ ] acceptance tests
<!-- add more below if you think they are relevant -->
* [ ] …

## References
<!-- issues documentation links, etc  -->

* 

alldoami: /ok-to-test sha=680b4fe
github-actions[bot]: Integration tests success for [680b4fe](https://github.com/chanzuckerberg/terraform-provider-snowflake/actions/runs/787333406)
alldoami: /ok-to-test sha=6804cac
github-actions[bot]: Integration tests failure for [6804cac](https://github.com/chanzuckerberg/terraform-provider-snowflake/actions/runs/789819052)
alldoami: /ok-to-test sha=878a2de
github-actions[bot]: Integration tests success for [878a2de](https://github.com/chanzuckerberg/terraform-provider-snowflake/actions/runs/789981511)

Patch:
diff --git a/docs/resources/masking_policy_grant.md b/docs/resources/masking_policy_grant.md
new file mode 100644
index 00000000..383a3967
--- /dev/null
+++ b/docs/resources/masking_policy_grant.md
@@ -0,0 +1,31 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "snowflake_masking_policy_grant Resource - terraform-provider-snowflake"
+subcategory: ""
+description: |-
+  
+---
+
+# snowflake_masking_policy_grant (Resource)
+
+
+
+
+
+<!-- schema generated by tfplugindocs -->
+## Schema
+
+### Required
+
+- **database_name** (String) The name of the database containing the masking policy on which to grant privileges.
+- **masking_policy_name** (String) The name of the masking policy on which to grant privileges immediately.
+- **schema_name** (String) The name of the schema containing the masking policy on which to grant privileges.
+
+### Optional
+
+- **id** (String) The ID of this resource.
+- **privilege** (String) The privilege to grant on the masking policy.
+- **roles** (Set of String) Grants privilege to these roles.
+- **with_grant_option** (Boolean) When this is set to true, allows the recipient role to grant the privileges to other roles.
+
+
diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go
index fefbf2e4..7ed3821e 100644
--- a/pkg/provider/provider.go
+++ b/pkg/provider/provider.go
@@ -90,6 +90,7 @@ func GetGrantResources() resources.TerraformGrantResources {
 		"snowflake_file_format_grant":       resources.FileFormatGrant(),
 		"snowflake_function_grant":          resources.FunctionGrant(),
 		"snowflake_integration_grant":       resources.IntegrationGrant(),
+		"snowflake_masking_policy_grant":    resources.MaskingPolicyGrant(),
 		"snowflake_materialized_view_grant": resources.MaterializedViewGrant(),
 		"snowflake_procedure_grant":         resources.ProcedureGrant(),
 		"snowflake_resource_monitor_grant":  resources.ResourceMonitorGrant(),
diff --git a/pkg/resources/masking_policy_grant.go b/pkg/resources/masking_policy_grant.go
new file mode 100644
index 00000000..592fecb7
--- /dev/null
+++ b/pkg/resources/masking_policy_grant.go
@@ -0,0 +1,158 @@
+package resources
+
+import (
+	"github.com/chanzuckerberg/terraform-provider-snowflake/pkg/snowflake"
+	"github.com/chanzuckerberg/terraform-provider-snowflake/pkg/validation"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+)
+
+var validMaskingPoilcyPrivileges = NewPrivilegeSet(
+	privilegeOwnership,
+	privilegeApply,
+)
+
+var maskingPolicyGrantSchema = map[string]*schema.Schema{
+	"database_name": {
+		Type:        schema.TypeString,
+		Required:    true,
+		Description: "The name of the database containing the masking policy on which to grant privileges.",
+		ForceNew:    true,
+	},
+	"masking_policy_name": {
+		Type:        schema.TypeString,
+		Required:    true,
+		Description: "The name of the masking policy on which to grant privileges immediately.",
+		ForceNew:    true,
+	},
+	"privilege": {
+		Type:         schema.TypeString,
+		Optional:     true,
+		Description:  "The privilege to grant on the masking policy.",
+		Default:      "APPLY",
+		ValidateFunc: validation.ValidatePrivilege(validMaskingPoilcyPrivileges.ToList(), true),
+		ForceNew:     true,
+	},
+	"roles": {
+		Type:        schema.TypeSet,
+		Elem:        &schema.Schema{Type: schema.TypeString},
+		Optional:    true,
+		Description: "Grants privilege to these roles.",
+		ForceNew:    true,
+	},
+	"schema_name": {
+		Type:        schema.TypeString,
+		Required:    true,
+		Description: "The name of the schema containing the masking policy on which to grant privileges.",
+		ForceNew:    true,
+	},
+	"with_grant_option": {
+		Type:        schema.TypeBool,
+		Optional:    true,
+		Description: "When this is set to true, allows the recipient role to grant the privileges to other roles.",
+		Default:     false,
+		ForceNew:    true,
+	},
+}
+
+// MaskingPolicyGrant returns a pointer to the resource representing a masking policy grant
+func MaskingPolicyGrant() *TerraformGrantResource {
+	return &TerraformGrantResource{
+		Resource: &schema.Resource{
+			Create: CreateMaskingPolicyGrant,
+			Read:   ReadMaskingPolicyGrant,
+			Delete: DeleteMaskingPolicyGrant,
+
+			Schema: maskingPolicyGrantSchema,
+			Importer: &schema.ResourceImporter{
+				StateContext: schema.ImportStatePassthroughContext,
+			},
+		},
+		ValidPrivs: validMaskingPoilcyPrivileges,
+	}
+}
+
+// CreateMaskingPolicyGrant implements schema.CreateFunc
+func CreateMaskingPolicyGrant(d *schema.ResourceData, meta interface{}) error {
+	var maskingPolicyName string
+	if name, ok := d.GetOk("masking_policy_name"); ok {
+		maskingPolicyName = name.(string)
+	}
+	dbName := d.Get("database_name").(string)
+	schemaName := d.Get("schema_name").(string)
+	priv := d.Get("privilege").(string)
+	grantOption := d.Get("with_grant_option").(bool)
+
+	builder := snowflake.MaskingPolicyGrant(dbName, schemaName, maskingPolicyName)
+
+	err := createGenericGrant(d, meta, builder)
+	if err != nil {
+		return err
+	}
+
+	grant := &grantID{
+		ResourceName: dbName,
+		SchemaName:   schemaName,
+		ObjectName:   maskingPolicyName,
+		Privilege:    priv,
+		GrantOption:  grantOption,
+	}
+	dataIDInput, err := grant.String()
+	if err != nil {
+		return err
+	}
+	d.SetId(dataIDInput)
+
+	return ReadMaskingPolicyGrant(d, meta)
+}
+
+// ReadMaskingPolicyGrant implements schema.ReadFunc
+func ReadMaskingPolicyGrant(d *schema.ResourceData, meta interface{}) error {
+	grantID, err := grantIDFromString(d.Id())
+	if err != nil {
+		return err
+	}
+	dbName := grantID.ResourceName
+	schemaName := grantID.SchemaName
+	maskingPolicyName := grantID.ObjectName
+	priv := grantID.Privilege
+
+	err = d.Set("database_name", dbName)
+	if err != nil {
+		return err
+	}
+	err = d.Set("schema_name", schemaName)
+	if err != nil {
+		return err
+	}
+	err = d.Set("masking_policy_name", maskingPolicyName)
+	if err != nil {
+		return err
+	}
+	err = d.Set("privilege", priv)
+	if err != nil {
+		return err
+	}
+	err = d.Set("with_grant_option", grantID.GrantOption)
+	if err != nil {
+		return err
+	}
+
+	builder := snowflake.MaskingPolicyGrant(dbName, schemaName, maskingPolicyName)
+
+	return readGenericGrant(d, meta, maskingPolicyGrantSchema, builder, false, validMaskingPoilcyPrivileges)
+}
+
+// DeleteMaskingPolicyGrant implements schema.DeleteFunc
+func DeleteMaskingPolicyGrant(d *schema.ResourceData, meta interface{}) error {
+	grantID, err := grantIDFromString(d.Id())
+	if err != nil {
+		return err
+	}
+	dbName := grantID.ResourceName
+	schemaName := grantID.SchemaName
+	maskingPolicyName := grantID.ObjectName
+
+	builder := snowflake.MaskingPolicyGrant(dbName, schemaName, maskingPolicyName)
+
+	return deleteGenericGrant(d, meta, builder)
+}
diff --git a/pkg/resources/privileges.go b/pkg/resources/privileges.go
index 63d00929..6f7d60be 100644
--- a/pkg/resources/privileges.go
+++ b/pkg/resources/privileges.go
@@ -41,6 +41,7 @@ const (
 	privilegeImportShare            Privilege = "IMPORT SHARE"
 	privilegeAddSearchOptimization  Privilege = "ADD SEARCH OPTIMIZATION"
 	privilegeApplyMaskingPolicy     Privilege = "APPLY MASKING POLICY"
+	privilegeApply                  Privilege = "APPLY"
 
 	privilegeCreateRole        Privilege = "CREATE ROLE"
 	privilegeCreateUser        Privilege = "CREATE USER"
diff --git a/pkg/snowflake/grant.go b/pkg/snowflake/grant.go
index 2b9c2878..172827e7 100644
--- a/pkg/snowflake/grant.go
+++ b/pkg/snowflake/grant.go
@@ -26,6 +26,7 @@ const (
 	procedureType        grantType = "PROCEDURE"
 	sequenceType         grantType = "SEQUENCE"
 	streamType           grantType = "STREAM"
+	maskingPolicyType    grantType = "MASKING POLICY"
 )
 
 type GrantExecutable interface {
@@ -153,7 +154,7 @@ func ViewGrant(db, schema, view string) GrantBuilder {
 	}
 }
 
-// MaterializedViewGrant returns a pointer to a CurrentGrantBuilder for a view
+// MaterializedViewGrant returns a pointer to a CurrentGrantBuilder for a materialized view
 func MaterializedViewGrant(db, schema, view string) GrantBuilder {
 	return &CurrentMaterializedViewGrantBuilder{
 		name:          view,
@@ -171,7 +172,7 @@ func TableGrant(db, schema, table string) GrantBuilder {
 	}
 }
 
-// ResourceMonitorGrant returns a pointer to a CurrentGrantBuilder for a warehouse
+// ResourceMonitorGrant returns a pointer to a CurrentGrantBuilder for a resource monitor
 func ResourceMonitorGrant(w string) GrantBuilder {
 	return &CurrentGrantBuilder{
 		name:          w,
@@ -180,7 +181,7 @@ func ResourceMonitorGrant(w string) GrantBuilder {
 	}
 }
 
-// IntegrationGrant returns a pointer to a CurrentGrantBuilder for a warehouse
+// IntegrationGrant returns a pointer to a CurrentGrantBuilder for an integration
 func IntegrationGrant(w string) GrantBuilder {
 	return &CurrentGrantBuilder{
 		name:          w,
@@ -198,7 +199,7 @@ func WarehouseGrant(w string) GrantBuilder {
 	}
 }
 
-// ExternalTableGrant returns a pointer to a CurrentGrantBuilder for a view
+// ExternalTableGrant returns a pointer to a CurrentGrantBuilder for an external table
 func ExternalTableGrant(db, schema, externalTable string) GrantBuilder {
 	return &CurrentGrantBuilder{
 		name:          externalTable,
@@ -207,7 +208,7 @@ func ExternalTableGrant(db, schema, externalTable string) GrantBuilder {
 	}
 }
 
-// FileFormatGrant returns a pointer to a CurrentGrantBuilder for a view
+// FileFormatGrant returns a pointer to a CurrentGrantBuilder for a file format
 func FileFormatGrant(db, schema, fileFormat string) GrantBuilder {
 	return &CurrentGrantBuilder{
 		name:          fileFormat,
@@ -225,7 +226,7 @@ func FunctionGrant(db, schema, function string, argumentTypes []string) GrantBui
 	}
 }
 
-// ProcedureGrant returns a pointer to a CurrentGrantBuilder for a view
+// ProcedureGrant returns a pointer to a CurrentGrantBuilder for a procedure
 func ProcedureGrant(db, schema, procedure string, argumentTypes []string) GrantBuilder {
 	return &CurrentGrantBuilder{
 		name:          procedure,
@@ -234,7 +235,7 @@ func ProcedureGrant(db, schema, procedure string, argumentTypes []string) GrantB
 	}
 }
 
-// SequenceGrant returns a pointer to a CurrentGrantBuilder for a view
+// SequenceGrant returns a pointer to a CurrentGrantBuilder for a sequence
 func SequenceGrant(db, schema, sequence string) GrantBuilder {
 	return &CurrentGrantBuilder{
 		name:          sequence,
@@ -243,7 +244,7 @@ func SequenceGrant(db, schema, sequence string) GrantBuilder {
 	}
 }
 
-// StreamGrant returns a pointer to a CurrentGrantBuilder for a view
+// StreamGrant returns a pointer to a CurrentGrantBuilder for a stream
 func StreamGrant(db, schema, stream string) GrantBuilder {
 	return &CurrentGrantBuilder{
 		name:          stream,
@@ -252,6 +253,15 @@ func StreamGrant(db, schema, stream string) GrantBuilder {
 	}
 }
 
+// MaskingPolicyGrant returns a pointer to a CurrentGrantBuilder for a masking policy
+func MaskingPolicyGrant(db, schema, maskingPolicy string) GrantBuilder {
+	return &CurrentGrantBuilder{
+		name:          maskingPolicy,
+		qualifiedName: fmt.Sprintf(`"%v"."%v"."%v"`, db, schema, maskingPolicy),
+		grantType:     maskingPolicyType,
+	}
+}
+
 type granteeType string
 
 const (


Test patch:
diff --git a/pkg/resources/helpers_test.go b/pkg/resources/helpers_test.go
index fa5155b1..ff87873c 100644
--- a/pkg/resources/helpers_test.go
+++ b/pkg/resources/helpers_test.go
@@ -303,3 +303,11 @@ func procedureGrant(t *testing.T, id string, params map[string]interface{}) *sch
 	d.SetId(id)
 	return d
 }
+
+func maskingPolicyGrant(t *testing.T, id string, params map[string]interface{}) *schema.ResourceData {
+	r := require.New(t)
+	d := schema.TestResourceDataRaw(t, resources.MaskingPolicyGrant().Resource.Schema, params)
+	r.NotNil(d)
+	d.SetId(id)
+	return d
+}
diff --git a/pkg/resources/masking_policy_grant_acceptance_test.go b/pkg/resources/masking_policy_grant_acceptance_test.go
new file mode 100644
index 00000000..80fd1620
--- /dev/null
+++ b/pkg/resources/masking_policy_grant_acceptance_test.go
@@ -0,0 +1,67 @@
+package resources_test
+
+import (
+	"fmt"
+	"strings"
+	"testing"
+
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
+)
+
+func TestAcc_MaskingPolicyGrant(t *testing.T) {
+	accName := strings.ToUpper(acctest.RandStringFromCharSet(10, acctest.CharSetAlpha))
+
+	resource.Test(t, resource.TestCase{
+		Providers: providers(),
+		Steps: []resource.TestStep{
+			{
+				Config: maskingPolicyGrantConfig(accName),
+				Check: resource.ComposeTestCheckFunc(
+					resource.TestCheckResourceAttr("snowflake_masking_policy_grant.test", "database_name", accName),
+					resource.TestCheckResourceAttr("snowflake_masking_policy_grant.test", "schema_name", accName),
+					resource.TestCheckResourceAttr("snowflake_masking_policy_grant.test", "masking_policy_name", accName),
+					resource.TestCheckResourceAttr("snowflake_masking_policy_grant.test", "with_grant_option", "false"),
+					resource.TestCheckResourceAttr("snowflake_masking_policy_grant.test", "privilege", "APPLY"),
+				),
+			},
+		},
+	})
+}
+
+func maskingPolicyGrantConfig(name string) string {
+	return fmt.Sprintf(`
+	resource "snowflake_database" "test" {
+		name = "%v"
+		comment = "Terraform acceptance test"
+	}
+
+	resource "snowflake_schema" "test" {
+		name = "%v"
+		database = snowflake_database.test.name
+		comment = "Terraform acceptance test"
+	}
+
+	resource "snowflake_role" "test" {
+		name = "%v"
+	}
+
+	resource "snowflake_masking_policy" "test" {
+		name = "%v"
+		database = snowflake_database.test.name
+		schema = snowflake_schema.test.name
+		value_data_type = "VARCHAR"
+		masking_expression = "case when current_role() in ('ANALYST') then val else sha2(val, 512) end"
+		return_data_type = "VARCHAR(16777216)"
+		comment = "Terraform acceptance test"
+	}
+
+	resource "snowflake_masking_policy_grant" "test" {
+		masking_policy_name = snowflake_masking_policy.test.name
+		database_name = snowflake_database.test.name
+		roles         = [snowflake_role.test.name]
+		schema_name   = snowflake_schema.test.name
+		privilege = "APPLY"
+	}
+	`, name, name, name, name)
+}
diff --git a/pkg/resources/masking_policy_grant_test.go b/pkg/resources/masking_policy_grant_test.go
new file mode 100644
index 00000000..1bb9a4a5
--- /dev/null
+++ b/pkg/resources/masking_policy_grant_test.go
@@ -0,0 +1,79 @@
+package resources_test
+
+import (
+	"database/sql"
+	"testing"
+	"time"
+
+	sqlmock "github.com/DATA-DOG/go-sqlmock"
+	"github.com/chanzuckerberg/terraform-provider-snowflake/pkg/provider"
+	"github.com/chanzuckerberg/terraform-provider-snowflake/pkg/resources"
+	. "github.com/chanzuckerberg/terraform-provider-snowflake/pkg/testhelpers"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+	"github.com/stretchr/testify/require"
+)
+
+func TestMaskingPolicyGrant(t *testing.T) {
+	r := require.New(t)
+	err := resources.MaskingPolicyGrant().Resource.InternalValidate(provider.Provider().Schema, true)
+	r.NoError(err)
+}
+
+func TestMaskingPolicyGrantCreate(t *testing.T) {
+	r := require.New(t)
+
+	in := map[string]interface{}{
+		"masking_policy_name": "test-masking-policy",
+		"schema_name":         "PUBLIC",
+		"database_name":       "test-db",
+		"privilege":           "APPLY",
+		"roles":               []interface{}{"test-role-1", "test-role-2"},
+	}
+	d := schema.TestResourceDataRaw(t, resources.MaskingPolicyGrant().Resource.Schema, in)
+	r.NotNil(d)
+
+	WithMockDb(t, func(db *sql.DB, mock sqlmock.Sqlmock) {
+		mock.ExpectExec(`^GRANT APPLY ON MASKING POLICY "test-db"."PUBLIC"."test-masking-policy" TO ROLE "test-role-1"$`).WillReturnResult(sqlmock.NewResult(1, 1))
+		mock.ExpectExec(`^GRANT APPLY ON MASKING POLICY "test-db"."PUBLIC"."test-masking-policy" TO ROLE "test-role-2"$`).WillReturnResult(sqlmock.NewResult(1, 1))
+		expectReadMaskingPolicyGrant(mock)
+		err := resources.CreateMaskingPolicyGrant(d, db)
+		r.NoError(err)
+	})
+}
+
+func TestMaskingPolicyGrantRead(t *testing.T) {
+	r := require.New(t)
+
+	d := maskingPolicyGrant(t, "test-db|PUBLIC|test-masking-policy|APPLY|false", map[string]interface{}{
+		"masking_policy_name": "test-masking-policy",
+		"schema_name":         "PUBLIC",
+		"database_name":       "test-db",
+		"privilege":           "APPLY",
+		"roles":               []interface{}{},
+		"with_grant_option":   false,
+	})
+
+	r.NotNil(d)
+
+	WithMockDb(t, func(db *sql.DB, mock sqlmock.Sqlmock) {
+		expectReadMaskingPolicyGrant(mock)
+		err := resources.ReadMaskingPolicyGrant(d, db)
+		r.NoError(err)
+	})
+
+	roles := d.Get("roles").(*schema.Set)
+	r.True(roles.Contains("test-role-1"))
+	r.True(roles.Contains("test-role-2"))
+	r.Equal(roles.Len(), 2)
+}
+
+func expectReadMaskingPolicyGrant(mock sqlmock.Sqlmock) {
+	rows := sqlmock.NewRows([]string{
+		"created_on", "privilege", "granted_on", "name", "granted_to", "grantee_name", "grant_option", "granted_by",
+	}).AddRow(
+		time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), "APPLY", "MASKING_POLICY", "test-masking-policy", "ROLE", "test-role-1", false, "bob",
+	).AddRow(
+		time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), "APPLY", "MASKING_POLICY", "test-masking-policy", "ROLE", "test-role-2", false, "bob",
+	)
+	mock.ExpectQuery(`^SHOW GRANTS ON MASKING POLICY "test-db"."PUBLIC"."test-masking-policy"$`).WillReturnRows(rows)
+}
diff --git a/pkg/snowflake/grant_test.go b/pkg/snowflake/grant_test.go
index 1b793d66..d2cdbd75 100644
--- a/pkg/snowflake/grant_test.go
+++ b/pkg/snowflake/grant_test.go
@@ -309,6 +309,21 @@ func TestResourceMonitorGrant(t *testing.T) {
 	r.Equal([]string{`SET currentRole=CURRENT_ROLE()`, `GRANT OWNERSHIP ON RESOURCE MONITOR "test_monitor" TO ROLE IDENTIFIER($currentRole) COPY CURRENT GRANTS`}, revoke)
 }
 
+func TestMaskingPolicyGrant(t *testing.T) {
+	r := require.New(t)
+	mg := snowflake.MaskingPolicyGrant("test_db", "PUBLIC", "testMaskingPolicy")
+	r.Equal(mg.Name(), "testMaskingPolicy")
+
+	s := mg.Show()
+	r.Equal(`SHOW GRANTS ON MASKING POLICY "test_db"."PUBLIC"."testMaskingPolicy"`, s)
+
+	s = mg.Role("bob").Grant("APPLY", false)
+	r.Equal(`GRANT APPLY ON MASKING POLICY "test_db"."PUBLIC"."testMaskingPolicy" TO ROLE "bob"`, s)
+
+	revoke := mg.Role("bob").Revoke("APPLY")
+	r.Equal([]string{`REVOKE APPLY ON MASKING POLICY "test_db"."PUBLIC"."testMaskingPolicy" FROM ROLE "bob"`}, revoke)
+}
+
 func TestShowGrantsOf(t *testing.T) {
 	r := require.New(t)
 	s := snowflake.ViewGrant("test_db", "PUBLIC", "testView").Role("testRole").Show()