From 16d1d7f2348a41d90f15651c2109f534e85ac7e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Cie=C5=9Blak?= Date: Fri, 24 Nov 2023 15:39:15 +0100 Subject: [PATCH] Add unsafe exec resource --- docs/resources/unsafe_execute.md | 62 ++++ .../snowflake_unsafe_execute/resource.tf | 32 ++ pkg/provider/provider.go | 1 + pkg/resources/unsafe_execute.go | 78 +++++ .../unsafe_execute_acceptance_test.go | 310 ++++++++++++++++++ pkg/sdk/client.go | 4 + templates/resources/unsafe_execute.md.tmpl | 29 ++ 7 files changed, 516 insertions(+) create mode 100644 docs/resources/unsafe_execute.md create mode 100644 examples/resources/snowflake_unsafe_execute/resource.tf create mode 100644 pkg/resources/unsafe_execute.go create mode 100644 pkg/resources/unsafe_execute_acceptance_test.go create mode 100644 templates/resources/unsafe_execute.md.tmpl diff --git a/docs/resources/unsafe_execute.md b/docs/resources/unsafe_execute.md new file mode 100644 index 0000000000..379efc2eb5 --- /dev/null +++ b/docs/resources/unsafe_execute.md @@ -0,0 +1,62 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "snowflake_unsafe_execute Resource - terraform-provider-snowflake" +subcategory: "" +description: |- + Experimental resource used for testing purposes only. Allows to execute ANY SQL statement. +--- + +# snowflake_unsafe_execute (Resource) + +!> **Warning** This is a dangerous resource that allows executing **ANY** SQL statement. It may destroy resources if used incorrectly. It may behave incorrectly combined with other resources. Will be deleted in the upcoming versions. Use at your own risk. + +Experimental resource used for testing purposes only. Allows to execute ANY SQL statement. + +## Example Usage + +```terraform +# create and destroy resource +resource "snowflake_unsafe_execute" "test" { + execute = "CREATE DATABASE ABC" + revert = "DROP DATABASE ABC" +} + +# create and destroy resource using qualified name +resource "snowflake_unsafe_execute" "test" { + execute = "CREATE DATABASE \"abc\"" + revert = "DROP DATABASE \"abc\"" +} + +# grant and revoke privilege USAGE to ROLE on database +resource "snowflake_unsafe_execute" "test" { + execute = "GRANT USAGE ON DATABASE ABC TO ROLE XYZ" + revert = "REVOKE USAGE ON DATABASE ABC FROM ROLE XYZ" +} + +# grant and revoke with for_each +variable "database_grants" { + type = list(object({ + database_name = string + role_id = string + privileges = list(string) + })) +} + +resource "snowflake_unsafe_execute" "test" { + for_each = { for index, db_grant in var.database_grants : index => db_grant } + execute = "GRANT ${join(",", each.value.privileges)} ON DATABASE ${each.value.database_name} TO ROLE ${each.value.role_id}" + revert = "REVOKE ${join(",", each.value.privileges)} ON DATABASE ${each.value.database_name} FROM ROLE ${each.value.role_id}" +} +``` + + +## Schema + +### Required + +- `execute` (String) SQL statement to execute. +- `revert` (String) SQL statement to revert the execute statement. Invoked when resource is deleted. + +### Read-Only + +- `id` (String) The ID of this resource. diff --git a/examples/resources/snowflake_unsafe_execute/resource.tf b/examples/resources/snowflake_unsafe_execute/resource.tf new file mode 100644 index 0000000000..026ea127c9 --- /dev/null +++ b/examples/resources/snowflake_unsafe_execute/resource.tf @@ -0,0 +1,32 @@ +# create and destroy resource +resource "snowflake_unsafe_execute" "test" { + execute = "CREATE DATABASE ABC" + revert = "DROP DATABASE ABC" +} + +# create and destroy resource using qualified name +resource "snowflake_unsafe_execute" "test" { + execute = "CREATE DATABASE \"abc\"" + revert = "DROP DATABASE \"abc\"" +} + +# grant and revoke privilege USAGE to ROLE on database +resource "snowflake_unsafe_execute" "test" { + execute = "GRANT USAGE ON DATABASE ABC TO ROLE XYZ" + revert = "REVOKE USAGE ON DATABASE ABC FROM ROLE XYZ" +} + +# grant and revoke with for_each +variable "database_grants" { + type = list(object({ + database_name = string + role_id = string + privileges = list(string) + })) +} + +resource "snowflake_unsafe_execute" "test" { + for_each = { for index, db_grant in var.database_grants : index => db_grant } + execute = "GRANT ${join(",", each.value.privileges)} ON DATABASE ${each.value.database_name} TO ROLE ${each.value.role_id}" + revert = "REVOKE ${join(",", each.value.privileges)} ON DATABASE ${each.value.database_name} FROM ROLE ${each.value.role_id}" +} diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go index c5fe8d6e55..236c563c9d 100644 --- a/pkg/provider/provider.go +++ b/pkg/provider/provider.go @@ -276,6 +276,7 @@ func getResources() map[string]*schema.Resource { "snowflake_tag_association": resources.TagAssociation(), "snowflake_tag_masking_policy_association": resources.TagMaskingPolicyAssociation(), "snowflake_task": resources.Task(), + "snowflake_unsafe_execute": resources.UnsafeExecute(), "snowflake_user": resources.User(), "snowflake_user_ownership_grant": resources.UserOwnershipGrant(), "snowflake_user_public_keys": resources.UserPublicKeys(), diff --git a/pkg/resources/unsafe_execute.go b/pkg/resources/unsafe_execute.go new file mode 100644 index 0000000000..3e3dd36333 --- /dev/null +++ b/pkg/resources/unsafe_execute.go @@ -0,0 +1,78 @@ +package resources + +import ( + "context" + "database/sql" + "log" + + "github.com/Snowflake-Labs/terraform-provider-snowflake/pkg/sdk" + "github.com/hashicorp/go-uuid" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +var unsafeExecuteSchema = map[string]*schema.Schema{ + "execute": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "SQL statement to execute.", + }, + "revert": { + Type: schema.TypeString, + Required: true, + Description: "SQL statement to revert the execute statement. Invoked when resource is deleted.", + }, +} + +func UnsafeExecute() *schema.Resource { + return &schema.Resource{ + Create: ExecuteUnsafeSQLStatement, + Read: schema.Noop, + Delete: RevertUnsafeSQLStatement, + Update: schema.Noop, + + Schema: unsafeExecuteSchema, + + DeprecationMessage: "Experimental resource. Will be deleted in the upcoming versions. Use at your own risk.", + Description: "Experimental resource used for testing purposes only. Allows to execute ANY SQL statement.", + } +} + +func ExecuteUnsafeSQLStatement(d *schema.ResourceData, meta interface{}) error { + db := meta.(*sql.DB) + ctx := context.Background() + client := sdk.NewClientFromDB(db) + + id, err := uuid.GenerateUUID() + if err != nil { + return err + } + + executeStatement := d.Get("execute").(string) + _, err = client.ExecUnsafe(ctx, executeStatement) + if err != nil { + return err + } + + d.SetId(id) + log.Printf(`[DEBUG] SQL "%s" applied successfully\n`, executeStatement) + + return nil +} + +func RevertUnsafeSQLStatement(d *schema.ResourceData, meta interface{}) error { + db := meta.(*sql.DB) + ctx := context.Background() + client := sdk.NewClientFromDB(db) + + revertStatement := d.Get("revert").(string) + _, err := client.ExecUnsafe(ctx, revertStatement) + if err != nil { + return err + } + + d.SetId("") + log.Printf(`[DEBUG] SQL "%s" applied successfully\n`, revertStatement) + + return nil +} diff --git a/pkg/resources/unsafe_execute_acceptance_test.go b/pkg/resources/unsafe_execute_acceptance_test.go new file mode 100644 index 0000000000..9c50775094 --- /dev/null +++ b/pkg/resources/unsafe_execute_acceptance_test.go @@ -0,0 +1,310 @@ +package resources_test + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "math/big" + "strings" + "testing" + + "github.com/Snowflake-Labs/terraform-provider-snowflake/pkg/sdk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAcc_UnsafeExecute_basic(t *testing.T) { + id := generateUnsafeExecuteTestDatabaseName(t) + idLowerCase := strings.ToLower(generateUnsafeExecuteTestDatabaseName(t)) + createDatabaseStatement := func(raw bool, id string) string { + if raw { + return fmt.Sprintf(`create database \"%s\"`, id) + } + return fmt.Sprintf("create database \"%s\"", id) + } + dropDatabaseStatement := func(raw bool, id string) string { + if raw { + return fmt.Sprintf(`drop database \"%s\"`, id) + } + return fmt.Sprintf("drop database \"%s\"", id) + } + resourceName := "snowflake_unsafe_execute.test" + + resource.Test(t, resource.TestCase{ + Providers: providers(), + CheckDestroy: testAccCheckDatabaseExistence(t, id, false), + Steps: []resource.TestStep{ + { + Config: schemaExecResourceConfig(createDatabaseStatement(true, id), dropDatabaseStatement(true, id)), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "execute", createDatabaseStatement(false, id)), + resource.TestCheckResourceAttr(resourceName, "revert", dropDatabaseStatement(false, id)), + resource.TestCheckResourceAttrSet(resourceName, "id"), + testAccCheckDatabaseExistence(t, id, true), + ), + }, + }, + }) + + resource.Test(t, resource.TestCase{ + Providers: providers(), + CheckDestroy: testAccCheckDatabaseExistence(t, idLowerCase, false), + Steps: []resource.TestStep{ + { + Config: schemaExecResourceConfig(createDatabaseStatement(true, idLowerCase), dropDatabaseStatement(true, idLowerCase)), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "execute", createDatabaseStatement(false, idLowerCase)), + resource.TestCheckResourceAttr(resourceName, "revert", dropDatabaseStatement(false, idLowerCase)), + resource.TestCheckResourceAttrSet(resourceName, "id"), + testAccCheckDatabaseExistence(t, idLowerCase, true), + ), + }, + }, + }) +} + +func TestAcc_UnsafeExecute_revertUpdated(t *testing.T) { + id := generateUnsafeExecuteTestDatabaseName(t) + execute := fmt.Sprintf("create database %s", id) + revert := fmt.Sprintf("drop database %s", id) + notMatchingRevert := "select 1" + var savedId string + resourceName := "snowflake_unsafe_execute.test" + + resource.Test(t, resource.TestCase{ + Providers: providers(), + CheckDestroy: testAccCheckDatabaseExistence(t, id, false), + Steps: []resource.TestStep{ + { + Config: schemaExecResourceConfig(execute, notMatchingRevert), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "execute", execute), + resource.TestCheckResourceAttr(resourceName, "revert", notMatchingRevert), + resource.TestCheckResourceAttrSet(resourceName, "id"), + resource.TestCheckResourceAttrWith(resourceName, "id", func(value string) error { + savedId = value + return nil + }), + testAccCheckDatabaseExistence(t, id, true), + ), + }, + { + Config: schemaExecResourceConfig(execute, revert), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "execute", execute), + resource.TestCheckResourceAttr(resourceName, "revert", revert), + resource.TestCheckResourceAttrSet(resourceName, "id"), + resource.TestCheckResourceAttrWith(resourceName, "id", func(value string) error { + if savedId != value { + return errors.New("different id after revert update") + } + return nil + }), + testAccCheckDatabaseExistence(t, id, true), + ), + }, + }, + }) +} + +func TestAcc_UnsafeExecute_executeUpdated(t *testing.T) { + id := generateUnsafeExecuteTestDatabaseName(t) + execute := fmt.Sprintf("create database %s", id) + revert := fmt.Sprintf("drop database %s", id) + + newId := fmt.Sprintf("%s_2", id) + newExecute := fmt.Sprintf("create database %s", newId) + newRevert := fmt.Sprintf("drop database %s", newId) + + var savedId string + + resourceName := "snowflake_unsafe_execute.test" + + resource.Test(t, resource.TestCase{ + Providers: providers(), + CheckDestroy: func(state *terraform.State) error { + err := testAccCheckDatabaseExistence(t, id, false)(state) + if err != nil { + return err + } + err = testAccCheckDatabaseExistence(t, newId, false)(state) + if err != nil { + return err + } + return nil + }, + Steps: []resource.TestStep{ + { + Config: schemaExecResourceConfig(execute, revert), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "execute", execute), + resource.TestCheckResourceAttr(resourceName, "revert", revert), + resource.TestCheckResourceAttrSet(resourceName, "id"), + resource.TestCheckResourceAttrWith(resourceName, "id", func(value string) error { + savedId = value + return nil + }), + testAccCheckDatabaseExistence(t, id, true), + ), + }, + { + Config: schemaExecResourceConfig(newExecute, newRevert), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "execute", newExecute), + resource.TestCheckResourceAttr(resourceName, "revert", newRevert), + resource.TestCheckResourceAttrSet(resourceName, "id"), + resource.TestCheckResourceAttrWith(resourceName, "id", func(value string) error { + if savedId == value { + return errors.New("same id after execute update") + } + return nil + }), + testAccCheckDatabaseExistence(t, id, false), + testAccCheckDatabaseExistence(t, newId, true), + ), + }, + }, + }) +} + +func TestAcc_UnsafeExecute_grants(t *testing.T) { + id := generateUnsafeExecuteTestDatabaseName(t) + roleId := generateUnsafeExecuteTestRoleName(t) + privilege := sdk.AccountObjectPrivilegeCreateSchema + execute := fmt.Sprintf("GRANT %s ON DATABASE %s TO ROLE %s", privilege, id, roleId) + revert := fmt.Sprintf("REVOKE %s ON DATABASE %s FROM ROLE %s", privilege, id, roleId) + resourceName := "snowflake_unsafe_execute.test" + + resource.Test(t, resource.TestCase{ + Providers: providers(), + CheckDestroy: func(state *terraform.State) error { + err := verifyGrantExists(t, roleId, privilege, false)(state) + dropResourcesForUnsafeExecuteTestCaseForGrants(t, id, roleId) + return err + }, + Steps: []resource.TestStep{ + { + PreConfig: func() { createResourcesForExecuteUnsafeTestCaseForGrants(t, id, roleId) }, + Config: schemaExecResourceConfig(execute, revert), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "execute", execute), + resource.TestCheckResourceAttr(resourceName, "revert", revert), + resource.TestCheckResourceAttrSet(resourceName, "id"), + verifyGrantExists(t, roleId, privilege, true), + ), + }, + }, + }) +} + +func schemaExecResourceConfig(exec string, revert string) string { + return fmt.Sprintf(` +resource "snowflake_unsafe_execute" "test" { + execute = "%s" + revert = "%s" +} +`, exec, revert) +} + +// generateUnsafeExecuteTestDatabaseName returns capitalized name on purpose. +// Using small caps without escaping creates problem with later using sdk client which uses identifier that is escaped by default. +func generateUnsafeExecuteTestDatabaseName(t *testing.T) string { + t.Helper() + id, err := rand.Int(rand.Reader, big.NewInt(10000)) + if err != nil { + t.Fatalf("Failed to generate database id: %v", err) + } + return fmt.Sprintf("UNSAFE_EXECUTE_TEST_DATABASE_%d", id) +} + +// generateUnsafeExecuteTestRoleName returns capitalized name on purpose. +// Using small caps without escaping creates problem with later using sdk client which uses identifier that is escaped by default. +func generateUnsafeExecuteTestRoleName(t *testing.T) string { + t.Helper() + id, err := rand.Int(rand.Reader, big.NewInt(10000)) + if err != nil { + t.Fatalf("Failed to generate role id: %v", err) + } + return fmt.Sprintf("UNSAFE_EXECUTE_TEST_ROLE_%d", id) +} + +func testAccCheckDatabaseExistence(t *testing.T, id string, shouldExist bool) func(state *terraform.State) error { + t.Helper() + return func(state *terraform.State) error { + client, err := sdk.NewDefaultClient() + require.NoError(t, err) + ctx := context.Background() + + _, err = client.Databases.ShowByID(ctx, sdk.NewAccountObjectIdentifier(id)) + if shouldExist { + if err != nil { + return fmt.Errorf("error while retrieving database %s, err = %w", id, err) + } + } else { + if err == nil { + return fmt.Errorf("database %v still exists", id) + } + } + return nil + } +} + +func createResourcesForExecuteUnsafeTestCaseForGrants(t *testing.T, dbId string, roleId string) { + t.Helper() + + client, err := sdk.NewDefaultClient() + require.NoError(t, err) + ctx := context.Background() + + err = client.Databases.Create(ctx, sdk.NewAccountObjectIdentifier(dbId), &sdk.CreateDatabaseOptions{}) + require.NoError(t, err) + + _, err = client.ExecUnsafe(ctx, fmt.Sprintf(`CREATE ROLE "%s"`, roleId)) + assert.NoError(t, err) +} + +func dropResourcesForUnsafeExecuteTestCaseForGrants(t *testing.T, dbId string, roleId string) { + t.Helper() + + client, err := sdk.NewDefaultClient() + require.NoError(t, err) + ctx := context.Background() + + err = client.Databases.Drop(ctx, sdk.NewAccountObjectIdentifier(dbId), &sdk.DropDatabaseOptions{}) + assert.NoError(t, err) + + _, err = client.ExecUnsafe(ctx, fmt.Sprintf(`DROP ROLE "%s"`, roleId)) + assert.NoError(t, err) +} + +func verifyGrantExists(t *testing.T, roleId string, privilege sdk.AccountObjectPrivilege, shouldExist bool) func(state *terraform.State) error { + t.Helper() + return func(state *terraform.State) error { + client, err := sdk.NewDefaultClient() + require.NoError(t, err) + ctx := context.Background() + + grants, err := client.Grants.Show(ctx, &sdk.ShowGrantOptions{ + To: &sdk.ShowGrantsTo{ + Role: sdk.NewAccountObjectIdentifier(roleId), + }, + }) + require.NoError(t, err) + + if shouldExist { + require.Equal(t, 1, len(grants)) + assert.Equal(t, privilege.String(), grants[0].Privilege) + assert.Equal(t, sdk.ObjectTypeDatabase, grants[0].GrantedOn) + assert.Equal(t, sdk.ObjectTypeRole, grants[0].GrantedTo) + assert.Equal(t, sdk.NewAccountObjectIdentifier(roleId).FullyQualifiedName(), grants[0].GranteeName.FullyQualifiedName()) + } else { + require.Equal(t, 0, len(grants)) + } + + // it does not matter what we return, because we have assertions above + return nil + } +} diff --git a/pkg/sdk/client.go b/pkg/sdk/client.go index 6c77db0cf6..333b9c8f76 100644 --- a/pkg/sdk/client.go +++ b/pkg/sdk/client.go @@ -162,6 +162,10 @@ const ( snowflakeAccountLocatorContextKey snowflakeAccountLocatorContext = "snowflake_account_locator" ) +func (c *Client) ExecUnsafe(ctx context.Context, sql string) (sql.Result, error) { + return c.exec(ctx, sql) +} + // Exec executes a query that does not return rows. func (c *Client) exec(ctx context.Context, sql string) (sql.Result, error) { ctx = context.WithValue(ctx, snowflakeAccountLocatorContextKey, c.accountLocator) diff --git a/templates/resources/unsafe_execute.md.tmpl b/templates/resources/unsafe_execute.md.tmpl new file mode 100644 index 0000000000..7c0ab2cacf --- /dev/null +++ b/templates/resources/unsafe_execute.md.tmpl @@ -0,0 +1,29 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +!> **Warning** This is a dangerous resource that allows executing **ANY** SQL statement. It may destroy resources if used incorrectly. It may behave incorrectly combined with other resources. Will be deleted in the upcoming versions. Use at your own risk. + +{{ .Description | trimspace }} + +{{ if .HasExample -}} +## Example Usage + +{{ tffile (printf "examples/resources/%s/resource.tf" .Name)}} +{{- end }} + +{{ .SchemaMarkdown | trimspace }} +{{- if .HasImport }} + +## Import + +Import is supported using the following syntax: + +{{ printf "{{codefile \"shell\" %q}}" .ImportFile }} +{{- end }}