mirror of
https://github.com/opentffoundation/opentf.git
synced 2025-12-22 03:07:51 -05:00
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>
250 lines
9.8 KiB
Go
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),
|
|
})
|
|
}
|