Files
opentf/internal/backend/remote-state/azure/backend.go
2025-09-15 19:22:17 +01:00

427 lines
15 KiB
Go

// 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
}