From e5161846ae267bb8b79add5a4f50e13a2a7a9af6 Mon Sep 17 00:00:00 2001 From: Youssef Omar Bouden Date: Wed, 28 Oct 2026 09:23:51 +0100 Subject: [PATCH] feat: use the cloud endpoints from the credentials for sovereign clouds The identity and the Resource Graph client were created with nil options, so every query targeted the public cloud and Azure Government credentials failed with NoValidSubscriptionsInQueryRequest. The endpoint URLs that az ad sp create-for-rbac --sdk-auth writes into the credentials now select the cloud: the public, Government and China endpoints map to the SDK's own configurations, any other pair is used as given with managementEndpointUrl as the token audience, and the configuration reaches both the credential and the client for service principal and workload identity alike. Signed-off-by: Youssef Omar Bouden --- README.md | 24 ++++++++++++++ fn.go | 78 +++++++++++++++++++++++++++++++++++++++++--- fn_test.go | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1aaf952..c108a07 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,30 @@ Example pipeline step: The Azure Credentials Secret structure is fully compatible with the standard [Azure Official Provider][azop] +### Sovereign clouds + +The function targets the Azure public cloud unless the credentials say +otherwise. For Azure Government, Azure China or another cloud, add the endpoint +URLs that `az ad sp create-for-rbac --sdk-auth` prints to the credentials JSON; +they apply to service principal and workload identity credentials alike: + +```json +{ + "clientId": "your-client-id", + "clientSecret": "your-client-secret", + "tenantId": "your-tenant-id", + "subscriptionId": "your-subscription-id", + "activeDirectoryEndpointUrl": "https://login.microsoftonline.us", + "resourceManagerEndpointUrl": "https://management.usgovcloudapi.net/", + "managementEndpointUrl": "https://management.core.usgovcloudapi.net/" +} +``` + +`activeDirectoryEndpointUrl` and `resourceManagerEndpointUrl` select the cloud. +The public, Government and China endpoints map to the Azure SDK's built-in +configurations; any other pair is used as given, with `managementEndpointUrl` +as the token audience when present. + Example XR status after e2e query: ```yaml diff --git a/fn.go b/fn.go index 64ac1c8..17bda50 100644 --- a/fn.go +++ b/fn.go @@ -4,11 +4,15 @@ import ( "context" "encoding/json" "fmt" + "net/url" "regexp" "strings" "sync/atomic" "time" + "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/to" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph" @@ -36,6 +40,12 @@ const ( ClientID = "clientId" // ClientSecret defines the azure credentials key for client secret ClientSecret = "clientSecret" + // ActiveDirectoryEndpointURL defines the azure credentials key for the Entra ID authority of the cloud + ActiveDirectoryEndpointURL = "activeDirectoryEndpointUrl" + // ResourceManagerEndpointURL defines the azure credentials key for the Resource Manager endpoint of the cloud + ResourceManagerEndpointURL = "resourceManagerEndpointUrl" + // ManagementEndpointURL defines the azure credentials key for the Resource Manager token audience of the cloud + ManagementEndpointURL = "managementEndpointUrl" // WorkloadIdentityCredentialPath defines the azure credentials key for federated token file path WorkloadIdentityCredentialPath = "federatedTokenFile" ) @@ -504,7 +514,13 @@ func (a *AzureQuery) azQuery(ctx context.Context, azureCreds interface{}, in *v1 func (a *AzureQuery) initializeWorkloadIdentityProvider(azureCreds map[string]string, log logging.Logger) (*armresourcegraph.Client, error) { tokenFilePath := azureCreds[WorkloadIdentityCredentialPath] + cloudConfig, err := cloudConfiguration(azureCreds) + if err != nil { + return nil, err + } + options := &azidentity.WorkloadIdentityCredentialOptions{ + ClientOptions: azcore.ClientOptions{Cloud: cloudConfig}, TokenFilePath: tokenFilePath, } @@ -521,7 +537,7 @@ func (a *AzureQuery) initializeWorkloadIdentityProvider(azureCreds map[string]st } // Create Azure credential - log.Info("Initializing workload identity provider", "tokenFile", tokenFilePath) + log.Info("Initializing workload identity provider", "tokenFile", tokenFilePath, "authority", cloudConfig.ActiveDirectoryAuthorityHost) cred, err := azidentity.NewWorkloadIdentityCredential(options) if err != nil { @@ -529,7 +545,7 @@ func (a *AzureQuery) initializeWorkloadIdentityProvider(azureCreds map[string]st } // Create and authorize a ResourceGraph client - client, err := armresourcegraph.NewClient(cred, nil) + client, err := armresourcegraph.NewClient(cred, &arm.ClientOptions{ClientOptions: azcore.ClientOptions{Cloud: cloudConfig}}) if err != nil { return nil, errors.Wrap(err, "failed to create client") } @@ -544,15 +560,20 @@ func (a *AzureQuery) initializeClientSecretProvider(azureCreds map[string]string clientID := azureCreds[ClientID] clientSecret := azureCreds[ClientSecret] + cloudConfig, err := cloudConfiguration(azureCreds) + if err != nil { + return nil, err + } + // To configure DefaultAzureCredential to authenticate a user-assigned managed identity, // set the environment variable AZURE_CLIENT_ID to the identity's client ID. - cred, err := azidentity.NewClientSecretCredential(tenantID, clientID, clientSecret, nil) + cred, err := azidentity.NewClientSecretCredential(tenantID, clientID, clientSecret, &azidentity.ClientSecretCredentialOptions{ClientOptions: azcore.ClientOptions{Cloud: cloudConfig}}) if err != nil { return nil, errors.Wrap(err, "failed to obtain clientsecret credentials") } // Create and authorize a ResourceGraph client - client, err := armresourcegraph.NewClient(cred, nil) + client, err := armresourcegraph.NewClient(cred, &arm.ClientOptions{ClientOptions: azcore.ClientOptions{Cloud: cloudConfig}}) if err != nil { return nil, errors.Wrap(err, "failed to create client") } @@ -986,3 +1007,52 @@ func (f *Function) checkContextTargetHasData(req *fnv1.RunFunctionRequest, in *v } return false } + +// cloudConfiguration selects the Azure cloud from the endpoint URLs that +// `az ad sp create-for-rbac --sdk-auth` writes into the credentials. Without +// them the public cloud is used. The public, Government and China endpoints +// map to the SDK's own configurations; any other pair is used as given. +func cloudConfiguration(azureCreds map[string]string) (cloud.Configuration, error) { + authority := azureCreds[ActiveDirectoryEndpointURL] + manager := azureCreds[ResourceManagerEndpointURL] + if authority == "" && manager == "" { + return cloud.AzurePublic, nil + } + + for _, known := range []cloud.Configuration{cloud.AzurePublic, cloud.AzureGovernment, cloud.AzureChina} { + if sameHost(authority, known.ActiveDirectoryAuthorityHost) && sameHost(manager, known.Services[cloud.ResourceManager].Endpoint) { + return known, nil + } + } + + if authority == "" || manager == "" { + return cloud.Configuration{}, errors.Errorf("both %s and %s are required for a cloud the SDK does not know", ActiveDirectoryEndpointURL, ResourceManagerEndpointURL) + } + audience := azureCreds[ManagementEndpointURL] + if audience == "" { + audience = manager + } + return cloud.Configuration{ + ActiveDirectoryAuthorityHost: authority, + Services: map[cloud.ServiceName]cloud.ServiceConfiguration{ + cloud.ResourceManager: {Audience: audience, Endpoint: strings.TrimSuffix(manager, "/")}, + }, + }, nil +} + +// sameHost reports whether the given URL names the same host as the known +// one. An unset URL matches, so that one endpoint is enough to pick a cloud. +func sameHost(given, known string) bool { + if given == "" { + return true + } + g, err := url.Parse(given) + if err != nil { + return false + } + k, err := url.Parse(known) + if err != nil { + return false + } + return strings.EqualFold(g.Host, k.Host) +} diff --git a/fn_test.go b/fn_test.go index 02882c0..ddb6f14 100644 --- a/fn_test.go +++ b/fn_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph" "github.com/google/go-cmp/cmp" @@ -3162,3 +3163,96 @@ func validateBackwardsCompatibility(rsp *fnv1.RunFunctionResponse) error { return nil } + +func TestCloudConfiguration(t *testing.T) { + custom := cloud.Configuration{ + ActiveDirectoryAuthorityHost: "https://login.example.test/", + Services: map[cloud.ServiceName]cloud.ServiceConfiguration{ + cloud.ResourceManager: {Audience: "https://management.core.example.test/", Endpoint: "https://management.example.test"}, + }, + } + customWithoutAudience := cloud.Configuration{ + ActiveDirectoryAuthorityHost: "https://login.example.test/", + Services: map[cloud.ServiceName]cloud.ServiceConfiguration{ + cloud.ResourceManager: {Audience: "https://management.example.test/", Endpoint: "https://management.example.test"}, + }, + } + + cases := map[string]struct { + reason string + creds map[string]string + want cloud.Configuration + wantErr bool + }{ + "NoEndpoints": { + reason: "Credentials without endpoint URLs keep targeting the public cloud", + creds: map[string]string{ClientID: "client", ClientSecret: "secret", TenantID: "tenant"}, + want: cloud.AzurePublic, + }, + "PublicEndpoints": { + reason: "The public endpoints map to the SDK's public cloud configuration", + creds: map[string]string{ + ActiveDirectoryEndpointURL: "https://login.microsoftonline.com", + ResourceManagerEndpointURL: "https://management.azure.com/", + ManagementEndpointURL: "https://management.core.windows.net/", + }, + want: cloud.AzurePublic, + }, + "Government": { + reason: "The Azure Government endpoints map to the SDK's Government configuration", + creds: map[string]string{ + ActiveDirectoryEndpointURL: "https://login.microsoftonline.us", + ResourceManagerEndpointURL: "https://management.usgovcloudapi.net/", + ManagementEndpointURL: "https://management.core.usgovcloudapi.net/", + }, + want: cloud.AzureGovernment, + }, + "GovernmentAuthorityOnly": { + reason: "One known endpoint is enough to pick the cloud", + creds: map[string]string{ActiveDirectoryEndpointURL: "https://login.microsoftonline.us/"}, + want: cloud.AzureGovernment, + }, + "China": { + reason: "The Azure China endpoints map to the SDK's China configuration", + creds: map[string]string{ + ActiveDirectoryEndpointURL: "https://login.chinacloudapi.cn", + ResourceManagerEndpointURL: "https://management.chinacloudapi.cn/", + }, + want: cloud.AzureChina, + }, + "Custom": { + reason: "Unknown endpoints are used as given, with managementEndpointUrl as the token audience", + creds: map[string]string{ + ActiveDirectoryEndpointURL: "https://login.example.test/", + ResourceManagerEndpointURL: "https://management.example.test/", + ManagementEndpointURL: "https://management.core.example.test/", + }, + want: custom, + }, + "CustomWithoutAudience": { + reason: "Without managementEndpointUrl the Resource Manager endpoint is the audience", + creds: map[string]string{ + ActiveDirectoryEndpointURL: "https://login.example.test/", + ResourceManagerEndpointURL: "https://management.example.test/", + }, + want: customWithoutAudience, + }, + "CustomMissingAuthority": { + reason: "An unknown Resource Manager endpoint without its authority is an error rather than a silent public cloud", + creds: map[string]string{ResourceManagerEndpointURL: "https://management.example.test/"}, + wantErr: true, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, err := cloudConfiguration(tc.creds) + if (err != nil) != tc.wantErr { + t.Fatalf("%s\ncloudConfiguration(...): error = %v, wantErr %v", tc.reason, err, tc.wantErr) + } + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("%s\ncloudConfiguration(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +}