diff --git a/pkg/resources/diff_suppressions.go b/pkg/resources/diff_suppressions.go index 597529e4ec..02af596885 100644 --- a/pkg/resources/diff_suppressions.go +++ b/pkg/resources/diff_suppressions.go @@ -254,3 +254,24 @@ func IgnoreNewEmptyListOrSubfields(ignoredSubfields ...string) schema.SchemaDiff return len(parts) == 3 && slices.Contains(ignoredSubfields, parts[2]) && new == "" } } + +// IgnoreMatchingColumnNameAndMaskingPolicyUsingFirstElem ignores when the first element of USING is matching the column name. +// see USING section in https://docs.snowflake.com/en/sql-reference/sql/create-view#optional-parameters +func IgnoreMatchingColumnNameAndMaskingPolicyUsingFirstElem() schema.SchemaDiffSuppressFunc { + return func(k, old, new string, d *schema.ResourceData) bool { + // suppress diff when the name of the column matches the name of using + parts := strings.SplitN(k, ".", 6) + if len(parts) < 6 { + log.Printf("[DEBUG] invalid resource key: %s", parts) + return false + } + // key is element count + if parts[5] == "#" && old == "1" && new == "0" { + return true + } + colNameKey := strings.Join([]string{parts[0], parts[1], "column_name"}, ".") + colName := d.Get(colNameKey).(string) + + return new == "" && old == colName + } +} diff --git a/pkg/resources/diff_suppressions_test.go b/pkg/resources/diff_suppressions_test.go index 7c54f03938..aae080705d 100644 --- a/pkg/resources/diff_suppressions_test.go +++ b/pkg/resources/diff_suppressions_test.go @@ -204,3 +204,86 @@ func Test_ignoreNewEmptyList(t *testing.T) { }) } } + +func Test_IgnoreMatchingColumnNameAndMaskingPolicyUsingFirstElem(t *testing.T) { + resourceSchema := map[string]*schema.Schema{ + "column": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "column_name": { + Type: schema.TypeString, + Required: true, + }, + "masking_policy": { + Type: schema.TypeList, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "using": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + }, + }, + }, + }, + }, + }, + } + resourceData := func(using ...any) map[string]any { + return map[string]any{ + "column": []any{ + map[string]any{ + "column_name": "foo", + "masking_policy": []any{ + map[string]any{ + "using": using, + }, + }, + }, + }, + } + } + tests := []struct { + name string + key string + old string + new string + resourceData *schema.ResourceData + wantSuppress bool + }{ + // TODO: add more cases? + { + name: "suppress when USING is not specified in the config, but is in the state - check count", + key: "column.0.masking_policy.0.using.#", + old: "1", + new: "0", + resourceData: schema.TestResourceDataRaw(t, resourceSchema, resourceData("foo")), + wantSuppress: true, + }, + { + name: "suppress when USING is not specified in the config, but is in the state - check elem", + key: "column.0.masking_policy.0.using.0", + old: "foo", + new: "", + resourceData: schema.TestResourceDataRaw(t, resourceSchema, resourceData("foo")), + wantSuppress: true, + }, + { + name: "do not suppress when there is column name mismatch", + key: "column.0.masking_policy.0.using.0", + old: "foo", + new: "bar", + resourceData: schema.TestResourceDataRaw(t, resourceSchema, resourceData("foo")), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.wantSuppress, resources.IgnoreMatchingColumnNameAndMaskingPolicyUsingFirstElem()(tt.key, tt.old, tt.new, tt.resourceData)) + }) + } +} diff --git a/pkg/resources/testdata/TestAcc_View/columns/test.tf b/pkg/resources/testdata/TestAcc_View/columns/test.tf index fd5b201fe7..7c76773ae6 100644 --- a/pkg/resources/testdata/TestAcc_View/columns/test.tf +++ b/pkg/resources/testdata/TestAcc_View/columns/test.tf @@ -13,7 +13,7 @@ resource "snowflake_view" "test" { masking_policy { policy_name = var.masking_name - using = var.masking_using + using = try(var.masking_using, null) } } diff --git a/pkg/resources/testdata/TestAcc_View/columns/variables.tf b/pkg/resources/testdata/TestAcc_View/columns/variables.tf index ba6e4bfe0d..462b89d908 100644 --- a/pkg/resources/testdata/TestAcc_View/columns/variables.tf +++ b/pkg/resources/testdata/TestAcc_View/columns/variables.tf @@ -23,5 +23,6 @@ variable "masking_name" { } variable "masking_using" { - type = list(string) + type = list(string) + default = null } diff --git a/pkg/resources/view.go b/pkg/resources/view.go index aaa77b53d3..56716321a0 100644 --- a/pkg/resources/view.go +++ b/pkg/resources/view.go @@ -167,7 +167,8 @@ var viewSchema = map[string]*schema.Schema{ Elem: &schema.Schema{ Type: schema.TypeString, }, - Description: "Specifies the arguments to pass into the conditional masking policy SQL expression. The first column in the list specifies the column for the policy conditions to mask or tokenize the data and must match the column to which the masking policy is set. The additional columns specify the columns to evaluate to determine whether to mask or tokenize the data in each row of the query result when a query is made on the first column. If the USING clause is omitted, Snowflake treats the conditional masking policy as a normal masking policy.", + DiffSuppressFunc: IgnoreMatchingColumnNameAndMaskingPolicyUsingFirstElem(), + Description: "Specifies the arguments to pass into the conditional masking policy SQL expression. The first column in the list specifies the column for the policy conditions to mask or tokenize the data and must match the column to which the masking policy is set. The additional columns specify the columns to evaluate to determine whether to mask or tokenize the data in each row of the query result when a query is made on the first column. If the USING clause is omitted, Snowflake treats the conditional masking policy as a normal masking policy.", }, }, }, @@ -571,6 +572,7 @@ func extractPolicyWithColumnsList(v any, columnsKey string) (sdk.SchemaObjectIde return sdk.SchemaObjectIdentifier{}, nil, err } if policyConfig[columnsKey] == nil { + // TODO: fix return id, nil, fmt.Errorf("unable to extract policy with column list, unable to find columnsKey: %s", columnsKey) } columnsRaw := expandStringList(policyConfig[columnsKey].([]any)) @@ -779,7 +781,9 @@ func handleColumns(d ResourceValueSetter, columns []sdk.ViewDetails, policyRefs projectionPolicy, err := collections.FindFirst(policyRefs, func(r sdk.PolicyReference) bool { return r.PolicyKind == sdk.PolicyKindProjectionPolicy && r.RefColumnName != nil && *r.RefColumnName == column.Name }) - if err == nil { + if err != nil { + columnsRaw[i]["projection_policy"] = nil + } else { if projectionPolicy.PolicyDb != nil && projectionPolicy.PolicySchema != nil { columnsRaw[i]["projection_policy"] = []map[string]any{ { @@ -793,6 +797,9 @@ func handleColumns(d ResourceValueSetter, columns []sdk.ViewDetails, policyRefs maskingPolicy, err := collections.FindFirst(policyRefs, func(r sdk.PolicyReference) bool { return r.PolicyKind == sdk.PolicyKindMaskingPolicy && r.RefColumnName != nil && *r.RefColumnName == column.Name }) + // if err != nil { + // columnsRaw[i]["masking_policy"] = nil + // } else { if err == nil { if maskingPolicy.PolicyDb != nil && maskingPolicy.PolicySchema != nil { var usingArgs []string diff --git a/pkg/resources/view_acceptance_test.go b/pkg/resources/view_acceptance_test.go index 2db7a4d7a1..f5238171e7 100644 --- a/pkg/resources/view_acceptance_test.go +++ b/pkg/resources/view_acceptance_test.go @@ -806,6 +806,118 @@ end;; }) } +func TestAcc_View_columnsWithMaskingPolicyWithoutUsing(t *testing.T) { + t.Setenv(string(testenvs.ConfigureClientOnce), "") + _ = testenvs.GetOrSkipTest(t, testenvs.EnableAcceptance) + acc.TestAccPreCheck(t) + + id := acc.TestClient().Ids.RandomSchemaObjectIdentifier() + table, tableCleanup := acc.TestClient().Table.CreateWithColumns(t, []sdk.TableColumnRequest{ + *sdk.NewTableColumnRequest("id", sdk.DataTypeNumber), + *sdk.NewTableColumnRequest("foo", sdk.DataTypeNumber), + *sdk.NewTableColumnRequest("bar", sdk.DataTypeNumber), + }) + t.Cleanup(tableCleanup) + statement := fmt.Sprintf("SELECT id, foo FROM %s", table.ID().FullyQualifiedName()) + + maskingPolicy, maskingPolicyCleanup := acc.TestClient().MaskingPolicy.CreateMaskingPolicyWithOptions(t, + []sdk.TableColumnSignature{ + { + Name: "One", + Type: sdk.DataTypeNumber, + }, + }, + sdk.DataTypeNumber, + ` +case + when One > 0 then One + else 0 +end;; +`, + new(sdk.CreateMaskingPolicyOptions), + ) + t.Cleanup(maskingPolicyCleanup) + + projectionPolicy, projectionPolicyCleanup := acc.TestClient().ProjectionPolicy.CreateProjectionPolicy(t) + t.Cleanup(projectionPolicyCleanup) + + // generators currently don't handle lists of objects, so use the old way + basicView := func(columns ...string) config.Variables { + return config.Variables{ + "name": config.StringVariable(id.Name()), + "database": config.StringVariable(id.DatabaseName()), + "schema": config.StringVariable(id.SchemaName()), + "statement": config.StringVariable(statement), + "column": config.SetVariable( + collections.Map(columns, func(columnName string) config.Variable { + return config.MapVariable(map[string]config.Variable{ + "column_name": config.StringVariable(columnName), + }) + })..., + ), + } + } + + basicViewWithPolicies := func() config.Variables { + conf := basicView("ID", "FOO") + delete(conf, "column") + conf["projection_name"] = config.StringVariable(projectionPolicy.FullyQualifiedName()) + conf["masking_name"] = config.StringVariable(maskingPolicy.ID().FullyQualifiedName()) + return conf + } + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: acc.TestAccProtoV6ProviderFactories, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.RequireAbove(tfversion.Version1_5_0), + }, + CheckDestroy: acc.CheckDestroy(t, resources.View), + Steps: []resource.TestStep{ + // With all policies on columns + { + ConfigDirectory: acc.ConfigurationDirectory("TestAcc_View/columns"), + ConfigVariables: basicViewWithPolicies(), + Check: assert.AssertThat(t, + resourceassert.ViewResource(t, "snowflake_view.test"). + HasNameString(id.Name()). + HasStatementString(statement). + HasDatabaseString(id.DatabaseName()). + HasSchemaString(id.SchemaName()). + HasColumnLength(2), + objectassert.View(t, id). + HasMaskingPolicyReferences(acc.TestClient(), 1). + HasProjectionPolicyReferences(acc.TestClient(), 1), + ), + }, + // Remove policies on columns externally + { + ConfigDirectory: acc.ConfigurationDirectory("TestAcc_View/columns"), + ConfigVariables: basicViewWithPolicies(), + PreConfig: func() { + acc.TestClient().View.Alter(t, sdk.NewAlterViewRequest(id).WithUnsetMaskingPolicyOnColumn(*sdk.NewViewUnsetColumnMaskingPolicyRequest("ID"))) + acc.TestClient().View.Alter(t, sdk.NewAlterViewRequest(id).WithUnsetProjectionPolicyOnColumn(*sdk.NewViewUnsetProjectionPolicyRequest("ID"))) + }, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("snowflake_view.test", plancheck.ResourceActionUpdate), + }, + }, + Check: assert.AssertThat(t, + resourceassert.ViewResource(t, "snowflake_view.test"). + HasNameString(id.Name()). + HasStatementString(statement). + HasDatabaseString(id.DatabaseName()). + HasSchemaString(id.SchemaName()). + HasColumnLength(2), + objectassert.View(t, id). + HasMaskingPolicyReferences(acc.TestClient(), 1). + HasProjectionPolicyReferences(acc.TestClient(), 1), + ), + }, + }, + }) +} + func TestAcc_View_Rename(t *testing.T) { t.Setenv(string(testenvs.ConfigureClientOnce), "") statement := "SELECT ROLE_NAME, ROLE_OWNER FROM INFORMATION_SCHEMA.APPLICABLE_ROLES"