// Copyright (c) The OpenTofu Authors // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2023 HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 package azure import ( "context" "errors" "fmt" "regexp" "time" "github.com/opentofu/opentofu/internal/backend" "github.com/opentofu/opentofu/internal/backend/remote-state/azure/auth" "github.com/opentofu/opentofu/internal/encryption" "github.com/opentofu/opentofu/internal/legacy/helper/schema" "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/container" ) const defaultTimeout = 300 // 5 minutes // New creates a new backend for Azure remote state. func New(enc encryption.StateEncryption) backend.Backend { s := &schema.Backend{ Schema: map[string]*schema.Schema{ "storage_account_name": { Type: schema.TypeString, Required: true, Description: "The name of the storage account.", }, "container_name": { Type: schema.TypeString, Required: true, Description: "The container name.", }, "key": { Type: schema.TypeString, Required: true, Description: "The blob key.", }, "metadata_host": { Type: schema.TypeString, Optional: true, Description: "The Metadata URL which will be used to obtain the Cloud Environment.", DefaultFunc: schema.EnvDefaultFunc("ARM_METADATA_HOST", nil), ConflictsWith: []string{"environment"}, }, "environment": { Type: schema.TypeString, Optional: true, Description: "The Azure cloud environment.", DefaultFunc: schema.EnvDefaultFunc("ARM_ENVIRONMENT", nil), ConflictsWith: []string{"metadata_host"}, }, "access_key": { Type: schema.TypeString, Optional: true, Description: "The access key.", DefaultFunc: schema.EnvDefaultFunc("ARM_ACCESS_KEY", ""), }, "sas_token": { Type: schema.TypeString, Optional: true, Description: "A SAS Token used to interact with the Blob Storage Account.", DefaultFunc: schema.EnvDefaultFunc("ARM_SAS_TOKEN", ""), }, "snapshot": { Type: schema.TypeBool, Optional: true, Description: "Enable/Disable automatic blob snapshotting", DefaultFunc: schema.EnvDefaultFunc("ARM_SNAPSHOT", false), }, "resource_group_name": { Type: schema.TypeString, Optional: true, Description: "The resource group name.", }, "client_id": { Type: schema.TypeString, Optional: true, Description: "The Client ID.", DefaultFunc: schema.EnvDefaultFunc("ARM_CLIENT_ID", ""), }, "client_id_file_path": { Type: schema.TypeString, Optional: true, Description: "The path to a file containing the Client ID.", DefaultFunc: schema.EnvDefaultFunc("ARM_CLIENT_ID_FILE_PATH", ""), }, "endpoint": { Type: schema.TypeString, Optional: true, Description: "A custom Endpoint used to access the Azure Resource Manager API's.", Deprecated: "This variable is unused and does not affect any execution. Please use environment or metadata host instead.", }, "timeout_seconds": { Type: schema.TypeInt, Optional: true, Description: "The timeout in seconds for initializing a client or retrieving a Blob or a Metadata from Azure.", DefaultFunc: schema.EnvDefaultFunc("ARM_TIMEOUT_SECONDS", defaultTimeout), ValidateFunc: func(v interface{}, _ string) ([]string, []error) { value, ok := v.(int) if !ok || value < 0 { return nil, []error{fmt.Errorf("timeout_seconds expected to be a non-negative integer")} } return nil, nil }, }, "subscription_id": { Type: schema.TypeString, Optional: true, Description: "The Subscription ID.", DefaultFunc: schema.EnvDefaultFunc("ARM_SUBSCRIPTION_ID", ""), }, "tenant_id": { Type: schema.TypeString, Optional: true, Description: "The Tenant ID.", DefaultFunc: schema.EnvDefaultFunc("ARM_TENANT_ID", ""), }, // Service Principal (Client Certificate) specific "client_certificate": { Type: schema.TypeString, Optional: true, Description: "A Base64-encoded PKCS#12 (PFX, not PEM) certificate used as the Client Certificate when authenticating as a Service Principal. The file must encode both the public certificate and its private key.", DefaultFunc: schema.EnvDefaultFunc("ARM_CLIENT_CERTIFICATE", ""), }, "client_certificate_password": { Type: schema.TypeString, Optional: true, Description: "The password associated with the Client Certificate specified in `client_certificate_path`", DefaultFunc: schema.EnvDefaultFunc("ARM_CLIENT_CERTIFICATE_PASSWORD", ""), }, "client_certificate_path": { Type: schema.TypeString, Optional: true, Description: "The path to the PKCS#12 PFX file used as the Client Certificate when authenticating as a Service Principal. The file must encode both the public certificate and its private key.", DefaultFunc: schema.EnvDefaultFunc("ARM_CLIENT_CERTIFICATE_PATH", ""), }, // Service Principal (Client Secret) specific "client_secret": { Type: schema.TypeString, Optional: true, Description: "The Client Secret.", DefaultFunc: schema.EnvDefaultFunc("ARM_CLIENT_SECRET", ""), }, "client_secret_file_path": { Type: schema.TypeString, Optional: true, Description: "The path to a file containing the Client Secret.", DefaultFunc: schema.EnvDefaultFunc("ARM_CLIENT_SECRET_FILE_PATH", ""), }, // Managed Service Identity specific "use_msi": { Type: schema.TypeBool, Optional: true, Description: "Should Managed Service Identity be used?", DefaultFunc: schema.EnvDefaultFunc("ARM_USE_MSI", false), }, "msi_endpoint": { Type: schema.TypeString, Optional: true, Description: "The Managed Service Identity Endpoint.", DefaultFunc: schema.EnvDefaultFunc("ARM_MSI_ENDPOINT", nil), Deprecated: "This configuration is now managed in a dependent library, not directly by OpenTofu. Please use the `MSI_ENDPOINT` environment variable to set the Managed Service Identity endpoint.", }, // OIDC auth specific fields "use_oidc": { Type: schema.TypeBool, Optional: true, DefaultFunc: schema.EnvDefaultFunc("ARM_USE_OIDC", false), Description: "Allow OIDC to be used for authentication", }, "oidc_token": { Type: schema.TypeString, Optional: true, DefaultFunc: schema.EnvDefaultFunc("ARM_OIDC_TOKEN", ""), Description: "A generic JWT token that can be used for OIDC authentication. Should not be used in conjunction with `oidc_request_token`.", }, "oidc_token_file_path": { Type: schema.TypeString, Optional: true, DefaultFunc: schema.EnvDefaultFunc("ARM_OIDC_TOKEN_FILE_PATH", ""), Description: "Path to file containing a generic JWT token that can be used for OIDC authentication. Should not be used in conjunction with `oidc_request_token`.", }, "oidc_request_url": { Type: schema.TypeString, Optional: true, DefaultFunc: schema.MultiEnvDefaultFunc([]string{"ARM_OIDC_REQUEST_URL", "ACTIONS_ID_TOKEN_REQUEST_URL"}, ""), Description: "The URL of the OIDC provider from which to request an ID token. Needs to be used in conjunction with `oidc_request_token`. This is meant to be used for Github Actions.", }, "oidc_request_token": { Type: schema.TypeString, Optional: true, DefaultFunc: schema.MultiEnvDefaultFunc([]string{"ARM_OIDC_REQUEST_TOKEN", "ACTIONS_ID_TOKEN_REQUEST_TOKEN"}, ""), Description: "The bearer token to use for the request to the OIDC providers `oidc_request_url` URL to fetch an ID token. Needs to be used in conjunction with `oidc_request_url`. This is meant to be used for Github Actions.", }, "use_aks_workload_identity": { Type: schema.TypeBool, Optional: true, DefaultFunc: schema.EnvDefaultFunc("ARM_USE_AKS_WORKLOAD_IDENTITY", false), Description: "Set to true to if you want to use Azure's AKS Workload Identity to authenticate to Azure. Defaults to false.", }, // Feature Flags "use_azuread_auth": { Type: schema.TypeBool, Optional: true, Description: "Should OpenTofu use AzureAD Authentication to access the Blob?", DefaultFunc: schema.EnvDefaultFunc("ARM_USE_AZUREAD", false), }, "use_cli": { Type: schema.TypeBool, Optional: true, Description: "Set to true if you want to use the Azure CLI to authenticate to Azure. Defaults to true.", DefaultFunc: schema.EnvDefaultFunc("ARM_USE_CLI", true), }, }, } result := &Backend{Backend: s, encryption: enc} result.Backend.ConfigureFunc = result.configure return result } type Backend struct { *schema.Backend encryption encryption.StateEncryption // The fields below are set from configure containerClient *container.Client // containerName is set here so that, in a unit test, we can // check that the container name is propagated correctly // from the configuration containerName string keyName string snapshot bool timeout time.Duration } func (b *Backend) configure(ctx context.Context) error { if b.containerName != "" { return nil } // Grab the resource data data := schema.FromContextBackendConfig(ctx) b.containerName = data.Get("container_name").(string) b.keyName = data.Get("key").(string) b.snapshot = data.Get("snapshot").(bool) b.timeout = time.Duration(data.Get("timeout_seconds").(int)) * time.Second accessKey := data.Get("access_key").(string) sasToken := data.Get("sas_token").(string) useAzureADAuthentication := data.Get("use_azuread_auth").(bool) environment := data.Get("environment").(string) metadataHost := data.Get("metadata_host").(string) cloudConfig, storageSuffix, err := auth.CloudConfigFromAddresses( ctx, environment, metadataHost, ) if err != nil { return err } config := &auth.Config{ AzureCLIAuthConfig: auth.AzureCLIAuthConfig{ CLIAuthEnabled: data.Get("use_cli").(bool), }, ClientSecretCredentialAuthConfig: auth.ClientSecretCredentialAuthConfig{ ClientID: data.Get("client_id").(string), ClientIDFilePath: data.Get("client_id_file_path").(string), ClientSecret: data.Get("client_secret").(string), ClientSecretFilePath: data.Get("client_secret_file_path").(string), }, ClientCertificateAuthConfig: auth.ClientCertificateAuthConfig{ ClientCertificate: data.Get("client_certificate").(string), ClientCertificatePassword: data.Get("client_certificate_password").(string), ClientCertificatePath: data.Get("client_certificate_path").(string), }, OIDCAuthConfig: auth.OIDCAuthConfig{ UseOIDC: data.Get("use_oidc").(bool), OIDCToken: data.Get("oidc_token").(string), OIDCTokenFilePath: data.Get("oidc_token_file_path").(string), OIDCRequestURL: data.Get("oidc_request_url").(string), OIDCRequestToken: data.Get("oidc_request_token").(string)}, MSIAuthConfig: auth.MSIAuthConfig{ UseMsi: data.Get("use_msi").(bool), Endpoint: data.Get("msi_endpoint").(string), }, StorageAddresses: auth.StorageAddresses{ CloudConfig: cloudConfig, ResourceGroup: data.Get("resource_group_name").(string), StorageAccount: data.Get("storage_account_name").(string), StorageContainer: b.containerName, StorageSuffix: storageSuffix, SubscriptionID: data.Get("subscription_id").(string), TenantID: data.Get("tenant_id").(string), }, WorkloadIdentityAuthConfig: auth.WorkloadIdentityAuthConfig{ UseAKSWorkloadIdentity: data.Get("use_aks_workload_identity").(bool), }, } // MUST check storage account name and container name before trying to create a client. // We are going to be constructing URLs from these names, they should be restricted before we call those functions err = checkAccountAndContainerNames(config.StorageAccount, config.StorageContainer) if err != nil { return err } // Check for nonempty Storage Account Shared Access Key if accessKey != "" { containerClient, err := auth.NewContainerClientFromStorageAccessKey(ctx, config.StorageAddresses, accessKey) if err != nil { return err } b.containerClient = containerClient return nil } // Shared Access Key is now known to be empty // Check for nonempty SAS Token if sasToken != "" { containerClient, err := auth.NewContainerClientFromSAS(ctx, config.StorageAddresses, sasToken) if err != nil { return err } b.containerClient = containerClient return nil } // Shared Access Key and SAS Token are both empty // Get auth credentials authMethod, err := auth.GetAuthMethod(ctx, config) if err != nil { return err } // If we use Azure AD (Entra ID) Auth, we're done! // Just set up the client with these auth credentials if useAzureADAuthentication { authCred, err := authMethod.Construct(ctx, config) if err != nil { return err } bootstrapContainerClient, err := auth.NewContainerClient(ctx, config.StorageAddresses, authCred) if err != nil { return fmt.Errorf("error getting container client: %w", err) } b.containerClient = bootstrapContainerClient return nil } // We are not using Azure AD Auth // We're going to use these credentials to bootstrap obtaining the Shared Access Key credentials authCred, err := authMethod.Construct(ctx, config) if err != nil { return err } // We also call on the auth method to augment the configuration, to ensure resource group and subscription ID are present err = authMethod.AugmentConfig(ctx, config) if err != nil { return err } containerClient, err := auth.NewContainerClientWithSharedKeyCredential(ctx, config.StorageAddresses, authCred) if err != nil { return fmt.Errorf("error getting container client: %w", err) } b.containerClient = containerClient return nil } func checkAccountAndContainerNames(storageAccount, storageContainer string) error { accountPattern := regexp.MustCompile(`^[0-9a-z]{3,24}$`) containerPattern := regexp.MustCompile(`^[0-9a-z][0-9a-z\-]{1,61}[0-9a-z]$`) hyphenPattern := regexp.MustCompile(`\-\-`) if !accountPattern.Match([]byte(storageAccount)) { return errors.New("invalid storage account name: Azure requires a storage account name consists of 3-24 lowercase characters and numbers only. See documentation here: https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/resource-name-rules#microsoftstorage") } if !containerPattern.Match([]byte(storageContainer)) { return errors.New("invalid storage container name: Azure requires a storage container name consists of 3-63 lowercase characters, numbers, and hyphens only. It cannot start or end with a hyphen. See documentation here: https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/resource-name-rules#microsoftstorage") } if hyphenPattern.Match([]byte(storageContainer)) { return errors.New("invalid storage container name: Hyphens in a storage container name must be nonconsecutive. See documentation here: https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/resource-name-rules#microsoftstorage") } return nil }