From ce80f57225d6019765a50da6eb74619e401db622 Mon Sep 17 00:00:00 2001 From: Nathan Gaberel Date: Mon, 24 Apr 2023 16:37:50 -0700 Subject: [PATCH] feat: masking policy application resource (#1739) * Sort resources and datasources. * Render qualified names with double quotes. * Add qualified_name computed field to table resource. * Add table column masking policy manager. * Add table column masking policy application resource. --- docs/resources/table.md | 1 + ...table_column_masking_policy_application.md | 79 ++++++++++ .../resource.tf | 46 ++++++ pkg/provider/provider.go | 99 ++++++------ pkg/resources/table.go | 6 + ...table_column_masking_policy_application.go | 149 ++++++++++++++++++ ...king_policy_application_acceptance_test.go | 77 +++++++++ pkg/snowflake/identifier.go | 35 +++- pkg/snowflake/masking_policy_application.go | 61 +++++++ .../masking_policy_application_test.go | 67 ++++++++ pkg/snowflake/password_policy_test.go | 10 +- ..._column_masking_policy_application.md.tmpl | 21 +++ 12 files changed, 590 insertions(+), 61 deletions(-) create mode 100644 docs/resources/table_column_masking_policy_application.md create mode 100644 examples/resources/snowflake_table_column_masking_policy_application/resource.tf create mode 100644 pkg/resources/table_column_masking_policy_application.go create mode 100644 pkg/resources/table_column_masking_policy_application_acceptance_test.go create mode 100644 pkg/snowflake/masking_policy_application.go create mode 100644 pkg/snowflake/masking_policy_application_test.go create mode 100644 templates/resources/table_column_masking_policy_application.md.tmpl diff --git a/docs/resources/table.md b/docs/resources/table.md index 8ded6d44..c248babf 100644 --- a/docs/resources/table.md +++ b/docs/resources/table.md @@ -102,6 +102,7 @@ resource "snowflake_table" "table" { - `id` (String) The ID of this resource. - `owner` (String) Name of the role that owns the table. +- `qualified_name` (String) Qualified name of the table. ### Nested Schema for `column` diff --git a/docs/resources/table_column_masking_policy_application.md b/docs/resources/table_column_masking_policy_application.md new file mode 100644 index 00000000..c09224c0 --- /dev/null +++ b/docs/resources/table_column_masking_policy_application.md @@ -0,0 +1,79 @@ +--- +page_title: "snowflake_table_column_masking_policy_application Resource - terraform-provider-snowflake" +subcategory: "" +description: |- + Applies a masking policy to a table column. +--- + +# snowflake_table_column_masking_policy_application (Resource) + +Applies a masking policy to a table column. + +Only one masking policy may be applied per table column, hence only one `snowflake_table_column_masking_policy_application` resources may be present per table column. +Using two or more `snowflake_table_column_masking_policy_application` resources for the same table column will result in the last one overriding any previously applied masking policies and unresolvable diffs in Terraform plan. + +When using this resource to manage a table column's masking policy make sure to ignore changes to the column's masking policy in the table definition, otherwise the two resources would conflict. See example below. + +## Example Usage + +```terraform +# Default provider for most resources +provider "snowflake" { + role = "SYSADMIN" +} + +# Alternative provider with masking_admin role +provider "snowflake" { + alias = "masking" + role = "MASKING_ADMIN" +} + +resource "snowflake_masking_policy" "policy" { + provider = snowflake.masking # Create masking policy with masking_admin role + + name = "EXAMPLE_MASKING_POLICY" + database = "EXAMPLE_DB" + schema = "EXAMPLE_SCHEMA" + value_data_type = "VARCHAR" + masking_expression = "case when current_role() in ('ANALYST') then val else sha2(val, 512) end" + return_data_type = "VARCHAR" +} + +# Table is created by the default provider +resource "snowflake_table" "table" { + database = "EXAMPLE_DB" + schema = "EXAMPLE_SCHEMA" + name = "table" + + column { + name = "secret" + type = "VARCHAR(16777216)" + } + + lifecycle { + # Masking policy is managed by a standalone resource and shouldn't be changed by the table resource. + ignore_changes = [column[0].masking_policy] + } +} + +resource "snowflake_table_column_masking_view_application" "application" { + provider = snowflake.masking # Apply masking policy with masking_admin role + + table = snowflake_table.table.qualified_name + column = "age" + masking_policy = snowflake_masking_policy.policy.qualified_name +} +``` + + +## Schema + +### Required + +- `column` (String) The column to apply the masking policy to. +- `masking_policy` (String) Fully qualified name (`database.schema.policyname`) of the policy to apply. +- `table` (String) The fully qualified name (`database.schema.table`) of the table to apply the masking policy to. + +### Read-Only + +- `id` (String) The ID of this resource. diff --git a/examples/resources/snowflake_table_column_masking_policy_application/resource.tf b/examples/resources/snowflake_table_column_masking_policy_application/resource.tf new file mode 100644 index 00000000..de9e002d --- /dev/null +++ b/examples/resources/snowflake_table_column_masking_policy_application/resource.tf @@ -0,0 +1,46 @@ +# Default provider for most resources +provider "snowflake" { + role = "SYSADMIN" +} + +# Alternative provider with masking_admin role +provider "snowflake" { + alias = "masking" + role = "MASKING_ADMIN" +} + +resource "snowflake_masking_policy" "policy" { + provider = snowflake.masking # Create masking policy with masking_admin role + + name = "EXAMPLE_MASKING_POLICY" + database = "EXAMPLE_DB" + schema = "EXAMPLE_SCHEMA" + value_data_type = "VARCHAR" + masking_expression = "case when current_role() in ('ANALYST') then val else sha2(val, 512) end" + return_data_type = "VARCHAR" +} + +# Table is created by the default provider +resource "snowflake_table" "table" { + database = "EXAMPLE_DB" + schema = "EXAMPLE_SCHEMA" + name = "table" + + column { + name = "secret" + type = "VARCHAR(16777216)" + } + + lifecycle { + # Masking policy is managed by a standalone resource and shouldn't be changed by the table resource. + ignore_changes = [column[0].masking_policy] + } +} + +resource "snowflake_table_column_masking_view_application" "application" { + provider = snowflake.masking # Apply masking policy with masking_admin role + + table = snowflake_table.table.qualified_name + column = "age" + masking_policy = snowflake_masking_policy.policy.qualified_name +} diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go index b7cb37e8..1d84c771 100644 --- a/pkg/provider/provider.go +++ b/pkg/provider/provider.go @@ -204,9 +204,9 @@ func GetGrantResources() resources.TerraformGrantResources { "snowflake_table_grant": resources.TableGrant(), "snowflake_tag_grant": resources.TagGrant(), "snowflake_task_grant": resources.TaskGrant(), + "snowflake_user_grant": resources.UserGrant(), "snowflake_view_grant": resources.ViewGrant(), "snowflake_warehouse_grant": resources.WarehouseGrant(), - "snowflake_user_grant": resources.UserGrant(), } return grants } @@ -214,54 +214,55 @@ func GetGrantResources() resources.TerraformGrantResources { func getResources() map[string]*schema.Resource { // NOTE(): do not add grant resources here others := map[string]*schema.Resource{ - "snowflake_account": resources.Account(), - "snowflake_account_parameter": resources.AccountParameter(), - "snowflake_alert": resources.Alert(), - "snowflake_api_integration": resources.APIIntegration(), - "snowflake_database": resources.Database(), - "snowflake_database_role": resources.DatabaseRole(), - "snowflake_external_function": resources.ExternalFunction(), - "snowflake_failover_group": resources.FailoverGroup(), - "snowflake_file_format": resources.FileFormat(), - "snowflake_function": resources.Function(), - "snowflake_managed_account": resources.ManagedAccount(), - "snowflake_masking_policy": resources.MaskingPolicy(), - "snowflake_materialized_view": resources.MaterializedView(), - "snowflake_network_policy_attachment": resources.NetworkPolicyAttachment(), - "snowflake_network_policy": resources.NetworkPolicy(), - "snowflake_oauth_integration": resources.OAuthIntegration(), - "snowflake_object_parameter": resources.ObjectParameter(), - "snowflake_external_oauth_integration": resources.ExternalOauthIntegration(), - "snowflake_password_policy": resources.PasswordPolicy(), - "snowflake_pipe": resources.Pipe(), - "snowflake_procedure": resources.Procedure(), - "snowflake_resource_monitor": resources.ResourceMonitor(), - "snowflake_role": resources.Role(), - "snowflake_role_grants": resources.RoleGrants(), - "snowflake_role_ownership_grant": resources.RoleOwnershipGrant(), - "snowflake_row_access_policy": resources.RowAccessPolicy(), - "snowflake_saml_integration": resources.SAMLIntegration(), - "snowflake_schema": resources.Schema(), - "snowflake_scim_integration": resources.SCIMIntegration(), - "snowflake_sequence": resources.Sequence(), - "snowflake_session_parameter": resources.SessionParameter(), - "snowflake_share": resources.Share(), - "snowflake_stage": resources.Stage(), - "snowflake_storage_integration": resources.StorageIntegration(), - "snowflake_notification_integration": resources.NotificationIntegration(), - "snowflake_stream": resources.Stream(), - "snowflake_table": resources.Table(), - "snowflake_table_constraint": resources.TableConstraint(), - "snowflake_external_table": resources.ExternalTable(), - "snowflake_tag": resources.Tag(), - "snowflake_tag_association": resources.TagAssociation(), - "snowflake_tag_masking_policy_association": resources.TagMaskingPolicyAssociation(), - "snowflake_task": resources.Task(), - "snowflake_user": resources.User(), - "snowflake_user_ownership_grant": resources.UserOwnershipGrant(), - "snowflake_user_public_keys": resources.UserPublicKeys(), - "snowflake_view": resources.View(), - "snowflake_warehouse": resources.Warehouse(), + "snowflake_account": resources.Account(), + "snowflake_account_parameter": resources.AccountParameter(), + "snowflake_alert": resources.Alert(), + "snowflake_api_integration": resources.APIIntegration(), + "snowflake_database": resources.Database(), + "snowflake_database_role": resources.DatabaseRole(), + "snowflake_external_function": resources.ExternalFunction(), + "snowflake_external_oauth_integration": resources.ExternalOauthIntegration(), + "snowflake_external_table": resources.ExternalTable(), + "snowflake_failover_group": resources.FailoverGroup(), + "snowflake_file_format": resources.FileFormat(), + "snowflake_function": resources.Function(), + "snowflake_managed_account": resources.ManagedAccount(), + "snowflake_masking_policy": resources.MaskingPolicy(), + "snowflake_materialized_view": resources.MaterializedView(), + "snowflake_network_policy": resources.NetworkPolicy(), + "snowflake_network_policy_attachment": resources.NetworkPolicyAttachment(), + "snowflake_notification_integration": resources.NotificationIntegration(), + "snowflake_oauth_integration": resources.OAuthIntegration(), + "snowflake_object_parameter": resources.ObjectParameter(), + "snowflake_password_policy": resources.PasswordPolicy(), + "snowflake_pipe": resources.Pipe(), + "snowflake_procedure": resources.Procedure(), + "snowflake_resource_monitor": resources.ResourceMonitor(), + "snowflake_role": resources.Role(), + "snowflake_role_grants": resources.RoleGrants(), + "snowflake_role_ownership_grant": resources.RoleOwnershipGrant(), + "snowflake_row_access_policy": resources.RowAccessPolicy(), + "snowflake_saml_integration": resources.SAMLIntegration(), + "snowflake_schema": resources.Schema(), + "snowflake_scim_integration": resources.SCIMIntegration(), + "snowflake_sequence": resources.Sequence(), + "snowflake_session_parameter": resources.SessionParameter(), + "snowflake_share": resources.Share(), + "snowflake_stage": resources.Stage(), + "snowflake_storage_integration": resources.StorageIntegration(), + "snowflake_stream": resources.Stream(), + "snowflake_table": resources.Table(), + "snowflake_table_column_masking_policy_application": resources.TableColumnMaskingPolicyApplication(), + "snowflake_table_constraint": resources.TableConstraint(), + "snowflake_tag": resources.Tag(), + "snowflake_tag_association": resources.TagAssociation(), + "snowflake_tag_masking_policy_association": resources.TagMaskingPolicyAssociation(), + "snowflake_task": resources.Task(), + "snowflake_user": resources.User(), + "snowflake_user_ownership_grant": resources.UserOwnershipGrant(), + "snowflake_user_public_keys": resources.UserPublicKeys(), + "snowflake_view": resources.View(), + "snowflake_warehouse": resources.Warehouse(), } return mergeSchemas( diff --git a/pkg/resources/table.go b/pkg/resources/table.go index dff3e381..7da719e0 100644 --- a/pkg/resources/table.go +++ b/pkg/resources/table.go @@ -191,6 +191,11 @@ var tableSchema = map[string]*schema.Schema{ Default: false, Description: "Specifies whether to enable change tracking on the table. Default false.", }, + "qualified_name": { + Type: schema.TypeString, + Computed: true, + Description: "Qualified name of the table.", + }, "tag": tagReferenceSchema, } @@ -591,6 +596,7 @@ func ReadTable(d *schema.ResourceData, meta interface{}) error { // "primary_key": snowflake.FlattenTablePrimaryKey(pkDescription), "data_retention_days": table.RetentionTime.Int32, "change_tracking": (table.ChangeTracking.String == "ON"), + "qualified_name": fmt.Sprintf(`"%s"."%s"."%s"`, tableID.DatabaseName, tableID.SchemaName, table.TableName.String), } for key, val := range toSet { diff --git a/pkg/resources/table_column_masking_policy_application.go b/pkg/resources/table_column_masking_policy_application.go new file mode 100644 index 00000000..4d7f3276 --- /dev/null +++ b/pkg/resources/table_column_masking_policy_application.go @@ -0,0 +1,149 @@ +package resources + +import ( + "database/sql" + "fmt" + + "github.com/Snowflake-Labs/terraform-provider-snowflake/pkg/snowflake" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +var tableColumnMaskingPolicyApplicationSchema = map[string]*schema.Schema{ + "table": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The fully qualified name (`database.schema.table`) of the table to apply the masking policy to.", + }, + "column": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The column to apply the masking policy to.", + }, + "masking_policy": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Fully qualified name (`database.schema.policyname`) of the policy to apply.", + }, +} + +func TableColumnMaskingPolicyApplication() *schema.Resource { + return &schema.Resource{ + Description: "Applies a masking policy to a table column.", + Create: CreateTableColumnMaskingPolicyApplication, + Read: ReadTableColumnMaskingPolicyApplication, + Delete: DeleteTableColumnMaskingPolicyApplication, + + Schema: tableColumnMaskingPolicyApplicationSchema, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + } +} + +// CreateTableColumnMaskingPolicyApplication implements schema.CreateFunc. +func CreateTableColumnMaskingPolicyApplication(d *schema.ResourceData, meta interface{}) error { + manager := snowflake.NewTableColumnMaskingPolicyApplicationManager() + + input := &snowflake.TableColumnMaskingPolicyApplicationCreateInput{ + TableColumnMaskingPolicyApplication: snowflake.TableColumnMaskingPolicyApplication{ + Table: snowflake.SchemaObjectIdentifierFromQualifiedName(d.Get("table").(string)), + Column: d.Get("column").(string), + MaskingPolicy: snowflake.SchemaObjectIdentifierFromQualifiedName(d.Get("masking_policy").(string)), + }, + } + + stmt := manager.Create(input) + + db := meta.(*sql.DB) + _, err := db.Exec(stmt) + if err != nil { + return fmt.Errorf("error applying masking policy: %w", err) + } + + d.SetId(TableColumnMaskingPolicyApplicationID(&input.TableColumnMaskingPolicyApplication)) + + return nil +} + +// ReadTableColumnMaskingPolicyApplication implements schema.ReadFunc. +func ReadTableColumnMaskingPolicyApplication(d *schema.ResourceData, meta interface{}) error { + manager := snowflake.NewTableColumnMaskingPolicyApplicationManager() + + table, column := TableColumnMaskingPolicyApplicationIdentifier(d.Id()) + + if err := d.Set("table", table.QualifiedName()); err != nil { + return fmt.Errorf("error setting table: %w", err) + } + if err := d.Set("column", column); err != nil { + return fmt.Errorf("error setting column: %w", err) + } + + input := &snowflake.TableColumnMaskingPolicyApplicationReadInput{ + Table: table, + Column: column, + } + + stmt := manager.Read(input) + + db := meta.(*sql.DB) + rows, err := db.Query(stmt) + if err != nil { + return fmt.Errorf("error querying password policy: %w", err) + } + + defer rows.Close() + maskingPolicy, err := manager.Parse(rows, column) + if err != nil { + return fmt.Errorf("failed to parse result of describe: %w", err) + } + + if err = d.Set("masking_policy", maskingPolicy); err != nil { + return fmt.Errorf("error setting masking_policy: %w", err) + } + + return nil +} + +// DeleteTableColumnMaskingPolicyApplication implements schema.DeleteFunc. +func DeleteTableColumnMaskingPolicyApplication(d *schema.ResourceData, meta interface{}) error { + manager := snowflake.NewTableColumnMaskingPolicyApplicationManager() + + input := &snowflake.TableColumnMaskingPolicyApplicationDeleteInput{ + TableColumn: snowflake.TableColumn{ + Table: snowflake.SchemaObjectIdentifierFromQualifiedName(d.Get("table").(string)), + Column: d.Get("column").(string), + }, + } + + stmt := manager.Delete(input) + + db := meta.(*sql.DB) + _, err := db.Exec(stmt) + if err != nil { + return fmt.Errorf("error executing drop statement: %w", err) + } + + return nil +} + +func TableColumnMaskingPolicyApplicationID(mpa *snowflake.TableColumnMaskingPolicyApplication) string { + identifier := snowflake.ColumnIdentifier{ + Database: mpa.Table.Database, + Schema: mpa.Table.Schema, + ObjectName: mpa.Table.ObjectName, + Column: mpa.Column, + } + return identifier.QualifiedName() +} + +func TableColumnMaskingPolicyApplicationIdentifier(id string) (table *snowflake.SchemaObjectIdentifier, column string) { + columnIdentifier := snowflake.ColumnIdentifierFromQualifiedName(id) + return &snowflake.SchemaObjectIdentifier{ + Database: columnIdentifier.Database, + Schema: columnIdentifier.Schema, + ObjectName: columnIdentifier.ObjectName, + }, columnIdentifier.Column +} diff --git a/pkg/resources/table_column_masking_policy_application_acceptance_test.go b/pkg/resources/table_column_masking_policy_application_acceptance_test.go new file mode 100644 index 00000000..f1fa38bb --- /dev/null +++ b/pkg/resources/table_column_masking_policy_application_acceptance_test.go @@ -0,0 +1,77 @@ +package resources_test + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAcc_TableColumnMaskingPolicyApplication(t *testing.T) { + database := acctest.RandStringFromCharSet(10, acctest.CharSetAlpha) + + resource.ParallelTest(t, resource.TestCase{ + Providers: providers(), + CheckDestroy: nil, + Steps: []resource.TestStep{ + { + Config: maskingPolicyApplicationTestConfig(database), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("snowflake_table_column_masking_policy_application.mpa", "table", fmt.Sprintf(`"%s"."test_schema"."table"`, database)), + ), + }, + { + ResourceName: "snowflake_table_column_masking_policy_application.mpa", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func maskingPolicyApplicationTestConfig(database string) string { + return fmt.Sprintf(` +resource "snowflake_database" "test" { + name = "%v" + comment = "Terraform acceptance test" +} + +resource "snowflake_schema" "test" { + name = "test_schema" + database = snowflake_database.test.name + comment = "Terraform acceptance test" +} + +resource "snowflake_masking_policy" "test" { + name = "mypolicy" + 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" + comment = "Terraform acceptance test" +} + +resource "snowflake_table" "table" { + database = snowflake_database.test.name + schema = snowflake_schema.test.name + name = "table" + + column { + name = "secret" + type = "VARCHAR(16777216)" + } + + lifecycle { + ignore_changes = [column[0].masking_policy] + } +} + +resource "snowflake_table_column_masking_policy_application" "mpa" { + table = snowflake_table.table.qualified_name + column = "secret" + masking_policy = snowflake_masking_policy.test.qualified_name +}`, + database) +} diff --git a/pkg/snowflake/identifier.go b/pkg/snowflake/identifier.go index d36803e4..534a9770 100644 --- a/pkg/snowflake/identifier.go +++ b/pkg/snowflake/identifier.go @@ -29,14 +29,14 @@ type SchemaIdentifier struct { } func (i *SchemaIdentifier) QualifiedName() string { - return fmt.Sprintf("%v.%v", i.Database, i.Schema) + return fmt.Sprintf(`"%v"."%v"`, i.Database, i.Schema) } func SchemaIdentifierFromQualifiedName(name string) *SchemaIdentifier { parts := strings.Split(name, ".") return &SchemaIdentifier{ - Database: parts[0], - Schema: parts[1], + Database: strings.Trim(parts[0], `"`), + Schema: strings.Trim(parts[1], `"`), } } @@ -47,14 +47,35 @@ type SchemaObjectIdentifier struct { } func (i *SchemaObjectIdentifier) QualifiedName() string { - return fmt.Sprintf("%v.%v.%v", i.Database, i.Schema, i.ObjectName) + return fmt.Sprintf(`"%v"."%v"."%v"`, i.Database, i.Schema, i.ObjectName) } func SchemaObjectIdentifierFromQualifiedName(name string) *SchemaObjectIdentifier { parts := strings.Split(name, ".") return &SchemaObjectIdentifier{ - Database: parts[0], - Schema: parts[1], - ObjectName: parts[2], + Database: strings.Trim(parts[0], `"`), + Schema: strings.Trim(parts[1], `"`), + ObjectName: strings.Trim(parts[2], `"`), + } +} + +type ColumnIdentifier struct { + Database string + Schema string + ObjectName string `db:"NAME"` + Column string +} + +func (i *ColumnIdentifier) QualifiedName() string { + return fmt.Sprintf(`"%v"."%v"."%v"."%v"`, i.Database, i.Schema, i.ObjectName, i.Column) +} + +func ColumnIdentifierFromQualifiedName(name string) *ColumnIdentifier { + parts := strings.Split(name, ".") + return &ColumnIdentifier{ + Database: strings.Trim(parts[0], `"`), + Schema: strings.Trim(parts[1], `"`), + ObjectName: strings.Trim(parts[2], `"`), + Column: strings.Trim(parts[3], `"`), } } diff --git a/pkg/snowflake/masking_policy_application.go b/pkg/snowflake/masking_policy_application.go new file mode 100644 index 00000000..8e3c486d --- /dev/null +++ b/pkg/snowflake/masking_policy_application.go @@ -0,0 +1,61 @@ +package snowflake + +import ( + "database/sql" + "fmt" + "strings" +) + +type TableColumnMaskingPolicyApplication struct { + Table *SchemaObjectIdentifier + Column string + MaskingPolicy *SchemaObjectIdentifier +} + +type TableColumn struct { + Table *SchemaObjectIdentifier + Column string +} + +type TableColumnMaskingPolicyApplicationManager struct{} + +func NewTableColumnMaskingPolicyApplicationManager() *TableColumnMaskingPolicyApplicationManager { + return &TableColumnMaskingPolicyApplicationManager{} +} + +type TableColumnMaskingPolicyApplicationCreateInput struct { + TableColumnMaskingPolicyApplication +} + +func (m *TableColumnMaskingPolicyApplicationManager) Create(x *TableColumnMaskingPolicyApplicationCreateInput) string { + return fmt.Sprintf(`ALTER TABLE IF EXISTS %s MODIFY COLUMN "%s" SET MASKING POLICY %s;`, x.Table.QualifiedName(), x.Column, x.MaskingPolicy.QualifiedName()) +} + +type TableColumnMaskingPolicyApplicationReadInput = TableColumn + +func (m *TableColumnMaskingPolicyApplicationManager) Read(x *TableColumnMaskingPolicyApplicationReadInput) string { + return fmt.Sprintf("DESCRIBE TABLE %s TYPE = COLUMNS;", x.Table.QualifiedName()) +} + +func (m *TableColumnMaskingPolicyApplicationManager) Parse(rows *sql.Rows, column string) (string, error) { + var name, sqlType, kind, null, defaultValue, primaryKey, uniqueKey, check, expression, comment, policyName sql.NullString + + for rows.Next() { + if err := rows.Scan(&name, &sqlType, &kind, &null, &defaultValue, &primaryKey, &uniqueKey, &check, &expression, &comment, &policyName); err != nil { + return "", err + } + + if strings.EqualFold(name.String, column) { + return policyName.String, nil + } + } + return "", nil +} + +type TableColumnMaskingPolicyApplicationDeleteInput struct { + TableColumn +} + +func (m *TableColumnMaskingPolicyApplicationManager) Delete(x *TableColumnMaskingPolicyApplicationDeleteInput) string { + return fmt.Sprintf(`ALTER TABLE IF EXISTS %s MODIFY COLUMN "%s" UNSET MASKING POLICY;`, x.Table.QualifiedName(), x.Column) +} diff --git a/pkg/snowflake/masking_policy_application_test.go b/pkg/snowflake/masking_policy_application_test.go new file mode 100644 index 00000000..5d607173 --- /dev/null +++ b/pkg/snowflake/masking_policy_application_test.go @@ -0,0 +1,67 @@ +package snowflake_test + +import ( + "testing" + + "github.com/Snowflake-Labs/terraform-provider-snowflake/pkg/snowflake" + "github.com/stretchr/testify/require" +) + +func TestCreateTableColumnMaskingPolicyApplication(t *testing.T) { + r := require.New(t) + + input := &snowflake.TableColumnMaskingPolicyApplicationCreateInput{ + TableColumnMaskingPolicyApplication: snowflake.TableColumnMaskingPolicyApplication{ + Table: &snowflake.SchemaObjectIdentifier{ + Database: "db", + Schema: "schema", + ObjectName: "table", + }, + Column: "column", + MaskingPolicy: &snowflake.SchemaObjectIdentifier{ + Database: "db", + Schema: "schema", + ObjectName: "mymaskingpolicy", + }, + }, + } + + mb := snowflake.NewTableColumnMaskingPolicyApplicationManager() + createStmt := mb.Create(input) + r.Equal(`ALTER TABLE IF EXISTS "db"."schema"."table" MODIFY COLUMN "column" SET MASKING POLICY "db"."schema"."mymaskingpolicy";`, createStmt) +} + +func TestDeleteTableColumnMaskingPolicyApplication(t *testing.T) { + r := require.New(t) + + input := &snowflake.TableColumnMaskingPolicyApplicationDeleteInput{ + TableColumn: snowflake.TableColumn{ + Table: &snowflake.SchemaObjectIdentifier{ + Database: "db", + Schema: "schema", + ObjectName: "table", + }, + Column: "column", + }, + } + + mb := snowflake.NewTableColumnMaskingPolicyApplicationManager() + dropStmt := mb.Delete(input) + r.Equal(`ALTER TABLE IF EXISTS "db"."schema"."table" MODIFY COLUMN "column" UNSET MASKING POLICY;`, dropStmt) +} + +func TestReadTableColumnMaskingPolicyApplication(t *testing.T) { + r := require.New(t) + + input := &snowflake.TableColumnMaskingPolicyApplicationReadInput{ + Table: &snowflake.SchemaObjectIdentifier{ + Database: "db", + Schema: "schema", + ObjectName: "table", + }, + } + + mb := snowflake.NewTableColumnMaskingPolicyApplicationManager() + describeStmt := mb.Read(input) + r.Equal(`DESCRIBE TABLE "db"."schema"."table" TYPE = COLUMNS;`, describeStmt) +} diff --git a/pkg/snowflake/password_policy_test.go b/pkg/snowflake/password_policy_test.go index 9531763f..75b0754f 100644 --- a/pkg/snowflake/password_policy_test.go +++ b/pkg/snowflake/password_policy_test.go @@ -30,7 +30,7 @@ func TestCreatePasswordPolicy(t *testing.T) { r.Nil(err) createStmt, err := mb.Create(input) r.Nil(err) - r.Equal(`CREATE OR REPLACE PASSWORD POLICY testdb.testschema.testres PASSWORD_MIN_LENGTH = 10;`, createStmt) + r.Equal(`CREATE OR REPLACE PASSWORD POLICY "testdb"."testschema"."testres" PASSWORD_MIN_LENGTH = 10;`, createStmt) } func TestAlterPasswordPolicy(t *testing.T) { @@ -55,7 +55,7 @@ func TestAlterPasswordPolicy(t *testing.T) { alterStmt, err := mb.Update(input) r.Nil(err) r.Equal( - `ALTER PASSWORD POLICY testdb.testschema.passpol SET PASSWORD_MIN_NUMERIC_CHARS = 16 PASSWORD_LOCKOUT_TIME_MINS = 50;`, + `ALTER PASSWORD POLICY "testdb"."testschema"."passpol" SET PASSWORD_MIN_NUMERIC_CHARS = 16 PASSWORD_LOCKOUT_TIME_MINS = 50;`, alterStmt, ) } @@ -80,7 +80,7 @@ func TestUnsetPasswordPolicy(t *testing.T) { unsetStmt, err := mb.Unset(input) r.Nil(err) r.Equal( - `ALTER PASSWORD POLICY testdb.testschema.passpol UNSET PASSWORD_MIN_NUMERIC_CHARS COMMENT;`, + `ALTER PASSWORD POLICY "testdb"."testschema"."passpol" UNSET PASSWORD_MIN_NUMERIC_CHARS COMMENT;`, unsetStmt, ) } @@ -100,7 +100,7 @@ func TestDeletePasswordPolicy(t *testing.T) { r.Nil(err) dropStmt, err := mb.Delete(input) r.Nil(err) - r.Equal(`DROP PASSWORD POLICY testdb.testschema.passpol;`, dropStmt) + r.Equal(`DROP PASSWORD POLICY "testdb"."testschema"."passpol";`, dropStmt) } func TestReadPasswordPolicy(t *testing.T) { @@ -116,5 +116,5 @@ func TestReadPasswordPolicy(t *testing.T) { r.Nil(err) describeStmt, err := mb.Read(input) r.Nil(err) - r.Equal(`DESCRIBE PASSWORD POLICY testdb.testschema.passpol;`, describeStmt) + r.Equal(`DESCRIBE PASSWORD POLICY "testdb"."testschema"."passpol";`, describeStmt) } diff --git a/templates/resources/table_column_masking_policy_application.md.tmpl b/templates/resources/table_column_masking_policy_application.md.tmpl new file mode 100644 index 00000000..f062ca7c --- /dev/null +++ b/templates/resources/table_column_masking_policy_application.md.tmpl @@ -0,0 +1,21 @@ +--- +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + +Only one masking policy may be applied per table column, hence only one `{{.Name}}` resources may be present per table column. +Using two or more `{{.Name}}` resources for the same table column will result in the last one overriding any previously applied masking policies and unresolvable diffs in Terraform plan. + +When using this resource to manage a table column's masking policy make sure to ignore changes to the column's masking policy in the table definition, otherwise the two resources would conflict. See example below. + +## Example Usage + +{{ tffile (printf "examples/resources/%s/resource.tf" .Name)}} + +{{ .SchemaMarkdown | trimspace }}