diff --git a/azurerm/internal/services/kusto/kusto_cluster_customer_managed_key_resource.go b/azurerm/internal/services/kusto/kusto_cluster_customer_managed_key_resource.go new file mode 100644 index 000000000000..20c83dfe2dbb --- /dev/null +++ b/azurerm/internal/services/kusto/kusto_cluster_customer_managed_key_resource.go @@ -0,0 +1,266 @@ +package kusto + +import ( + "fmt" + "log" + "time" + + "github.com/Azure/azure-sdk-for-go/services/kusto/mgmt/2020-02-15/kusto" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/helper/validation" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/helpers/azure" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/helpers/tf" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/clients" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/locks" + keyVaultParse "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/keyvault/parse" + keyVaultValidate "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/keyvault/validate" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/kusto/parse" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/timeouts" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/utils" +) + +func resourceArmKustoClusterCustomerManagedKey() *schema.Resource { + return &schema.Resource{ + Create: resourceArmKustoClusterCustomerManagedKeyCreateUpdate, + Read: resourceArmKustoClusterCustomerManagedKeyRead, + Update: resourceArmKustoClusterCustomerManagedKeyCreateUpdate, + Delete: resourceArmKustoClusterCustomerManagedKeyDelete, + + // TODO: this needs a custom ID validating importer + Importer: &schema.ResourceImporter{ + State: schema.ImportStatePassthrough, + }, + + Timeouts: &schema.ResourceTimeout{ + Create: schema.DefaultTimeout(30 * time.Minute), + Read: schema.DefaultTimeout(5 * time.Minute), + Update: schema.DefaultTimeout(30 * time.Minute), + Delete: schema.DefaultTimeout(30 * time.Minute), + }, + + Schema: map[string]*schema.Schema{ + "cluster_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: azure.ValidateResourceID, + }, + + "key_vault_id": { + Type: schema.TypeString, + Required: true, + ValidateFunc: keyVaultValidate.KeyVaultID, + }, + + "key_name": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringIsNotEmpty, + }, + + "key_version": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringIsNotEmpty, + }, + }, + } +} + +func resourceArmKustoClusterCustomerManagedKeyCreateUpdate(d *schema.ResourceData, meta interface{}) error { + clusterClient := meta.(*clients.Client).Kusto.ClustersClient + vaultsClient := meta.(*clients.Client).KeyVault.VaultsClient + ctx, cancel := timeouts.ForCreateUpdate(meta.(*clients.Client).StopContext, d) + defer cancel() + + clusterIDRaw := d.Get("cluster_id").(string) + clusterID, err := parse.KustoClusterID(clusterIDRaw) + if err != nil { + return err + } + + locks.ByName(clusterID.Name, "azurerm_kusto_cluster") + defer locks.UnlockByName(clusterID.Name, "azurerm_kusto_cluster") + + cluster, err := clusterClient.Get(ctx, clusterID.ResourceGroup, clusterID.Name) + if err != nil { + return fmt.Errorf("Error retrieving Kusto Cluster %q (Resource Group %q): %+v", clusterID.Name, clusterID.ResourceGroup, err) + } + if cluster.ClusterProperties == nil { + return fmt.Errorf("Error retrieving Kusto Cluster %q (Resource Group %q): `ClusterProperties` was nil", clusterID.Name, clusterID.ResourceGroup) + } + + // since we're mutating the kusto cluster here, we can use that as the ID + resourceID := clusterIDRaw + + if d.IsNewResource() { + // whilst this looks superflurious given encryption is enabled by default, due to the way + // the Azure API works this technically can be nil + if cluster.ClusterProperties.KeyVaultProperties != nil { + return tf.ImportAsExistsError("azurerm_kusto_cluster_customer_managed_key", resourceID) + } + } + + keyVaultIDRaw := d.Get("key_vault_id").(string) + keyVaultID, err := keyVaultParse.KeyVaultID(keyVaultIDRaw) + if err != nil { + return err + } + + keyVault, err := vaultsClient.Get(ctx, keyVaultID.ResourceGroup, keyVaultID.Name) + if err != nil { + return fmt.Errorf("Error retrieving Key Vault %q (Resource Group %q): %+v", keyVaultID.Name, keyVaultID.ResourceGroup, err) + } + + softDeleteEnabled := false + purgeProtectionEnabled := false + if props := keyVault.Properties; props != nil { + if esd := props.EnableSoftDelete; esd != nil { + softDeleteEnabled = *esd + } + if epp := props.EnablePurgeProtection; epp != nil { + purgeProtectionEnabled = *epp + } + } + if !softDeleteEnabled || !purgeProtectionEnabled { + return fmt.Errorf("Key Vault %q (Resource Group %q) must be configured for both Purge Protection and Soft Delete", keyVaultID.Name, keyVaultID.ResourceGroup) + } + + keyVaultBaseURL, err := azure.GetKeyVaultBaseUrlFromID(ctx, vaultsClient, keyVaultIDRaw) + if err != nil { + return fmt.Errorf("Error looking up Key Vault URI from Key Vault %q (Resource Group %q): %+v", keyVaultID.Name, keyVaultID.ResourceGroup, err) + } + + keyName := d.Get("key_name").(string) + keyVersion := d.Get("key_version").(string) + props := kusto.ClusterUpdate{ + ClusterProperties: &kusto.ClusterProperties{ + KeyVaultProperties: &kusto.KeyVaultProperties{ + KeyName: utils.String(keyName), + KeyVersion: utils.String(keyVersion), + KeyVaultURI: utils.String(keyVaultBaseURL), + }, + }, + } + + future, err := clusterClient.Update(ctx, clusterID.ResourceGroup, clusterID.Name, props) + if err != nil { + return fmt.Errorf("Error updating Customer Managed Key for Kusto Cluster %q (Resource Group %q): %+v", clusterID.Name, clusterID.ResourceGroup, err) + } + if err = future.WaitForCompletionRef(ctx, clusterClient.Client); err != nil { + return fmt.Errorf("Error waiting for completion of Kusto Cluster Update %q (Resource Group %q): %+v", clusterID.Name, clusterID.ResourceGroup, err) + } + + d.SetId(resourceID) + + return resourceArmKustoClusterCustomerManagedKeyRead(d, meta) +} + +func resourceArmKustoClusterCustomerManagedKeyRead(d *schema.ResourceData, meta interface{}) error { + clusterClient := meta.(*clients.Client).Kusto.ClustersClient + vaultsClient := meta.(*clients.Client).KeyVault.VaultsClient + ctx, cancel := timeouts.ForRead(meta.(*clients.Client).StopContext, d) + defer cancel() + + clusterID, err := parse.KustoClusterID(d.Id()) + if err != nil { + return err + } + + cluster, err := clusterClient.Get(ctx, clusterID.ResourceGroup, clusterID.Name) + if err != nil { + if utils.ResponseWasNotFound(cluster.Response) { + log.Printf("[DEBUG] Kusto Cluster %q could not be found in Resource Group %q - removing from state!", clusterID.Name, clusterID.ResourceGroup) + d.SetId("") + return nil + } + + return fmt.Errorf("Error retrieving Kusto Cluster %q (Resource Group %q): %+v", clusterID.Name, clusterID.ResourceGroup, err) + } + if cluster.ClusterProperties == nil { + return fmt.Errorf("Error retrieving Kusto Cluster %q (Resource Group %q): `ClusterProperties` was nil", clusterID.Name, clusterID.ResourceGroup) + } + if cluster.ClusterProperties.KeyVaultProperties == nil { + log.Printf("[DEBUG] Customer Managed Key was not defined for Kusto Cluster %q (Resource Group %q) - removing from state!", clusterID.Name, clusterID.ResourceGroup) + d.SetId("") + return nil + } + + props := cluster.ClusterProperties.KeyVaultProperties + + keyName := "" + keyVaultURI := "" + keyVersion := "" + if props != nil { + if props.KeyName != nil { + keyName = *props.KeyName + } + if props.KeyVaultURI != nil { + keyVaultURI = *props.KeyVaultURI + } + if props.KeyVersion != nil { + keyVersion = *props.KeyVersion + } + } + + if keyVaultURI == "" { + return fmt.Errorf("Error retrieving Kusto Cluster %q (Resource Group %q): `properties.keyVaultProperties.keyVaultUri` was nil", clusterID.Name, clusterID.ResourceGroup) + } + + // now we have the key vault uri we can look up the ID + keyVaultID, err := azure.GetKeyVaultIDFromBaseUrl(ctx, vaultsClient, keyVaultURI) + if err != nil { + return fmt.Errorf("Error retrieving Key Vault ID from the Base URI %q: %+v", keyVaultURI, err) + } + + d.Set("cluster_id", d.Id()) + d.Set("key_vault_id", keyVaultID) + d.Set("key_name", keyName) + d.Set("key_version", keyVersion) + + return nil +} + +func resourceArmKustoClusterCustomerManagedKeyDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*clients.Client).Kusto.ClustersClient + ctx, cancel := timeouts.ForDelete(meta.(*clients.Client).StopContext, d) + defer cancel() + + clusterID, err := parse.KustoClusterID(d.Id()) + if err != nil { + return err + } + + locks.ByName(clusterID.Name, "azurerm_kusto_cluster") + defer locks.UnlockByName(clusterID.Name, "azurerm_kusto_cluster") + + // confirm it still exists prior to trying to update it, else we'll get an error + cluster, err := client.Get(ctx, clusterID.ResourceGroup, clusterID.Name) + if err != nil { + if utils.ResponseWasNotFound(cluster.Response) { + return nil + } + + return fmt.Errorf("Error retrieving Kusto Cluster %q (Resource Group %q): %+v", clusterID.Name, clusterID.ResourceGroup, err) + } + + // Since this isn't a real object, just modifying an existing object + // "Delete" doesn't really make sense it should really be a "Revert to Default" + // So instead of the Delete func actually deleting the Kusto Cluster I am + // making it reset the Kusto Cluster to it's default state + props := kusto.ClusterUpdate{ + ClusterProperties: &kusto.ClusterProperties{ + KeyVaultProperties: &kusto.KeyVaultProperties{}, + }, + } + + future, err := client.Update(ctx, clusterID.ResourceGroup, clusterID.Name, props) + if err != nil { + return fmt.Errorf("Error removing Customer Managed Key for Kusto Cluster %q (Resource Group %q): %+v", clusterID.Name, clusterID.ResourceGroup, err) + } + if err = future.WaitForCompletionRef(ctx, client.Client); err != nil { + return fmt.Errorf("Error waiting for completion of Kusto Cluster Update %q (Resource Group %q): %+v", clusterID.Name, clusterID.ResourceGroup, err) + } + + return nil +} diff --git a/azurerm/internal/services/kusto/registration.go b/azurerm/internal/services/kusto/registration.go index bc2170362e94..ff958b3c6a25 100644 --- a/azurerm/internal/services/kusto/registration.go +++ b/azurerm/internal/services/kusto/registration.go @@ -29,6 +29,7 @@ func (r Registration) SupportedDataSources() map[string]*schema.Resource { func (r Registration) SupportedResources() map[string]*schema.Resource { return map[string]*schema.Resource{ "azurerm_kusto_cluster": resourceArmKustoCluster(), + "azurerm_kusto_cluster_customer_managed_key": resourceArmKustoClusterCustomerManagedKey(), "azurerm_kusto_cluster_principal_assignment": resourceArmKustoClusterPrincipalAssignment(), "azurerm_kusto_database": resourceArmKustoDatabase(), "azurerm_kusto_database_principal": resourceArmKustoDatabasePrincipal(), diff --git a/azurerm/internal/services/kusto/tests/kusto_cluster_customer_managed_key_test.go b/azurerm/internal/services/kusto/tests/kusto_cluster_customer_managed_key_test.go new file mode 100644 index 000000000000..e78cd21a8228 --- /dev/null +++ b/azurerm/internal/services/kusto/tests/kusto_cluster_customer_managed_key_test.go @@ -0,0 +1,294 @@ +package tests + +import ( + "fmt" + "testing" + + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/acceptance" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/clients" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/kusto/parse" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/utils" + + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/terraform" +) + +func TestAccAzureRMKustoClusterCustomerManagedKey_basic(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_kusto_cluster_customer_managed_key", "test") + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + Providers: acceptance.SupportedProviders, + CheckDestroy: testCheckAzureRMKustoClusterDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAzureRMKustoClusterCustomerManagedKey_basic(data), + Check: resource.ComposeTestCheckFunc( + testCheckAzureRMKustoClusterWithCustomerManagedKeyExists(data.ResourceName), + resource.TestCheckResourceAttrSet(data.ResourceName, "key_vault_id"), + resource.TestCheckResourceAttrSet(data.ResourceName, "key_name"), + resource.TestCheckResourceAttrSet(data.ResourceName, "key_version"), + ), + }, + data.ImportStep(), + { + // Delete the encryption settings resource and verify it is gone + Config: testAccAzureRMKustoClusterCustomerManagedKey_template(data), + Check: resource.ComposeTestCheckFunc( + // Then ensure the encryption settings on the Kusto cluster + // have been reverted to their default state + testCheckAzureRMKustoClusterExistsWithoutCustomerManagedKey("azurerm_kusto_cluster.test"), + ), + }, + }, + }) +} + +func TestAccAzureRMKustoClusterCustomerManagedKey_requiresImport(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_kusto_cluster_customer_managed_key", "test") + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + Providers: acceptance.SupportedProviders, + CheckDestroy: testCheckAzureRMKustoClusterDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAzureRMKustoClusterCustomerManagedKey_basic(data), + Check: resource.ComposeTestCheckFunc( + testCheckAzureRMKustoClusterWithCustomerManagedKeyExists(data.ResourceName), + resource.TestCheckResourceAttrSet(data.ResourceName, "key_vault_id"), + resource.TestCheckResourceAttrSet(data.ResourceName, "key_name"), + resource.TestCheckResourceAttrSet(data.ResourceName, "key_version"), + ), + }, + data.RequiresImportErrorStep(testAccAzureRMKustoClusterCustomerManagedKey_requiresImport), + }, + }) +} + +func TestAccAzureRMKustoClusterCustomerManagedKey_updateKey(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_kusto_cluster_customer_managed_key", "test") + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + Providers: acceptance.SupportedProviders, + CheckDestroy: testCheckAzureRMKustoClusterDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAzureRMKustoClusterCustomerManagedKey_basic(data), + Check: resource.ComposeTestCheckFunc( + testCheckAzureRMKustoClusterWithCustomerManagedKeyExists(data.ResourceName), + resource.TestCheckResourceAttrSet(data.ResourceName, "key_vault_id"), + resource.TestCheckResourceAttrSet(data.ResourceName, "key_name"), + resource.TestCheckResourceAttrSet(data.ResourceName, "key_version"), + ), + }, + data.ImportStep(), + { + Config: testAccAzureRMKustoClusterCustomerManagedKey_updated(data), + Check: resource.ComposeTestCheckFunc( + testCheckAzureRMKustoClusterWithCustomerManagedKeyExists(data.ResourceName), + ), + }, + data.ImportStep(), + }, + }) +} + +func testCheckAzureRMKustoClusterWithCustomerManagedKeyExists(resourceName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + client := acceptance.AzureProvider.Meta().(*clients.Client).Kusto.ClustersClient + ctx := acceptance.AzureProvider.Meta().(*clients.Client).StopContext + + // Ensure we have enough information in state to look up in API + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("Not found: %s", resourceName) + } + + id, err := parse.KustoClusterID(rs.Primary.ID) + if err != nil { + return err + } + + resp, err := client.Get(ctx, id.ResourceGroup, id.Name) + if err != nil { + if utils.ResponseWasNotFound(resp.Response) { + return fmt.Errorf("Bad: Kusto Cluster %q (resource group: %q) does not exist", id.Name, id.ResourceGroup) + } + + return fmt.Errorf("Bad: Get on kustoClustersClient: %+v", err) + } + + if props := resp.ClusterProperties; props != nil { + if encryption := props.KeyVaultProperties; encryption == nil { + return fmt.Errorf("Kusto Cluster encryption properties not found: %s", resourceName) + } + } + + return nil + } +} + +func testCheckAzureRMKustoClusterExistsWithoutCustomerManagedKey(resourceName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + client := acceptance.AzureProvider.Meta().(*clients.Client).Kusto.ClustersClient + ctx := acceptance.AzureProvider.Meta().(*clients.Client).StopContext + + // Ensure we have enough information in state to look up in API + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("Not found: %s", resourceName) + } + + id, err := parse.KustoClusterID(rs.Primary.ID) + if err != nil { + return err + } + + resp, err := client.Get(ctx, id.ResourceGroup, id.Name) + if err != nil { + if utils.ResponseWasNotFound(resp.Response) { + return fmt.Errorf("Bad: Kusto Cluster %q (resource group: %q) does not exist", id.Name, id.ResourceGroup) + } + + return fmt.Errorf("Bad: Get on kustoClustersClient: %+v", err) + } + + if props := resp.ClusterProperties; props != nil { + if encryption := props.KeyVaultProperties; encryption != nil { + return fmt.Errorf("Kusto Cluster encryption properties still found: %s", resourceName) + } + } + + return nil + } +} + +func testAccAzureRMKustoClusterCustomerManagedKey_basic(data acceptance.TestData) string { + template := testAccAzureRMKustoClusterCustomerManagedKey_template(data) + return fmt.Sprintf(` +%s + +resource "azurerm_kusto_cluster_customer_managed_key" "test" { + cluster_id = azurerm_kusto_cluster.test.id + key_vault_id = azurerm_key_vault.test.id + key_name = azurerm_key_vault_key.first.name + key_version = azurerm_key_vault_key.first.version +} +`, template) +} + +func testAccAzureRMKustoClusterCustomerManagedKey_requiresImport(data acceptance.TestData) string { + template := testAccAzureRMKustoClusterCustomerManagedKey_basic(data) + return fmt.Sprintf(` +%s + +resource "azurerm_kusto_cluster_customer_managed_key" "import" { + cluster_id = azurerm_kusto_cluster_customer_managed_key.test.cluster_id + key_vault_id = azurerm_kusto_cluster_customer_managed_key.test.key_vault_id + key_name = azurerm_kusto_cluster_customer_managed_key.test.key_name + key_version = azurerm_kusto_cluster_customer_managed_key.test.key_version +} +`, template) +} + +func testAccAzureRMKustoClusterCustomerManagedKey_updated(data acceptance.TestData) string { + template := testAccAzureRMKustoClusterCustomerManagedKey_template(data) + return fmt.Sprintf(` +%s + +resource "azurerm_key_vault_key" "second" { + name = "second" + key_vault_id = azurerm_key_vault.test.id + key_type = "RSA" + key_size = 2048 + key_opts = ["decrypt", "encrypt", "sign", "unwrapKey", "verify", "wrapKey"] + + depends_on = [ + azurerm_key_vault_access_policy.client, + azurerm_key_vault_access_policy.cluster, + ] +} + +resource "azurerm_kusto_cluster_customer_managed_key" "test" { + cluster_id = azurerm_kusto_cluster.test.id + key_vault_id = azurerm_key_vault.test.id + key_name = azurerm_key_vault_key.second.name + key_version = azurerm_key_vault_key.second.version +} +`, template) +} + +func testAccAzureRMKustoClusterCustomerManagedKey_template(data acceptance.TestData) string { + return fmt.Sprintf(` +provider "azurerm" { + features { + key_vault { + purge_soft_delete_on_destroy = false + } + } +} + +data "azurerm_client_config" "current" {} + +resource "azurerm_resource_group" "test" { + name = "acctestRG-%d" + location = "%s" +} + +resource "azurerm_key_vault" "test" { + name = "acctestkv%s" + location = azurerm_resource_group.test.location + resource_group_name = azurerm_resource_group.test.name + tenant_id = data.azurerm_client_config.current.tenant_id + sku_name = "standard" + soft_delete_enabled = true + purge_protection_enabled = true +} + +resource "azurerm_key_vault_access_policy" "cluster" { + key_vault_id = azurerm_key_vault.test.id + tenant_id = azurerm_kusto_cluster.test.identity.0.tenant_id + object_id = azurerm_kusto_cluster.test.identity.0.principal_id + + key_permissions = ["get", "unwrapkey", "wrapkey"] +} + +resource "azurerm_key_vault_access_policy" "client" { + key_vault_id = azurerm_key_vault.test.id + tenant_id = data.azurerm_client_config.current.tenant_id + object_id = data.azurerm_client_config.current.object_id + + key_permissions = ["get", "list", "create", "delete", "recover"] +} + +resource "azurerm_key_vault_key" "first" { + name = "test" + key_vault_id = azurerm_key_vault.test.id + key_type = "RSA" + key_size = 2048 + key_opts = ["decrypt", "encrypt", "sign", "unwrapKey", "verify", "wrapKey"] + + depends_on = [ + azurerm_key_vault_access_policy.client, + azurerm_key_vault_access_policy.cluster, + ] +} + +resource "azurerm_kusto_cluster" "test" { + name = "acctestkc%s" + location = azurerm_resource_group.test.location + resource_group_name = azurerm_resource_group.test.name + + sku { + name = "Dev(No SLA)_Standard_D11_v2" + capacity = 1 + } + + identity { + type = "SystemAssigned" + } +} +`, data.RandomInteger, data.Locations.Primary, data.RandomString, data.RandomString) +} diff --git a/website/azurerm.erb b/website/azurerm.erb index dd0241488334..199b0768ef14 100644 --- a/website/azurerm.erb +++ b/website/azurerm.erb @@ -1421,6 +1421,9 @@