Files
opentf/internal/backend/remote-state/azure/auth/clients.go
Larry Bordowitz bcbfebce3d Implement the Azure Key Provider
This uses the same auth package as the newly-rewritten Azure State
Backend, so many of the properties and environment variables are the
same. I have put this through both the compliance test as well as built
the binary and run some end-to-end tests, and found that it
appropriately uses the Azure key as expected.

Signed-off-by: Larry Bordowitz <laurence.bordowitz@gmail.com>
2025-09-29 06:19:02 -04:00

250 lines
9.8 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 auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/container"
"github.com/opentofu/opentofu/internal/httpclient"
"github.com/opentofu/opentofu/internal/tfdiags"
)
func clientOptions(client *http.Client, cloudConfig cloud.Configuration) policy.ClientOptions {
return policy.ClientOptions{
Telemetry: policy.TelemetryOptions{
Disabled: true,
},
Transport: client,
Cloud: cloudConfig,
}
}
// NewResourceClient gets a client for resource groups. This is strictly only used in testing.
func NewResourceClient(client *http.Client, authCred azcore.TokenCredential, subscriptionID string) (*armresources.ResourceGroupsClient, error) {
resourceClient, err := armresources.NewResourceGroupsClient(subscriptionID, authCred, &arm.ClientOptions{
ClientOptions: clientOptions(client, cloud.AzurePublic),
DisableRPRegistration: false,
})
if err != nil {
return nil, fmt.Errorf("error getting resource client: %w", err)
}
return resourceClient, nil
}
// NewStorageAccountsClient gets a client for the storage account with the given auth credentials.
// This should only be used for testing and internally within this package.
func NewStorageAccountsClient(client *http.Client, authCred azcore.TokenCredential, cloudConfig cloud.Configuration, subscriptionID string) (*armstorage.AccountsClient, error) {
storageClient, err := armstorage.NewAccountsClient(subscriptionID, authCred, &arm.ClientOptions{
ClientOptions: clientOptions(client, cloudConfig),
DisableRPRegistration: false,
})
if err != nil {
return nil, fmt.Errorf("error getting storage client: %w", err)
}
return storageClient, nil
}
type StorageAddresses struct {
CloudConfig cloud.Configuration
ResourceGroup string
StorageAccount string
StorageContainer string
StorageSuffix string
SubscriptionID string
TenantID string
}
// NewContainerClientWithSharedKeyCredential gets a container client authenticated with
// a shared Storage Account Access Key, using previously obtained authentication credentials to
// obtain said key from the Storage Account.
func NewContainerClientWithSharedKeyCredential(ctx context.Context, names StorageAddresses, authCred azcore.TokenCredential) (*container.Client, error) {
containerClient, _, err := NewContainerClientWithSharedKeyCredentialAndKey(ctx, names, authCred)
return containerClient, err
}
func checkNamesForAccessKeyCredentials(names StorageAddresses) error {
var diags tfdiags.Diagnostics
if names.ResourceGroup == "" {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Resource Group is empty",
"In order to obtain a Storage Account Access Key, a resource group is necessary",
))
}
if names.StorageAccount == "" {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Storage Account is empty",
"In order to obtain a Storage Account Access Key, a storage account name is necessary",
))
}
if names.SubscriptionID == "" {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Subscription ID is empty",
"In order to obtain a Storage Account Access Key, a subscription id is necessary",
))
}
return diags.Err()
}
// NewContainerClientWithSharedKeyCredentialAndKey gets a container client and shared key
// that it's authenticated with. This function should only be used for testing and internally within this package.
func NewContainerClientWithSharedKeyCredentialAndKey(ctx context.Context, names StorageAddresses, authCred azcore.TokenCredential) (*container.Client, string, error) {
client := httpclient.New(ctx)
// Lookup the key with an account client
accountsClient, err := NewStorageAccountsClient(client, authCred, names.CloudConfig, names.SubscriptionID)
if err != nil {
return nil, "", err
}
keys, err := accountsClient.ListKeys(ctx, names.ResourceGroup, names.StorageAccount, nil)
if err != nil {
return nil, "", fmt.Errorf("error listing access keys on the storage account: %w", err)
}
if len(keys.Keys) == 0 || keys.Keys[0] == nil || keys.Keys[0].Value == nil {
return nil, "", errors.New("malformed structure returned from the ListKeys function")
}
storageAccessKey := *keys.Keys[0].Value
return newContainerClientFromStorageAccessKey(client, names, storageAccessKey)
}
const STORAGE cloud.ServiceName = "storage"
func CloudConfigFromAddresses(ctx context.Context, environment, metadataHost string) (cloud.Configuration, string, error) {
if metadataHost != "" {
config, err := CloudConfigFromMetadataHost(ctx, metadataHost)
return config, config.Services[STORAGE].Endpoint, err
}
// These environments come from the hamilton Azure library, which was the predecessor to this implementation
// https://github.com/manicminer/hamilton/blob/v0.44.0/environments/environments.go#L103-L118
switch environment {
case "", "public", "global", "canary":
return cloud.AzurePublic, "core.windows.net", nil
case "usgovernment", "usgovernmentl4", "dod", "usgovernmentl5":
return cloud.AzureGovernment, "core.usgovcloudapi.net", nil
case "china":
return cloud.AzureChina, "core.chinacloudapi.cn", nil
}
return cloud.Configuration{}, "", fmt.Errorf("unknown environment identifier: %s", environment)
}
type Authentication struct {
LoginEndpoint string `json:"loginEndpoint"`
Audiences []string `json:"audiences"`
}
type Environment struct {
Authentication Authentication `json:"authentication"`
ResourceManager string `json:"resourceManager"`
Suffixes map[string]string `json:"suffixes"`
}
func CloudConfigFromMetadataHost(ctx context.Context, metadataHost string) (cloud.Configuration, error) {
// Obtaining cloud config from the metadata host
client := httpclient.New(ctx)
// If you change the API version here, verify the JSON response format is accurate to that version
// You can check with this URL:
// https://management.azure.com/metadata/endpoints?api-version=2023-11-01
resp, err := client.Get(fmt.Sprintf("https://%s/metadata/endpoints?api-version=2023-11-01", metadataHost))
if err != nil {
return cloud.Configuration{}, fmt.Errorf("retrieving environments from Azure MetaData service: %w", err)
}
defer resp.Body.Close()
var environment Environment
if err := json.NewDecoder(resp.Body).Decode(&environment); err != nil {
return cloud.Configuration{}, fmt.Errorf("decoding json in metadata response: %w", err)
}
storageSuffix, ok := environment.Suffixes["storage"]
if !ok {
return cloud.Configuration{}, errors.New("could not find storage endpoint in given metadata host")
}
if len(environment.Authentication.Audiences) == 0 {
return cloud.Configuration{}, errors.New("could not find token audience in given metadata host")
}
audience := environment.Authentication.Audiences[0]
return cloud.Configuration{
ActiveDirectoryAuthorityHost: environment.Authentication.LoginEndpoint,
Services: map[cloud.ServiceName]cloud.ServiceConfiguration{
cloud.ResourceManager: {
Endpoint: environment.ResourceManager,
Audience: audience,
},
STORAGE: {
Endpoint: storageSuffix,
Audience: audience,
},
},
}, nil
}
// NewContainerClientFromStorageAccessKey gets a container client authenticated with
// the provided Storage Account Access Key.
func NewContainerClientFromStorageAccessKey(ctx context.Context, names StorageAddresses, storageAccessKey string) (*container.Client, error) {
client := httpclient.New(ctx)
containerClient, _, err := newContainerClientFromStorageAccessKey(client, names, storageAccessKey)
return containerClient, err
}
// containerURL must only be called once it is verified that the StorageAccount and StorageContainer
// names are valid in Azure.
func containerURL(names StorageAddresses) string {
return fmt.Sprintf("https://%s.blob.%s/%s", names.StorageAccount, names.StorageSuffix, names.StorageContainer)
}
func newContainerClientFromStorageAccessKey(client *http.Client, names StorageAddresses, storageAccessKey string) (*container.Client, string, error) {
sharedKeyCredential, err := container.NewSharedKeyCredential(names.StorageAccount, storageAccessKey)
if err != nil {
return nil, "", fmt.Errorf("error creating credential from shared access key: %w", err)
}
containerURL := containerURL(names)
containerClient, err := container.NewClientWithSharedKeyCredential(containerURL, sharedKeyCredential, &container.ClientOptions{
ClientOptions: clientOptions(client, names.CloudConfig),
})
if err != nil {
return nil, "", fmt.Errorf("error obtaining container client from access key: %w", err)
}
return containerClient, storageAccessKey, nil
}
// NewContainerClientFromSAS gets a client authenticated with a Shared Access Signature
func NewContainerClientFromSAS(ctx context.Context, names StorageAddresses, sasToken string) (*container.Client, error) {
client := httpclient.New(ctx)
url := containerURL(names)
containerURL := fmt.Sprintf("%s?%s", url, sasToken)
return container.NewClientWithNoCredential(containerURL, &container.ClientOptions{
ClientOptions: clientOptions(client, names.CloudConfig),
})
}
// NewContainerClient gets a client authenticated with the given auth credentials.
func NewContainerClient(ctx context.Context, names StorageAddresses, authCred azcore.TokenCredential) (*container.Client, error) {
client := httpclient.New(ctx)
return container.NewClient(containerURL(names), authCred, &container.ClientOptions{
ClientOptions: clientOptions(client, names.CloudConfig),
})
}