RW2: Allow custom scope in azuread (#17483)

Signed-off-by: Ben Edmunds <sammybenblue2@gmail.com>
This commit is contained in:
Ben Edmunds 2025-12-02 11:45:23 +00:00 committed by GitHub
parent 8a1086a128
commit 0e682a70a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 134 additions and 4 deletions

View File

@ -3277,6 +3277,14 @@ azuread:
[ sdk:
[ tenant_id: <string> ] ]
# Optional custom OAuth 2.0 scope to request when acquiring tokens.
# If not specified, defaults to the appropriate monitoring scope for the cloud:
# - AzurePublic: https://monitor.azure.com//.default
# - AzureGovernment: https://monitor.azure.us//.default
# - AzureChina: https://monitor.azure.cn//.default
# Use this to authenticate against custom Azure applications or non-standard endpoints.
[ scope: <string> ]
# WARNING: Remote write is NOT SUPPORTED by Google Cloud. This configuration is reserved for future use.
# Optional Google Cloud Monitoring configuration.
# Cannot be used at the same time as basic_auth, authorization, oauth2, sigv4 or azuread.

View File

@ -103,6 +103,9 @@ type AzureADConfig struct { //nolint:revive // exported.
// Cloud is the Azure cloud in which the service is running. Example: AzurePublic/AzureGovernment/AzureChina.
Cloud string `yaml:"cloud,omitempty"`
// Scope is the custom OAuth 2.0 scope to request when acquiring tokens.
Scope string `yaml:"scope,omitempty"`
}
// azureADRoundTripper is used to store the roundtripper and the tokenprovider.
@ -211,6 +214,12 @@ func (c *AzureADConfig) Validate() error {
}
}
if c.Scope != "" {
if matched, err := regexp.MatchString("^[\\w\\s:/.\\-]+$", c.Scope); err != nil || !matched {
return errors.New("the provided scope contains invalid characters")
}
}
return nil
}
@ -360,14 +369,22 @@ func newSDKTokenCredential(clientOpts *azcore.ClientOptions, sdkConfig *SDKConfi
// newTokenProvider helps to fetch accessToken for different types of credential. This also takes care of
// refreshing the accessToken before expiry. This accessToken is attached to the Authorization header while making requests.
func newTokenProvider(cfg *AzureADConfig, cred azcore.TokenCredential) (*tokenProvider, error) {
audience, err := getAudience(cfg.Cloud)
if err != nil {
return nil, err
var scopes []string
// Use custom scope if provided, otherwise fallback to cloud-specific audience
if cfg.Scope != "" {
scopes = []string{cfg.Scope}
} else {
audience, err := getAudience(cfg.Cloud)
if err != nil {
return nil, err
}
scopes = []string{audience}
}
tokenProvider := &tokenProvider{
credentialClient: cred,
options: &policy.TokenRequestOptions{Scopes: []string{audience}},
options: &policy.TokenRequestOptions{Scopes: scopes},
}
return tokenProvider, nil

View File

@ -198,6 +198,11 @@ func TestAzureAdConfig(t *testing.T) {
filename: "testdata/azuread_bad_workloadidentity_missingtenantid.yaml",
err: "must provide an Azure Workload Identity tenant_id in the Azure AD config",
},
// Invalid scope validation.
{
filename: "testdata/azuread_bad_scope_invalid.yaml",
err: "the provided scope contains invalid characters",
},
// Valid config with missing optionally cloud field.
{
filename: "testdata/azuread_good_cloudmissing.yaml",
@ -222,6 +227,10 @@ func TestAzureAdConfig(t *testing.T) {
{
filename: "testdata/azuread_good_workloadidentity.yaml",
},
// Valid OAuth config with custom scope.
{
filename: "testdata/azuread_good_oauth_customscope.yaml",
},
}
for _, c := range cases {
_, err := loadAzureAdConfig(c.filename)
@ -387,3 +396,87 @@ func getToken() azcore.AccessToken {
ExpiresOn: time.Now().Add(10 * time.Second),
}
}
func TestCustomScopeSupport(t *testing.T) {
mockCredential := new(mockCredential)
testToken := &azcore.AccessToken{
Token: testTokenString,
ExpiresOn: testTokenExpiry(),
}
cases := []struct {
name string
cfg *AzureADConfig
expectedScope string
}{
{
name: "Custom scope with OAuth",
cfg: &AzureADConfig{
Cloud: "AzurePublic",
OAuth: &OAuthConfig{
ClientID: dummyClientID,
ClientSecret: dummyClientSecret,
TenantID: dummyTenantID,
},
Scope: "https://custom-app.com/.default",
},
expectedScope: "https://custom-app.com/.default",
},
{
name: "Custom scope with Managed Identity",
cfg: &AzureADConfig{
Cloud: "AzurePublic",
ManagedIdentity: &ManagedIdentityConfig{
ClientID: dummyClientID,
},
Scope: "https://monitor.azure.com//.default",
},
expectedScope: "https://monitor.azure.com//.default",
},
{
name: "Default scope fallback with OAuth",
cfg: &AzureADConfig{
Cloud: "AzurePublic",
OAuth: &OAuthConfig{
ClientID: dummyClientID,
ClientSecret: dummyClientSecret,
TenantID: dummyTenantID,
},
},
expectedScope: IngestionPublicAudience,
},
{
name: "Default scope fallback with China cloud",
cfg: &AzureADConfig{
Cloud: "AzureChina",
OAuth: &OAuthConfig{
ClientID: dummyClientID,
ClientSecret: dummyClientSecret,
TenantID: dummyTenantID,
},
},
expectedScope: IngestionChinaAudience,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
// Set up mock to capture the actual scopes used
mockCredential.On("GetToken", mock.Anything, mock.MatchedBy(func(options policy.TokenRequestOptions) bool {
return len(options.Scopes) == 1 && options.Scopes[0] == c.expectedScope
})).Return(*testToken, nil).Once()
tokenProvider, err := newTokenProvider(c.cfg, mockCredential)
require.NoError(t, err)
require.NotNil(t, tokenProvider)
// Verify that the token provider uses the expected scope
token, err := tokenProvider.getAccessToken(context.Background())
require.NoError(t, err)
require.Equal(t, testTokenString, token)
// Reset mock for next test
mockCredential.ExpectedCalls = nil
})
}
}

View File

@ -0,0 +1,6 @@
cloud: AzurePublic
oauth:
client_id: 00000000-0000-0000-0000-000000000000
client_secret: Cl1ent$ecret!
tenant_id: 00000000-a12b-3cd4-e56f-000000000000
scope: "invalid<>scope*chars"

View File

@ -0,0 +1,6 @@
cloud: AzurePublic
oauth:
client_id: 00000000-0000-0000-0000-000000000000
client_secret: Cl1ent$ecret!
tenant_id: 00000000-a12b-3cd4-e56f-000000000000
scope: "https://custom-app.com/.default"