@cryptotaxi247 / netdata-1 / commits / 0650102c7

refactor(go/cloudauth): adopt shared Azure auth schema for collectors and secretstore (#21995)

Ilya Mashchenko committed Mar 20, 2026 at 23:08 UTC 0650102c70096fef699bef8131c0cb1466432833
26 files changed +1143 -391
src/go/plugin/agent/secrets/secretstore/backends/azure/config_schema.json
+19 -10
@@ -9,10 +9,11 @@
9 "description": "Azure authentication mode.",
10 "type": "string",
11 "enum": [
12 - "client",
13 - "managed_identity"
12 + "service_principal",
13 + "managed_identity",
14 + "default"
15 ],
15 - "default": "client"
16 + "default": "default"
17 }
18 },
19 "required": [
@@ -24,11 +25,11 @@
25 {
26 "properties": {
27 "mode": {
27 - "const": "client"
28 + "const": "service_principal"
29 },
29 - "mode_client": {
30 - "title": "Client",
31 - "description": "Client credentials used when mode is `client`.",
30 + "mode_service_principal": {
31 + "title": "Service Principal",
32 + "description": "Service principal credentials used when mode is `service_principal`.",
33 "type": "object",
34 "properties": {
35 "tenant_id": {
@@ -56,7 +57,7 @@
57 }
58 },
59 "required": [
59 - "mode_client"
60 + "mode_service_principal"
61 ]
62 },
63 {
@@ -77,6 +78,13 @@
78 }
79 }
80 }
81 + },
82 + {
83 + "properties": {
84 + "mode": {
85 + "const": "default"
86 + }
87 + }
88 }
89 ]
90 }
@@ -90,9 +98,10 @@
98 "ui:widget": "radio",
99 "ui:options": {
100 "inline": true
93 - }
101 + },
102 + "ui:help": "Choose how Netdata gets Azure credentials.\n\n- `service_principal`: Use an Azure app / service principal. Requires `tenant_id`, `client_id`, and `client_secret` in `mode_service_principal`.\n- `managed_identity`: Use the managed identity attached to the Azure resource running Netdata. Set `mode_managed_identity.client_id` only for a user-assigned identity.\n- `default`: Use the Azure SDK `DefaultAzureCredential` chain. This automatically tries available Azure credential sources, such as environment-based credentials, managed identity, and local developer credentials.\n\nUse `service_principal` for explicit app credentials. Use `managed_identity` when Netdata runs on an Azure resource with an attached identity. Use `default` when you want Azure SDK auto-discovery or local development convenience."
103 },
95 - "mode_client": {
104 + "mode_service_principal": {
105 "client_secret": {
106 "ui:widget": "password"
107 }
src/go/plugin/agent/secrets/secretstore/backends/azure/init.go
+147 -38
@@ -5,57 +5,166 @@ package azure
5 import (
6 "context"
7 "fmt"
8 - "strings"
8 + "net"
9 + "net/http"
10 + "time"
11
12 + "github.com/Azure/azure-sdk-for-go/sdk/azcore"
13 + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
14 "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
15 + "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/cloudauth"
16 )
17
18 +const azureKeyVaultScope = "https://vault.azure.net/.default"
19 +
20 func (s *store) init(_ context.Context) error {
14 - published := &publishedStore{provider: s.provider}
21 + if err := s.Config.ValidateWithPath(""); err != nil {
22 + return err
23 + }
24 +
25 + cred, err := s.Config.NewCredentialWithOptions(s.credentialOptions())
26 + if err != nil {
27 + return fmt.Errorf("creating azure credential for kind %q: %w", secretstore.KindAzureKV, err)
28 + }
29 + cred = credentialWithTimeout{
30 + cred: cred,
31 + timeout: s.authTimeout(),
32 + }
33 +
34 + tokenProvider, err := cloudauth.NewTokenProvider(
35 + cred,
36 + []string{azureKeyVaultScope},
37 + cloudauth.DefaultTokenRefreshMargin,
38 + )
39 + if err != nil {
40 + return fmt.Errorf("creating azure token provider for kind %q: %w", secretstore.KindAzureKV, err)
41 + }
42
16 - switch strings.TrimSpace(s.Config.Mode) {
17 - case "client":
18 - if s.Config.ModeClient == nil {
19 - return fmt.Errorf("mode_client is required when mode is 'client'")
43 + s.published = &publishedStore{
44 + provider: s.provider,
45 + tokenProvider: tokenProvider,
46 + }
47 + return nil
48 +}
49 +
50 +func (s *store) authTimeout() time.Duration {
51 + switch s.Config.NormalizedMode() {
52 + case cloudauth.AzureADAuthModeServicePrincipal:
53 + if s.provider.apiClient != nil {
54 + return s.provider.apiClient.Timeout
55 }
21 - tenantID := strings.TrimSpace(s.Config.ModeClient.TenantID)
22 - if tenantID == "" {
23 - return fmt.Errorf("mode_client.tenant_id is required")
56 + case cloudauth.AzureADAuthModeManagedIdentity:
57 + if s.provider.imdsClient != nil {
58 + return s.provider.imdsClient.Timeout
59 }
25 - clientID := strings.TrimSpace(s.Config.ModeClient.ClientID)
26 - if clientID == "" {
27 - return fmt.Errorf("mode_client.client_id is required")
60 + case cloudauth.AzureADAuthModeDefault:
61 + if s.provider.apiClient != nil && s.provider.apiClient.Timeout > 0 {
62 + return s.provider.apiClient.Timeout
63 }
29 - clientSecret := strings.TrimSpace(s.Config.ModeClient.ClientSecret)
30 - if clientSecret == "" {
31 - return fmt.Errorf("mode_client.client_secret is required")
64 + if s.provider.imdsClient != nil {
65 + return s.provider.imdsClient.Timeout
66 }
33 - s.Config.Mode = "client"
34 - s.Config.ModeClient.TenantID = tenantID
35 - s.Config.ModeClient.ClientID = clientID
36 - s.Config.ModeClient.ClientSecret = clientSecret
37 - s.Config.ModeManagedIdentity = nil
38 - published.mode = s.Config.Mode
39 - published.clientTenantID = tenantID
40 - published.clientID = clientID
41 - published.clientSecret = clientSecret
42 - case "managed_identity":
43 - s.Config.Mode = "managed_identity"
44 - s.Config.ModeClient = nil
45 - published.mode = s.Config.Mode
46 - if s.Config.ModeManagedIdentity != nil {
47 - clientID := strings.TrimSpace(s.Config.ModeManagedIdentity.ClientID)
48 - if clientID != "" {
49 - s.Config.ModeManagedIdentity.ClientID = clientID
50 - published.managedIdentityClientID = clientID
51 - } else {
52 - s.Config.ModeManagedIdentity = nil
53 - }
67 + }
68 +
69 + return 0
70 +}
71 +
72 +func (s *store) credentialOptions() *cloudauth.AzureADCredentialOptions {
73 + opts := &cloudauth.AzureADCredentialOptions{}
74 +
75 + switch s.Config.NormalizedMode() {
76 + case cloudauth.AzureADAuthModeServicePrincipal:
77 + if s.provider.apiClient != nil && s.provider.apiClient.Transport != nil {
78 + opts.ClientOptions.Transport = transportAdapter{s.provider.apiClient.Transport}
79 }
80 + case cloudauth.AzureADAuthModeManagedIdentity:
81 + if s.provider.imdsClient != nil && s.provider.imdsClient.Transport != nil {
82 + opts.ClientOptions.Transport = transportAdapter{s.provider.imdsClient.Transport}
83 + }
84 + case cloudauth.AzureADAuthModeDefault:
85 + opts.ClientOptions.Transport = routingTransportAdapter{
86 + defaultRoundTripper: roundTripperForClient(s.provider.apiClient),
87 + noProxyRoundTripper: roundTripperForClient(s.provider.imdsClient),
88 + }
89 + }
90 +
91 + return opts
92 +}
93 +
94 +type transportAdapter struct {
95 + roundTripper httpRoundTripper
96 +}
97 +
98 +type httpRoundTripper interface {
99 + RoundTrip(*http.Request) (*http.Response, error)
100 +}
101 +
102 +func (t transportAdapter) Do(req *http.Request) (*http.Response, error) {
103 + return t.roundTripper.RoundTrip(req)
104 +}
105 +
106 +type routingTransportAdapter struct {
107 + defaultRoundTripper httpRoundTripper
108 + noProxyRoundTripper httpRoundTripper
109 +}
110 +
111 +func (t routingTransportAdapter) Do(req *http.Request) (*http.Response, error) {
112 + switch {
113 + case shouldUseNoProxyTransport(req) && t.noProxyRoundTripper != nil:
114 + return t.noProxyRoundTripper.RoundTrip(req)
115 + case t.defaultRoundTripper != nil:
116 + return t.defaultRoundTripper.RoundTrip(req)
117 + case t.noProxyRoundTripper != nil:
118 + return t.noProxyRoundTripper.RoundTrip(req)
119 default:
56 - return fmt.Errorf("mode '%s' is invalid for kind '%s'", s.Config.Mode, secretstore.KindAzureKV)
120 + return http.DefaultTransport.RoundTrip(req)
121 }
122 +}
123
59 - s.published = published
124 +func roundTripperForClient(client *http.Client) httpRoundTripper {
125 + if client != nil && client.Transport != nil {
126 + return client.Transport
127 + }
128 + if rt, ok := http.DefaultTransport.(httpRoundTripper); ok {
129 + return rt
130 + }
131 return nil
132 }
133 +
134 +// Managed identity endpoints are local or link-local and must bypass proxies.
135 +func shouldUseNoProxyTransport(req *http.Request) bool {
136 + if req == nil || req.URL == nil {
137 + return false
138 + }
139 +
140 + host := req.URL.Hostname()
141 + if host == "" {
142 + return false
143 + }
144 + if host == "localhost" {
145 + return true
146 + }
147 +
148 + ip := net.ParseIP(host)
149 + if ip == nil {
150 + return false
151 + }
152 +
153 + return ip.IsLoopback() || ip.IsLinkLocalUnicast()
154 +}
155 +
156 +type credentialWithTimeout struct {
157 + cred azcore.TokenCredential
158 + timeout time.Duration
159 +}
160 +
161 +func (c credentialWithTimeout) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) {
162 + if c.timeout <= 0 {
163 + return c.cred.GetToken(ctx, opts)
164 + }
165 +
166 + ctx, cancel := context.WithTimeout(ctx, c.timeout)
167 + defer cancel()
168 +
169 + return c.cred.GetToken(ctx, opts)
170 +}
src/go/plugin/agent/secrets/secretstore/backends/azure/init_test.go new
+250
@@ -0,0 +1,250 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package azure
4 +
5 +import (
6 + "context"
7 + "errors"
8 + "net/http"
9 + "testing"
10 + "time"
11 +
12 + "github.com/Azure/azure-sdk-for-go/sdk/azcore"
13 + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
14 + "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/cloudauth"
15 + "github.com/stretchr/testify/assert"
16 + "github.com/stretchr/testify/require"
17 +)
18 +
19 +func TestStoreInit(t *testing.T) {
20 + tests := map[string]struct {
21 + cfg Config
22 + wantErrContains string
23 + }{
24 + "service principal": {
25 + cfg: Config{
26 + Mode: cloudauth.AzureADAuthModeServicePrincipal,
27 + ModeServicePrincipal: &cloudauth.AzureADModeServicePrincipalConfig{
28 + TenantID: "tenant-id",
29 + ClientID: "client-id",
30 + ClientSecret: "client-secret",
31 + },
32 + },
33 + },
34 + "default": {
35 + cfg: Config{
36 + Mode: cloudauth.AzureADAuthModeDefault,
37 + },
38 + },
39 + "service principal validation": {
40 + cfg: Config{
41 + Mode: cloudauth.AzureADAuthModeServicePrincipal,
42 + ModeServicePrincipal: &cloudauth.AzureADModeServicePrincipalConfig{
43 + TenantID: "tenant-id",
44 + ClientID: "client-id",
45 + },
46 + },
47 + wantErrContains: "mode_service_principal.client_secret is required",
48 + },
49 + }
50 +
51 + for name, tc := range tests {
52 + t.Run(name, func(t *testing.T) {
53 + s := &store{
54 + Config: tc.cfg,
55 + provider: &provider{
56 + apiClient: &http.Client{},
57 + imdsClient: &http.Client{},
58 + },
59 + }
60 +
61 + err := s.init(context.Background())
62 + if tc.wantErrContains != "" {
63 + require.Error(t, err)
64 + assert.ErrorContains(t, err, tc.wantErrContains)
65 + return
66 + }
67 +
68 + require.NoError(t, err)
69 + require.NotNil(t, s.published)
70 + assert.NotNil(t, s.published.tokenProvider)
71 + })
72 + }
73 +}
74 +
75 +func TestStoreAuthTimeout(t *testing.T) {
76 + tests := map[string]struct {
77 + mode string
78 + apiTimeout time.Duration
79 + imdsTimeout time.Duration
80 + want time.Duration
81 + }{
82 + "service principal uses api client timeout": {
83 + mode: cloudauth.AzureADAuthModeServicePrincipal,
84 + apiTimeout: 10 * time.Second,
85 + want: 10 * time.Second,
86 + },
87 + "managed identity uses imds timeout": {
88 + mode: cloudauth.AzureADAuthModeManagedIdentity,
89 + imdsTimeout: 2 * time.Second,
90 + want: 2 * time.Second,
91 + },
92 + "default prefers api client timeout": {
93 + mode: cloudauth.AzureADAuthModeDefault,
94 + apiTimeout: 10 * time.Second,
95 + imdsTimeout: 2 * time.Second,
96 + want: 10 * time.Second,
97 + },
98 + }
99 +
100 + for name, tc := range tests {
101 + t.Run(name, func(t *testing.T) {
102 + s := &store{
103 + Config: Config{Mode: tc.mode},
104 + provider: &provider{
105 + apiClient: &http.Client{Timeout: tc.apiTimeout},
106 + imdsClient: &http.Client{Timeout: tc.imdsTimeout},
107 + },
108 + }
109 +
110 + assert.Equal(t, tc.want, s.authTimeout())
111 + })
112 + }
113 +}
114 +
115 +func TestCredentialWithTimeout(t *testing.T) {
116 + errMissingDeadline := errors.New("missing deadline")
117 +
118 + tests := map[string]struct {
119 + timeout time.Duration
120 + buildTokenFn func(t *testing.T) (func(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error), func(t *testing.T))
121 + wantErr bool
122 + wantErrIs error
123 + wantToken string
124 + }{
125 + "zero timeout passes through": {
126 + timeout: 0,
127 + buildTokenFn: func(*testing.T) (func(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error), func(t *testing.T)) {
128 + return func(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) {
129 + return azcore.AccessToken{Token: "ok"}, nil
130 + }, nil
131 + },
132 + wantToken: "ok",
133 + },
134 + "timeout cancels token request": {
135 + timeout: 20 * time.Millisecond,
136 + buildTokenFn: func(*testing.T) (func(ctx context.Context, _ policy.TokenRequestOptions) (azcore.AccessToken, error), func(t *testing.T)) {
137 + var sawDeadline bool
138 +
139 + return func(ctx context.Context, _ policy.TokenRequestOptions) (azcore.AccessToken, error) {
140 + if _, ok := ctx.Deadline(); !ok {
141 + return azcore.AccessToken{}, errMissingDeadline
142 + }
143 +
144 + sawDeadline = true
145 + <-ctx.Done()
146 + return azcore.AccessToken{}, ctx.Err()
147 + }, func(t *testing.T) {
148 + assert.True(t, sawDeadline)
149 + }
150 + },
151 + wantErr: true,
152 + wantErrIs: context.DeadlineExceeded,
153 + },
154 + }
155 +
156 + for name, tc := range tests {
157 + t.Run(name, func(t *testing.T) {
158 + getToken, assertPostRun := tc.buildTokenFn(t)
159 +
160 + cred := credentialWithTimeout{
161 + cred: fakeTokenCredential{getToken: getToken},
162 + timeout: tc.timeout,
163 + }
164 +
165 + type result struct {
166 + token azcore.AccessToken
167 + err error
168 + }
169 +
170 + results := make(chan result, 1)
171 + go func() {
172 + token, err := cred.GetToken(context.Background(), policy.TokenRequestOptions{Scopes: []string{azureKeyVaultScope}})
173 + results <- result{token: token, err: err}
174 + }()
175 +
176 + var res result
177 + select {
178 + case res = <-results:
179 + case <-time.After(2 * time.Second):
180 + t.Fatal("GetToken did not return before the outer test timeout")
181 + }
182 +
183 + if tc.wantErr {
184 + require.Error(t, res.err)
185 + assert.ErrorIs(t, res.err, tc.wantErrIs)
186 + if assertPostRun != nil {
187 + assertPostRun(t)
188 + }
189 + return
190 + }
191 +
192 + require.NoError(t, res.err)
193 + assert.Equal(t, tc.wantToken, res.token.Token)
194 + })
195 + }
196 +}
197 +
198 +func TestDefaultCredentialTransportRouting(t *testing.T) {
199 + tests := map[string]struct {
200 + url string
201 + wantDefaultCalls int
202 + wantNoProxyCalls int
203 + }{
204 + "aad host uses default transport": {
205 + url: "https://login.microsoftonline.com/tenant/oauth2/v2.0/token",
206 + wantDefaultCalls: 1,
207 + },
208 + "imds uses no proxy transport": {
209 + url: "http://169.254.169.254/metadata/identity/oauth2/token",
210 + wantNoProxyCalls: 1,
211 + },
212 + "localhost managed identity endpoint uses no proxy transport": {
213 + url: "http://localhost/msi/token",
214 + wantNoProxyCalls: 1,
215 + },
216 + }
217 +
218 + for name, tc := range tests {
219 + t.Run(name, func(t *testing.T) {
220 + var defaultCalls int
221 + var noProxyCalls int
222 +
223 + s := &store{
224 + Config: Config{Mode: cloudauth.AzureADAuthModeDefault},
225 + provider: &provider{
226 + apiClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
227 + defaultCalls++
228 + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header)}, nil
229 + })},
230 + imdsClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
231 + noProxyCalls++
232 + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header)}, nil
233 + })},
234 + },
235 + }
236 +
237 + transport, ok := s.credentialOptions().ClientOptions.Transport.(routingTransportAdapter)
238 + require.True(t, ok)
239 +
240 + req, err := http.NewRequest(http.MethodGet, tc.url, nil)
241 + require.NoError(t, err)
242 +
243 + _, err = transport.Do(req)
244 + require.NoError(t, err)
245 +
246 + assert.Equal(t, tc.wantDefaultCalls, defaultCalls)
247 + assert.Equal(t, tc.wantNoProxyCalls, noProxyCalls)
248 + })
249 + }
250 +}
src/go/plugin/agent/secrets/secretstore/backends/azure/provider.go
+6 -24
@@ -11,6 +11,7 @@ import (
11
12 "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
13 "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore/internal/httpx"
14 + "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/cloudauth"
15 )
16
17 var (
@@ -19,26 +20,11 @@ var (
20 reAzureSafeName = regexp.MustCompile(`^[a-zA-Z0-9-]+$`)
21 )
22
22 -type Config struct {
23 - Mode string `json:"mode" yaml:"mode"`
24 - ModeClient *ModeClientConfig `json:"mode_client,omitempty" yaml:"mode_client,omitempty"`
25 - ModeManagedIdentity *ModeManagedIdentityConfig `json:"mode_managed_identity,omitempty" yaml:"mode_managed_identity,omitempty"`
26 -}
27 -
28 -type ModeClientConfig struct {
29 - TenantID string `json:"tenant_id" yaml:"tenant_id"`
30 - ClientID string `json:"client_id" yaml:"client_id"`
31 - ClientSecret string `json:"client_secret" yaml:"client_secret"`
32 -}
33 -
34 -type ModeManagedIdentityConfig struct {
35 - ClientID string `json:"client_id,omitempty" yaml:"client_id,omitempty"`
36 -}
23 +type Config = cloudauth.AzureADAuthConfig
24
25 type provider struct {
39 - apiClient *http.Client
40 - imdsClient *http.Client
41 - loginEndpointURL string
26 + apiClient *http.Client
27 + imdsClient *http.Client
28 }
29
30 type store struct {
@@ -48,12 +34,8 @@ type store struct {
34 }
35
36 type publishedStore struct {
51 - provider *provider
52 - mode string
53 - clientTenantID string
54 - clientID string
55 - clientSecret string
56 - managedIdentityClientID string
37 + provider *provider
38 + tokenProvider *cloudauth.TokenProvider
39 }
40
41 func New() secretstore.Creator {
src/go/plugin/agent/secrets/secretstore/backends/azure/resolve.go
+5 -92
@@ -8,7 +8,6 @@ import (
8 "fmt"
9 "io"
10 "net/http"
11 - "net/url"
11 "strings"
12
13 "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
@@ -74,99 +73,13 @@ func splitOperand(operand string) (string, string, bool) {
73 }
74
75 func (s *publishedStore) accessToken(ctx context.Context) (string, error) {
77 - switch s.mode {
78 - case "client":
79 - if s.clientTenantID == "" {
80 - return "", fmt.Errorf("mode_client.tenant_id is required")
81 - }
82 - if s.clientID == "" {
83 - return "", fmt.Errorf("mode_client.client_id is required")
84 - }
85 - if s.clientSecret == "" {
86 - return "", fmt.Errorf("mode_client.client_secret is required")
87 - }
88 - return s.clientCredentialsToken(ctx, s.clientTenantID, s.clientID, s.clientSecret)
89 - case "managed_identity":
90 - var clientID string
91 - if s.managedIdentityClientID != "" {
92 - clientID = s.managedIdentityClientID
93 - }
94 - return s.managedIdentityToken(ctx, clientID)
95 - default:
96 - return "", fmt.Errorf("mode '%s' is invalid for azure-kv", s.mode)
76 + if s.tokenProvider == nil {
77 + return "", fmt.Errorf("azure token provider is not initialized")
78 }
98 -}
99 -
100 -func (s *publishedStore) clientCredentialsToken(ctx context.Context, tenantID, clientID, clientSecret string) (string, error) {
101 - tokenURL := s.provider.loginEndpointURL
102 - if tokenURL == "" {
103 - tokenURL = fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", tenantID)
104 - }
105 - form := url.Values{
106 - "client_id": {clientID},
107 - "client_secret": {clientSecret},
108 - "scope": {"https://vault.azure.net/.default"},
109 - "grant_type": {"client_credentials"},
110 - }
111 - req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
112 - if err != nil {
113 - return "", fmt.Errorf("creating client credentials token request: %w", err)
114 - }
115 - req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
116 - resp, err := s.provider.apiClient.Do(req)
117 - if err != nil {
118 - return "", fmt.Errorf("client credentials token request failed: %w", err)
119 - }
120 - defer resp.Body.Close()
121 - body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
122 - if err != nil {
123 - return "", fmt.Errorf("reading client credentials token response: %w", err)
124 - }
125 - if resp.StatusCode != http.StatusOK {
126 - return "", fmt.Errorf("client credentials token request returned HTTP %d: %s", resp.StatusCode, httpx.TruncateBody(body))
127 - }
128 - var result struct {
129 - AccessToken string `json:"access_token"`
130 - }
131 - if err := json.Unmarshal(body, &result); err != nil {
132 - return "", fmt.Errorf("parsing client credentials token response: %w", err)
133 - }
134 - if result.AccessToken == "" {
135 - return "", fmt.Errorf("client credentials token response missing access_token")
136 - }
137 - return result.AccessToken, nil
138 -}
79
140 -func (s *publishedStore) managedIdentityToken(ctx context.Context, clientID string) (string, error) {
141 - reqURL := "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://vault.azure.net"
142 - if clientID != "" {
143 - reqURL += "&client_id=" + url.QueryEscape(clientID)
144 - }
145 - req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
80 + token, _, err := s.tokenProvider.Token(ctx)
81 if err != nil {
147 - return "", fmt.Errorf("creating managed identity token request: %w", err)
148 - }
149 - req.Header.Set("Metadata", "true")
150 - resp, err := s.provider.imdsClient.Do(req)
151 - if err != nil {
152 - return "", fmt.Errorf("managed identity token request failed: %w", err)
153 - }
154 - defer resp.Body.Close()
155 - body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
156 - if err != nil {
157 - return "", fmt.Errorf("reading managed identity token response: %w", err)
158 - }
159 - if resp.StatusCode != http.StatusOK {
160 - return "", fmt.Errorf("managed identity token request returned HTTP %d: %s", resp.StatusCode, httpx.TruncateBody(body))
161 - }
162 - var result struct {
163 - AccessToken string `json:"access_token"`
164 - }
165 - if err := json.Unmarshal(body, &result); err != nil {
166 - return "", fmt.Errorf("parsing managed identity token response: %w", err)
167 - }
168 - if result.AccessToken == "" {
169 - return "", fmt.Errorf("managed identity token response missing access_token")
82 + return "", fmt.Errorf("acquiring Azure Key Vault access token: %w", err)
83 }
171 - return result.AccessToken, nil
84 + return token, nil
85 }
src/go/plugin/agent/secrets/secretstore/backends/azure/resolve_test.go new
+114
@@ -0,0 +1,114 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package azure
4 +
5 +import (
6 + "context"
7 + "errors"
8 + "io"
9 + "net/http"
10 + "strings"
11 + "testing"
12 + "time"
13 +
14 + "github.com/Azure/azure-sdk-for-go/sdk/azcore"
15 + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
16 + "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
17 + "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/cloudauth"
18 + "github.com/stretchr/testify/assert"
19 + "github.com/stretchr/testify/require"
20 +)
21 +
22 +type fakeTokenCredential struct {
23 + getToken func(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error)
24 +}
25 +
26 +func (f fakeTokenCredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) {
27 + return f.getToken(ctx, opts)
28 +}
29 +
30 +type roundTripFunc func(*http.Request) (*http.Response, error)
31 +
32 +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
33 + return f(req)
34 +}
35 +
36 +func TestPublishedStoreResolve(t *testing.T) {
37 + errTokenFailure := errors.New("token failure")
38 +
39 + tests := map[string]struct {
40 + operand string
41 + transport roundTripFunc
42 + getToken func(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error)
43 + wantValue string
44 + wantErrContains string
45 + wantErrIs error
46 + }{
47 + "key vault secret": {
48 + operand: "my-vault/my-secret",
49 + transport: func(req *http.Request) (*http.Response, error) {
50 + assert.Equal(t, "https", req.URL.Scheme)
51 + assert.Equal(t, "my-vault.vault.azure.net", req.URL.Host)
52 + assert.Equal(t, "/secrets/my-secret", req.URL.Path)
53 + assert.Equal(t, "Bearer test-token", req.Header.Get("Authorization"))
54 +
55 + return &http.Response{
56 + StatusCode: http.StatusOK,
57 + Body: io.NopCloser(strings.NewReader(`{"value":"secret-value"}`)),
58 + Header: make(http.Header),
59 + }, nil
60 + },
61 + getToken: func(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) {
62 + return azcore.AccessToken{
63 + Token: "test-token",
64 + ExpiresOn: time.Now().Add(30 * time.Minute),
65 + }, nil
66 + },
67 + wantValue: "secret-value",
68 + },
69 + "token acquisition failure": {
70 + operand: "my-vault/my-secret",
71 + getToken: func(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) {
72 + return azcore.AccessToken{}, errTokenFailure
73 + },
74 + wantErrContains: "acquiring Azure Key Vault access token",
75 + wantErrIs: errTokenFailure,
76 + },
77 + }
78 +
79 + for name, tc := range tests {
80 + t.Run(name, func(t *testing.T) {
81 + tokenProvider, err := cloudauth.NewTokenProvider(
82 + fakeTokenCredential{
83 + getToken: tc.getToken,
84 + },
85 + []string{azureKeyVaultScope},
86 + time.Minute,
87 + )
88 + require.NoError(t, err)
89 +
90 + s := &publishedStore{
91 + provider: &provider{
92 + apiClient: &http.Client{Transport: tc.transport},
93 + },
94 + tokenProvider: tokenProvider,
95 + }
96 +
97 + value, err := s.Resolve(context.Background(), secretstore.ResolveRequest{
98 + StoreKey: "azure-kv:azure_prod",
99 + Operand: tc.operand,
100 + Original: "${store:azure-kv:azure_prod:my-vault/my-secret}",
101 + })
102 + if tc.wantErrContains != "" {
103 + require.Error(t, err)
104 + assert.ErrorContains(t, err, tc.wantErrContains)
105 + assert.ErrorIs(t, err, tc.wantErrIs)
106 + assert.Empty(t, value)
107 + return
108 + }
109 +
110 + require.NoError(t, err)
111 + assert.Equal(t, tc.wantValue, value)
112 + })
113 + }
114 +}
src/go/plugin/agent/secrets/secretstore/provider_parity_test.go
+16 -5
@@ -49,23 +49,34 @@ func TestProviderSchemaAndValidationParity(t *testing.T) {
49 },
50 invalid: map[string]any{
51 "name": "azure_prod",
52 - "mode": "client",
53 - "mode_client": map[string]any{
52 + "mode": "service_principal",
53 + "mode_service_principal": map[string]any{
54 "client_id": "client-id",
55 },
56 },
57 - wantErrContains: "mode_client.tenant_id is required",
57 + wantErrContains: "mode_service_principal.tenant_id is required",
58 assertSchemaShape: func(t *testing.T, schema map[string]any) {
59 jsonSchema := schema["jsonSchema"].(map[string]any)
60 uiSchema := schema["uiSchema"].(map[string]any)
61 assert.Contains(t, jsonSchema["required"], "mode")
62 deps := jsonSchema["dependencies"].(map[string]any)
63 assert.Contains(t, deps, "mode")
64 - modeClient := uiSchema["mode_client"].(map[string]any)
65 - clientSecret := modeClient["client_secret"].(map[string]any)
64 + modeServicePrincipal := uiSchema["mode_service_principal"].(map[string]any)
65 + clientSecret := modeServicePrincipal["client_secret"].(map[string]any)
66 assert.Equal(t, "password", clientSecret["ui:widget"])
67 },
68 },
69 + "azure default requires mode": {
70 + kind: secretstore.KindAzureKV,
71 + valid: map[string]any{
72 + "name": "azure_default",
73 + "mode": "default",
74 + },
75 + invalid: map[string]any{
76 + "name": "azure_default",
77 + },
78 + wantErrContains: "mode is required",
79 + },
80 "gcp": {
81 kind: secretstore.KindGCPSM,
82 valid: map[string]any{
src/go/plugin/go.d/collector/mssql/config_schema.json
+83 -25
@@ -63,29 +63,81 @@
63 "default"
64 ],
65 "default": "default"
66 - },
67 - "tenant_id": {
68 - "title": "Tenant ID",
69 - "description": "Azure tenant ID. Required for service_principal mode.",
70 - "type": "string"
71 - },
72 - "client_id": {
73 - "title": "Client ID",
74 - "description": "Service principal client ID for service_principal mode.",
75 - "type": "string"
76 - },
77 - "client_secret": {
78 - "title": "Client Secret",
79 - "description": "Service principal client secret for service_principal mode.",
80 - "type": "string",
81 - "sensitive": true
82 - },
83 - "managed_identity_client_id": {
84 - "title": "Managed Identity Client ID",
85 - "description": "Optional client ID of a user-assigned managed identity.",
86 - "type": "string"
66 }
88 - }
67 + },
68 + "dependencies": {
69 + "mode": {
70 + "oneOf": [
71 + {
72 + "properties": {
73 + "mode": {
74 + "const": "service_principal"
75 + },
76 + "mode_service_principal": {
77 + "title": "Service Principal",
78 + "description": "Service principal settings used when mode is `service_principal`.",
79 + "type": "object",
80 + "properties": {
81 + "tenant_id": {
82 + "title": "Tenant ID",
83 + "description": "Azure tenant ID.",
84 + "type": "string"
85 + },
86 + "client_id": {
87 + "title": "Client ID",
88 + "description": "Service principal client ID.",
89 + "type": "string"
90 + },
91 + "client_secret": {
92 + "title": "Client Secret",
93 + "description": "Service principal client secret.",
94 + "type": "string",
95 + "sensitive": true
96 + }
97 + },
98 + "required": [
99 + "tenant_id",
100 + "client_id",
101 + "client_secret"
102 + ]
103 + }
104 + },
105 + "required": [
106 + "mode_service_principal"
107 + ]
108 + },
109 + {
110 + "properties": {
111 + "mode": {
112 + "const": "managed_identity"
113 + },
114 + "mode_managed_identity": {
115 + "title": "Managed Identity",
116 + "description": "Managed identity settings used when mode is `managed_identity`.",
117 + "type": "object",
118 + "properties": {
119 + "client_id": {
120 + "title": "Client ID",
121 + "description": "Optional client ID of a user-assigned managed identity.",
122 + "type": "string"
123 + }
124 + }
125 + }
126 + }
127 + },
128 + {
129 + "properties": {
130 + "mode": {
131 + "const": "default"
132 + }
133 + }
134 + }
135 + ]
136 + }
137 + },
138 + "required": [
139 + "mode"
140 + ]
141 }
142 },
143 "required": [
@@ -237,10 +289,16 @@
289 },
290 "azure_ad": {
291 "mode": {
240 - "ui:help": "Use `service_principal`, `managed_identity`, or `default` credential chain."
292 + "ui:widget": "radio",
293 + "ui:options": {
294 + "inline": true
295 + },
296 + "ui:help": "Choose how Netdata gets Azure credentials.\n\n- `service_principal`: Use an Azure app / service principal. Requires `tenant_id`, `client_id`, and `client_secret` in `mode_service_principal`.\n- `managed_identity`: Use the managed identity attached to the Azure resource running Netdata. Set `mode_managed_identity.client_id` only for a user-assigned identity.\n- `default`: Use the Azure SDK `DefaultAzureCredential` chain. This automatically tries available Azure credential sources, such as environment-based credentials, managed identity, and local developer credentials.\n\nUse `service_principal` for explicit app credentials. Use `managed_identity` when Netdata runs on an Azure resource with an attached identity. Use `default` when you want Azure SDK auto-discovery or local development convenience."
297 },
242 - "client_secret": {
243 - "ui:widget": "password"
298 + "mode_service_principal": {
299 + "client_secret": {
300 + "ui:widget": "password"
301 + }
302 }
303 }
304 },
src/go/plugin/go.d/collector/mssql/integrations/microsoft_sql_server.md
+9 -11
@@ -717,11 +717,11 @@ The following options can be defined globally: update_every, autodetection_retry
717 | | autodetection_retry | Autodetection retry interval (seconds). Set 0 to disable. | 0 | no |
718 | **Target** | dsn | SQL Server DSN (Data Source Name). See [DSN syntax](https://github.com/microsoft/go-mssqldb#connection-parameters-and-dsn). When `cloud_auth.provider` is `azure_ad`, use URL format with `sqlserver://` scheme. | sqlserver://localhost:1433 | yes |
719 | **Cloud Auth** | cloud_auth.provider | Cloud auth provider (`none` or `azure_ad`). | none | no |
720 -| **Cloud Auth/Azure** | cloud_auth.azure_ad.mode | Azure AD credential mode (`service_principal`, `managed_identity`, or `default`). | default | no |
721 -| | cloud_auth.azure_ad.tenant_id | Azure tenant ID. Required for `service_principal` mode. | | no |
722 -| | cloud_auth.azure_ad.client_id | Azure client ID. Required for `service_principal`; optional for user-assigned managed identity. | | no |
723 -| | cloud_auth.azure_ad.client_secret | Azure client secret for `service_principal` mode. | | no |
724 -| | cloud_auth.azure_ad.managed_identity_client_id | Optional client ID of a user-assigned managed identity (`managed_identity` mode). | | no |
720 +| **Cloud Auth/Azure** | cloud_auth.azure_ad.mode | Azure AD credential mode (`service_principal`, `managed_identity`, or `default`). Required when `cloud_auth.provider` is `azure_ad`. | | yes |
721 +| | cloud_auth.azure_ad.mode_service_principal.tenant_id | Azure tenant ID. Required for `service_principal` mode. | | no |
722 +| | cloud_auth.azure_ad.mode_service_principal.client_id | Azure client ID. Required for `service_principal` mode. | | no |
723 +| | cloud_auth.azure_ad.mode_service_principal.client_secret | Azure client secret for `service_principal` mode. | | no |
724 +| | cloud_auth.azure_ad.mode_managed_identity.client_id | Optional client ID of a user-assigned managed identity (`managed_identity` mode). | | no |
725 | **Target** | timeout | Query timeout (seconds). | 5 | no |
726 | **Functions** | functions.top_queries.disabled | Disable the [top-queries](#top-queries) function. | no | no |
727 | | functions.top_queries.timeout | Query timeout for top-queries function (seconds). Uses collector timeout if not set. | | no |
@@ -847,9 +847,10 @@ jobs:
847 provider: azure_ad
848 azure_ad:
849 mode: service_principal
850 - tenant_id: "00000000-0000-0000-0000-000000000000"
851 - client_id: "11111111-1111-1111-1111-111111111111"
852 - client_secret: "super-secret-value"
850 + mode_service_principal:
851 + tenant_id: "00000000-0000-0000-0000-000000000000"
852 + client_id: "11111111-1111-1111-1111-111111111111"
853 + client_secret: "super-secret-value"
854
855 ```
856 </details>
@@ -1003,6 +1004,3 @@ Ensure SQL Server is configured for mixed mode authentication if using SQL login
1004
1005 The monitoring user needs VIEW SERVER STATE permission.
1006 Grant it with: `GRANT VIEW SERVER STATE TO netdata_user;`
1006 -
1007 -
1008 -
src/go/plugin/go.d/collector/mssql/metadata.yaml
+11 -11
@@ -155,26 +155,25 @@ modules:
155 required: false
156 group: Cloud Auth
157 - name: cloud_auth.azure_ad.mode
158 - description: Azure AD credential mode (`service_principal`, `managed_identity`, or `default`).
159 - default_value: default
160 - required: false
158 + description: Azure AD credential mode (`service_principal`, `managed_identity`, or `default`). Required when `cloud_auth.provider` is `azure_ad`.
159 + required: true
160 group: Cloud Auth/Azure
162 - - name: cloud_auth.azure_ad.tenant_id
161 + - name: cloud_auth.azure_ad.mode_service_principal.tenant_id
162 description: Azure tenant ID. Required for `service_principal` mode.
163 default_value: ""
164 required: false
165 group: Cloud Auth/Azure
167 - - name: cloud_auth.azure_ad.client_id
168 - description: Azure client ID. Required for `service_principal`; optional for user-assigned managed identity.
166 + - name: cloud_auth.azure_ad.mode_service_principal.client_id
167 + description: Azure client ID. Required for `service_principal` mode.
168 default_value: ""
169 required: false
170 group: Cloud Auth/Azure
172 - - name: cloud_auth.azure_ad.client_secret
171 + - name: cloud_auth.azure_ad.mode_service_principal.client_secret
172 description: Azure client secret for `service_principal` mode.
173 default_value: ""
174 required: false
175 group: Cloud Auth/Azure
177 - - name: cloud_auth.azure_ad.managed_identity_client_id
176 + - name: cloud_auth.azure_ad.mode_managed_identity.client_id
177 description: Optional client ID of a user-assigned managed identity (`managed_identity` mode).
178 default_value: ""
179 required: false
@@ -289,9 +288,10 @@ modules:
288 provider: azure_ad
289 azure_ad:
290 mode: service_principal
292 - tenant_id: "00000000-0000-0000-0000-000000000000"
293 - client_id: "11111111-1111-1111-1111-111111111111"
294 - client_secret: "super-secret-value"
291 + mode_service_principal:
292 + tenant_id: "00000000-0000-0000-0000-000000000000"
293 + client_id: "11111111-1111-1111-1111-111111111111"
294 + client_secret: "super-secret-value"
295 - name: Azure SQL with managed identity
296 description: Use managed identity authentication (system-assigned by default).
297 config: |
src/go/plugin/go.d/collector/mssql/mssql_test.go
+5 -3
@@ -32,9 +32,11 @@ func TestCollector_Init_InvalidAzureADConfig(t *testing.T) {
32 c := New()
33 c.CloudAuth.Provider = cloudauth.ProviderAzureAD
34 c.CloudAuth.AzureAD = &cloudauth.AzureADAuthConfig{
35 - Mode: cloudauth.AzureADAuthModeServicePrincipal,
36 - ClientID: "client-id",
37 - TenantID: "tenant-id",
35 + Mode: cloudauth.AzureADAuthModeServicePrincipal,
36 + ModeServicePrincipal: &cloudauth.AzureADModeServicePrincipalConfig{
37 + ClientID: "client-id",
38 + TenantID: "tenant-id",
39 + },
40 }
41 // Missing client_secret.
42
src/go/plugin/go.d/collector/postgres/collector_test.go
+12 -8
@@ -121,10 +121,12 @@ func TestCollector_Init(t *testing.T) {
121 CloudAuth: cloudauth.Config{
122 Provider: cloudauth.ProviderAzureAD,
123 AzureAD: &cloudauth.AzureADAuthConfig{
124 - Mode: "service_principal",
125 - TenantID: "tenant-id",
126 - ClientID: "client-id",
127 - // Missing client_secret.
124 + Mode: "service_principal",
125 + ModeServicePrincipal: &cloudauth.AzureADModeServicePrincipalConfig{
126 + TenantID: "tenant-id",
127 + ClientID: "client-id",
128 + // Missing client_secret.
129 + },
130 },
131 },
132 },
@@ -150,10 +152,12 @@ func TestCollector_Init_AzureADInitializesTokenProvider(t *testing.T) {
152 c.CloudAuth = cloudauth.Config{
153 Provider: cloudauth.ProviderAzureAD,
154 AzureAD: &cloudauth.AzureADAuthConfig{
153 - Mode: cloudauth.AzureADAuthModeServicePrincipal,
154 - TenantID: "tenant-id",
155 - ClientID: "client-id",
156 - ClientSecret: "client-secret",
155 + Mode: cloudauth.AzureADAuthModeServicePrincipal,
156 + ModeServicePrincipal: &cloudauth.AzureADModeServicePrincipalConfig{
157 + TenantID: "tenant-id",
158 + ClientID: "client-id",
159 + ClientSecret: "client-secret",
160 + },
161 },
162 }
163
src/go/plugin/go.d/collector/postgres/config_schema.json
+83 -25
@@ -70,29 +70,81 @@
70 "default"
71 ],
72 "default": "default"
73 - },
74 - "tenant_id": {
75 - "title": "Tenant ID",
76 - "description": "Azure tenant ID. Required for service_principal mode.",
77 - "type": "string"
78 - },
79 - "client_id": {
80 - "title": "Client ID",
81 - "description": "Service principal client ID for service_principal mode.",
82 - "type": "string"
83 - },
84 - "client_secret": {
85 - "title": "Client Secret",
86 - "description": "Service principal client secret for service_principal mode.",
87 - "type": "string",
88 - "sensitive": true
89 - },
90 - "managed_identity_client_id": {
91 - "title": "Managed Identity Client ID",
92 - "description": "Optional client ID of a user-assigned managed identity.",
93 - "type": "string"
73 }
95 - }
74 + },
75 + "dependencies": {
76 + "mode": {
77 + "oneOf": [
78 + {
79 + "properties": {
80 + "mode": {
81 + "const": "service_principal"
82 + },
83 + "mode_service_principal": {
84 + "title": "Service Principal",
85 + "description": "Service principal settings used when mode is `service_principal`.",
86 + "type": "object",
87 + "properties": {
88 + "tenant_id": {
89 + "title": "Tenant ID",
90 + "description": "Azure tenant ID.",
91 + "type": "string"
92 + },
93 + "client_id": {
94 + "title": "Client ID",
95 + "description": "Service principal client ID.",
96 + "type": "string"
97 + },
98 + "client_secret": {
99 + "title": "Client Secret",
100 + "description": "Service principal client secret.",
101 + "type": "string",
102 + "sensitive": true
103 + }
104 + },
105 + "required": [
106 + "tenant_id",
107 + "client_id",
108 + "client_secret"
109 + ]
110 + }
111 + },
112 + "required": [
113 + "mode_service_principal"
114 + ]
115 + },
116 + {
117 + "properties": {
118 + "mode": {
119 + "const": "managed_identity"
120 + },
121 + "mode_managed_identity": {
122 + "title": "Managed Identity",
123 + "description": "Managed identity settings used when mode is `managed_identity`.",
124 + "type": "object",
125 + "properties": {
126 + "client_id": {
127 + "title": "Client ID",
128 + "description": "Optional client ID of a user-assigned managed identity.",
129 + "type": "string"
130 + }
131 + }
132 + }
133 + }
134 + },
135 + {
136 + "properties": {
137 + "mode": {
138 + "const": "default"
139 + }
140 + }
141 + }
142 + ]
143 + }
144 + },
145 + "required": [
146 + "mode"
147 + ]
148 }
149 },
150 "required": [
@@ -281,10 +333,16 @@
333 },
334 "azure_ad": {
335 "mode": {
284 - "ui:help": "Use `service_principal`, `managed_identity`, or `default` credential chain."
336 + "ui:widget": "radio",
337 + "ui:options": {
338 + "inline": true
339 + },
340 + "ui:help": "Choose how Netdata gets Azure credentials.\n\n- `service_principal`: Use an Azure app / service principal. Requires `tenant_id`, `client_id`, and `client_secret` in `mode_service_principal`.\n- `managed_identity`: Use the managed identity attached to the Azure resource running Netdata. Set `mode_managed_identity.client_id` only for a user-assigned identity.\n- `default`: Use the Azure SDK `DefaultAzureCredential` chain. This automatically tries available Azure credential sources, such as environment-based credentials, managed identity, and local developer credentials.\n\nUse `service_principal` for explicit app credentials. Use `managed_identity` when Netdata runs on an Azure resource with an attached identity. Use `default` when you want Azure SDK auto-discovery or local development convenience."
341 },
286 - "client_secret": {
287 - "ui:widget": "password"
342 + "mode_service_principal": {
343 + "client_secret": {
344 + "ui:widget": "password"
345 + }
346 }
347 }
348 },
src/go/plugin/go.d/collector/postgres/integrations/postgresql.md
+9 -10
@@ -530,11 +530,11 @@ The following options can be defined globally: update_every, autodetection_retry
530 | | autodetection_retry | Autodetection retry interval (seconds). Set 0 to disable. | 0 | no |
531 | **Target** | dsn | Postgres connection string (DSN). See [DSN syntax](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING). | postgres://postgres:postgres@127.0.0.1:5432/postgres | yes |
532 | **Cloud Auth** | cloud_auth.provider | Cloud auth provider (`none` or `azure_ad`). | none | no |
533 -| **Cloud Auth/Azure** | cloud_auth.azure_ad.mode | Azure AD credential mode (`service_principal`, `managed_identity`, or `default`). | default | no |
534 -| | cloud_auth.azure_ad.tenant_id | Azure tenant ID. Required for `service_principal` mode. | | no |
535 -| | cloud_auth.azure_ad.client_id | Azure client ID. Required for `service_principal`; optional for user-assigned managed identity. | | no |
536 -| | cloud_auth.azure_ad.client_secret | Azure client secret for `service_principal` mode. | | no |
537 -| | cloud_auth.azure_ad.managed_identity_client_id | Optional client ID of a user-assigned managed identity (`managed_identity` mode). | | no |
533 +| **Cloud Auth/Azure** | cloud_auth.azure_ad.mode | Azure AD credential mode (`service_principal`, `managed_identity`, or `default`). Required when `cloud_auth.provider` is `azure_ad`. | | yes |
534 +| | cloud_auth.azure_ad.mode_service_principal.tenant_id | Azure tenant ID. Required for `service_principal` mode. | | no |
535 +| | cloud_auth.azure_ad.mode_service_principal.client_id | Azure client ID. Required for `service_principal` mode. | | no |
536 +| | cloud_auth.azure_ad.mode_service_principal.client_secret | Azure client secret for `service_principal` mode. | | no |
537 +| | cloud_auth.azure_ad.mode_managed_identity.client_id | Optional client ID of a user-assigned managed identity (`managed_identity` mode). | | no |
538 | **Target** | timeout | Query timeout (seconds). | 2 | no |
539 | **Filters** | collect_databases_matching | Database selector. Controls which databases are included. Uses [simple patterns](https://github.com/netdata/netdata/tree/master/src/go/pkg/matcher#simple-patterns-matcher). | | no |
540 | **Limits** | max_db_tables | Maximum number of tables per database to collect metrics for (0 = no limit). | 50 | no |
@@ -637,9 +637,10 @@ jobs:
637 provider: azure_ad
638 azure_ad:
639 mode: service_principal
640 - tenant_id: "00000000-0000-0000-0000-000000000000"
641 - client_id: "11111111-1111-1111-1111-111111111111"
642 - client_secret: "super-secret-value"
640 + mode_service_principal:
641 + tenant_id: "00000000-0000-0000-0000-000000000000"
642 + client_id: "11111111-1111-1111-1111-111111111111"
643 + client_secret: "super-secret-value"
644
645 ```
646 </details>
@@ -750,5 +751,3 @@ If your Netdata runs in a Docker container named "netdata" (replace if different
751 ```bash
752 docker logs netdata 2>&1 | grep postgres
753 ```
753 -
754 -
src/go/plugin/go.d/collector/postgres/metadata.yaml
+11 -11
@@ -103,26 +103,25 @@ modules:
103 required: false
104 group: Cloud Auth
105 - name: cloud_auth.azure_ad.mode
106 - description: Azure AD credential mode (`service_principal`, `managed_identity`, or `default`).
107 - default_value: default
108 - required: false
106 + description: Azure AD credential mode (`service_principal`, `managed_identity`, or `default`). Required when `cloud_auth.provider` is `azure_ad`.
107 + required: true
108 group: Cloud Auth/Azure
110 - - name: cloud_auth.azure_ad.tenant_id
109 + - name: cloud_auth.azure_ad.mode_service_principal.tenant_id
110 description: Azure tenant ID. Required for `service_principal` mode.
111 default_value: ""
112 required: false
113 group: Cloud Auth/Azure
115 - - name: cloud_auth.azure_ad.client_id
116 - description: Azure client ID. Required for `service_principal`; optional for user-assigned managed identity.
114 + - name: cloud_auth.azure_ad.mode_service_principal.client_id
115 + description: Azure client ID. Required for `service_principal` mode.
116 default_value: ""
117 required: false
118 group: Cloud Auth/Azure
120 - - name: cloud_auth.azure_ad.client_secret
119 + - name: cloud_auth.azure_ad.mode_service_principal.client_secret
120 description: Azure client secret for `service_principal` mode.
121 default_value: ""
122 required: false
123 group: Cloud Auth/Azure
125 - - name: cloud_auth.azure_ad.managed_identity_client_id
124 + - name: cloud_auth.azure_ad.mode_managed_identity.client_id
125 description: Optional client ID of a user-assigned managed identity (`managed_identity` mode).
126 default_value: ""
127 required: false
@@ -206,9 +205,10 @@ modules:
205 provider: azure_ad
206 azure_ad:
207 mode: service_principal
209 - tenant_id: "00000000-0000-0000-0000-000000000000"
210 - client_id: "11111111-1111-1111-1111-111111111111"
211 - client_secret: "super-secret-value"
208 + mode_service_principal:
209 + tenant_id: "00000000-0000-0000-0000-000000000000"
210 + client_id: "11111111-1111-1111-1111-111111111111"
211 + client_secret: "super-secret-value"
212 - name: Azure Database for PostgreSQL with managed identity
213 description: Use managed identity authentication (system-assigned by default).
214 config: |
src/go/plugin/go.d/collector/sql/collector_test.go
+5 -3
@@ -164,9 +164,11 @@ func TestCollector_Init_ConfigValidation(t *testing.T) {
164 c.Functions = []ConfigFunction{{ID: "test", Query: "SELECT 1"}}
165 c.CloudAuth.Provider = "azure_ad"
166 c.CloudAuth.AzureAD = &cloudauth.AzureADAuthConfig{
167 - Mode: "service_principal",
168 - TenantID: "tenant",
169 - ClientID: "client",
167 + Mode: "service_principal",
168 + ModeServicePrincipal: &cloudauth.AzureADModeServicePrincipalConfig{
169 + TenantID: "tenant",
170 + ClientID: "client",
171 + },
172 }
173 },
174 wantFail: true,
src/go/plugin/go.d/collector/sql/config_schema.json
+83 -25
@@ -89,29 +89,81 @@
89 "default"
90 ],
91 "default": "default"
92 - },
93 - "tenant_id": {
94 - "title": "Tenant ID",
95 - "description": "Azure tenant ID. Required for service_principal mode.",
96 - "type": "string"
97 - },
98 - "client_id": {
99 - "title": "Client ID",
100 - "description": "Service principal client ID for service_principal mode.",
101 - "type": "string"
102 - },
103 - "client_secret": {
104 - "title": "Client Secret",
105 - "description": "Service principal client secret for service_principal mode.",
106 - "type": "string",
107 - "sensitive": true
108 - },
109 - "managed_identity_client_id": {
110 - "title": "Managed Identity Client ID",
111 - "description": "Optional client ID of a user-assigned managed identity.",
112 - "type": "string"
92 }
114 - }
93 + },
94 + "dependencies": {
95 + "mode": {
96 + "oneOf": [
97 + {
98 + "properties": {
99 + "mode": {
100 + "const": "service_principal"
101 + },
102 + "mode_service_principal": {
103 + "title": "Service Principal",
104 + "description": "Service principal settings used when mode is `service_principal`.",
105 + "type": "object",
106 + "properties": {
107 + "tenant_id": {
108 + "title": "Tenant ID",
109 + "description": "Azure tenant ID.",
110 + "type": "string"
111 + },
112 + "client_id": {
113 + "title": "Client ID",
114 + "description": "Service principal client ID.",
115 + "type": "string"
116 + },
117 + "client_secret": {
118 + "title": "Client Secret",
119 + "description": "Service principal client secret.",
120 + "type": "string",
121 + "sensitive": true
122 + }
123 + },
124 + "required": [
125 + "tenant_id",
126 + "client_id",
127 + "client_secret"
128 + ]
129 + }
130 + },
131 + "required": [
132 + "mode_service_principal"
133 + ]
134 + },
135 + {
136 + "properties": {
137 + "mode": {
138 + "const": "managed_identity"
139 + },
140 + "mode_managed_identity": {
141 + "title": "Managed Identity",
142 + "description": "Managed identity settings used when mode is `managed_identity`.",
143 + "type": "object",
144 + "properties": {
145 + "client_id": {
146 + "title": "Client ID",
147 + "description": "Optional client ID of a user-assigned managed identity.",
148 + "type": "string"
149 + }
150 + }
151 + }
152 + }
153 + },
154 + {
155 + "properties": {
156 + "mode": {
157 + "const": "default"
158 + }
159 + }
160 + }
161 + ]
162 + }
163 + },
164 + "required": [
165 + "mode"
166 + ]
167 }
168 },
169 "required": [
@@ -554,10 +606,16 @@
606 },
607 "azure_ad": {
608 "mode": {
557 - "ui:help": "Use `service_principal`, `managed_identity`, or `default` credential chain."
609 + "ui:widget": "radio",
610 + "ui:options": {
611 + "inline": true
612 + },
613 + "ui:help": "Choose how Netdata gets Azure credentials.\n\n- `service_principal`: Use an Azure app / service principal. Requires `tenant_id`, `client_id`, and `client_secret` in `mode_service_principal`.\n- `managed_identity`: Use the managed identity attached to the Azure resource running Netdata. Set `mode_managed_identity.client_id` only for a user-assigned identity.\n- `default`: Use the Azure SDK `DefaultAzureCredential` chain. This automatically tries available Azure credential sources, such as environment-based credentials, managed identity, and local developer credentials.\n\nUse `service_principal` for explicit app credentials. Use `managed_identity` when Netdata runs on an Azure resource with an attached identity. Use `default` when you want Azure SDK auto-discovery or local development convenience."
614 },
559 - "client_secret": {
560 - "ui:widget": "password"
615 + "mode_service_principal": {
616 + "client_secret": {
617 + "ui:widget": "password"
618 + }
619 }
620 }
621 },
src/go/plugin/go.d/collector/sql/integrations/sql_databases_generic.md
+15 -14
@@ -201,10 +201,12 @@ cloud_auth: # OPTIONAL. Cloud auth for pgx/sqls
201 provider: <none|azure_ad> # OPTIONAL. Default: none.
202 azure_ad:
203 mode: <service_principal|managed_identity|default>
204 - tenant_id: "<tenant-id>" # REQUIRED for service_principal
205 - client_id: "<client-id>" # REQUIRED for service_principal
206 - client_secret: "<client-secret>" # REQUIRED for service_principal
207 - managed_identity_client_id: "<client-id>" # Optional for user-assigned MI
204 + mode_service_principal: # REQUIRED for service_principal
205 + tenant_id: "<tenant-id>"
206 + client_id: "<client-id>"
207 + client_secret: "<client-secret>"
208 + mode_managed_identity: # OPTIONAL for managed_identity
209 + client_id: "<client-id>" # Optional for user-assigned MI
210
211 # Optional static labels applied to all charts
212 static_labels:
@@ -312,11 +314,11 @@ functions:
314 | **Target** | driver | SQL driver to use. Supported values: `mysql`, `pgx`, `oracle`, `sqlserver`, `azuresql`. | mysql | yes |
315 | | dsn | Database connection string (DSN). The format depends on the selected driver ( [MySQL](https://github.com/go-sql-driver/mysql#dsn-data-source-name), [PostgreSQL](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING-URIS), [MS SQL Server](https://github.com/microsoft/go-mssqldb#connection-parameters-and-dsn)). | | yes |
316 | **Cloud Auth** | cloud_auth.provider | Cloud auth provider (`none` or `azure_ad`). Supported for `pgx`, `sqlserver`, and `azuresql`. | none | no |
315 -| **Cloud Auth/Azure** | cloud_auth.azure_ad.mode | Azure AD credential mode (`service_principal`, `managed_identity`, or `default`). | default | no |
316 -| | cloud_auth.azure_ad.tenant_id | Azure tenant ID. Required for `service_principal` mode. | | no |
317 -| | cloud_auth.azure_ad.client_id | Azure client ID. Required for `service_principal`; optional for user-assigned managed identity. | | no |
318 -| | cloud_auth.azure_ad.client_secret | Azure client secret for `service_principal` mode. | | no |
319 -| | cloud_auth.azure_ad.managed_identity_client_id | Optional client ID of a user-assigned managed identity (`managed_identity` mode). | | no |
317 +| **Cloud Auth/Azure** | cloud_auth.azure_ad.mode | Azure AD credential mode (`service_principal`, `managed_identity`, or `default`). Required when `cloud_auth.provider` is `azure_ad`. | | yes |
318 +| | cloud_auth.azure_ad.mode_service_principal.tenant_id | Azure tenant ID. Required for `service_principal` mode. | | no |
319 +| | cloud_auth.azure_ad.mode_service_principal.client_id | Azure client ID. Required for `service_principal` mode. | | no |
320 +| | cloud_auth.azure_ad.mode_service_principal.client_secret | Azure client secret for `service_principal` mode. | | no |
321 +| | cloud_auth.azure_ad.mode_managed_identity.client_id | Optional client ID of a user-assigned managed identity (`managed_identity` mode). | | no |
322 | **Connection** | timeout | Query and connection check timeout (seconds). | 5 | no |
323 | **Labels** | static_labels | A map of static labels added to every chart created by this job. Useful for tagging charts with environment, region, or role. | {} | no |
324 | **Queries & Metrics** | queries | A list of reusable queries. Metric blocks can reference these via `query_ref` to avoid repeating SQL. See [Configuration Structure](#configuration) for details. | [] | no |
@@ -392,9 +394,10 @@ jobs:
394 provider: azure_ad
395 azure_ad:
396 mode: service_principal
395 - tenant_id: "00000000-0000-0000-0000-000000000000"
396 - client_id: "11111111-1111-1111-1111-111111111111"
397 - client_secret: "super-secret-value"
397 + mode_service_principal:
398 + tenant_id: "00000000-0000-0000-0000-000000000000"
399 + client_id: "11111111-1111-1111-1111-111111111111"
400 + client_secret: "super-secret-value"
401 metrics:
402 - id: user_connections
403 mode: columns
@@ -894,5 +897,3 @@ If your Netdata runs in a Docker container named "netdata" (replace if different
897 ```bash
898 docker logs netdata 2>&1 | grep sql
899 ```
897 -
898 -
src/go/plugin/go.d/collector/sql/metadata.yaml
+17 -15
@@ -144,10 +144,12 @@ modules:
144 provider: <none|azure_ad> # OPTIONAL. Default: none.
145 azure_ad:
146 mode: <service_principal|managed_identity|default>
147 - tenant_id: "<tenant-id>" # REQUIRED for service_principal
148 - client_id: "<client-id>" # REQUIRED for service_principal
149 - client_secret: "<client-secret>" # REQUIRED for service_principal
150 - managed_identity_client_id: "<client-id>" # Optional for user-assigned MI
147 + mode_service_principal: # REQUIRED for service_principal
148 + tenant_id: "<tenant-id>"
149 + client_id: "<client-id>"
150 + client_secret: "<client-secret>"
151 + mode_managed_identity: # OPTIONAL for managed_identity
152 + client_id: "<client-id>" # Optional for user-assigned MI
153
154 # Optional static labels applied to all charts
155 static_labels:
@@ -278,26 +280,25 @@ modules:
280 required: false
281 group: Cloud Auth
282 - name: cloud_auth.azure_ad.mode
281 - description: Azure AD credential mode (`service_principal`, `managed_identity`, or `default`).
282 - default_value: default
283 - required: false
283 + description: Azure AD credential mode (`service_principal`, `managed_identity`, or `default`). Required when `cloud_auth.provider` is `azure_ad`.
284 + required: true
285 group: Cloud Auth/Azure
285 - - name: cloud_auth.azure_ad.tenant_id
286 + - name: cloud_auth.azure_ad.mode_service_principal.tenant_id
287 description: Azure tenant ID. Required for `service_principal` mode.
288 default_value: ""
289 required: false
290 group: Cloud Auth/Azure
290 - - name: cloud_auth.azure_ad.client_id
291 - description: Azure client ID. Required for `service_principal`; optional for user-assigned managed identity.
291 + - name: cloud_auth.azure_ad.mode_service_principal.client_id
292 + description: Azure client ID. Required for `service_principal` mode.
293 default_value: ""
294 required: false
295 group: Cloud Auth/Azure
295 - - name: cloud_auth.azure_ad.client_secret
296 + - name: cloud_auth.azure_ad.mode_service_principal.client_secret
297 description: Azure client secret for `service_principal` mode.
298 default_value: ""
299 required: false
300 group: Cloud Auth/Azure
300 - - name: cloud_auth.azure_ad.managed_identity_client_id
301 + - name: cloud_auth.azure_ad.mode_managed_identity.client_id
302 description: Optional client ID of a user-assigned managed identity (`managed_identity` mode).
303 default_value: ""
304 required: false
@@ -416,9 +417,10 @@ modules:
417 provider: azure_ad
418 azure_ad:
419 mode: service_principal
419 - tenant_id: "00000000-0000-0000-0000-000000000000"
420 - client_id: "11111111-1111-1111-1111-111111111111"
421 - client_secret: "super-secret-value"
420 + mode_service_principal:
421 + tenant_id: "00000000-0000-0000-0000-000000000000"
422 + client_id: "11111111-1111-1111-1111-111111111111"
423 + client_secret: "super-secret-value"
424 metrics:
425 - id: user_connections
426 mode: columns
src/go/plugin/go.d/config/go.d/ss/azure-kv.conf
+2 -2
@@ -3,8 +3,8 @@
3
4 #jobs:
5 # - name: azure_prod
6 -# mode: client
7 -# mode_client:
6 +# mode: service_principal
7 +# mode_service_principal:
8 # tenant_id: 00000000-0000-0000-0000-000000000000
9 # client_id: 00000000-0000-0000-0000-000000000000
10 # client_secret: your-client-secret
src/go/plugin/go.d/pkg/cloudauth/azuread_auth_config.go
+100 -31
@@ -15,43 +15,68 @@ const (
15 AzureADAuthModeServicePrincipal = "service_principal"
16 AzureADAuthModeManagedIdentity = "managed_identity"
17 AzureADAuthModeDefault = "default"
18 +
19 + azureADAuthConfigPath = "cloud_auth.azure_ad"
20 )
21
22 +type AzureADModeServicePrincipalConfig struct {
23 + TenantID string `yaml:"tenant_id,omitempty" json:"tenant_id,omitempty"`
24 + ClientID string `yaml:"client_id,omitempty" json:"client_id,omitempty"`
25 + ClientSecret string `yaml:"client_secret,omitempty" json:"client_secret,omitempty"`
26 +}
27 +
28 +type AzureADModeManagedIdentityConfig struct {
29 + ClientID string `yaml:"client_id,omitempty" json:"client_id,omitempty"`
30 +}
31 +
32 type AzureADAuthConfig struct {
21 - Mode string `yaml:"mode,omitempty" json:"mode,omitempty"`
22 - TenantID string `yaml:"tenant_id,omitempty" json:"tenant_id,omitempty"`
23 - ClientID string `yaml:"client_id,omitempty" json:"client_id,omitempty"`
24 - ClientSecret string `yaml:"client_secret,omitempty" json:"client_secret,omitempty"`
25 - ManagedIdentityClientID string `yaml:"managed_identity_client_id,omitempty" json:"managed_identity_client_id,omitempty"`
33 + Mode string `yaml:"mode,omitempty" json:"mode,omitempty"`
34 + ModeServicePrincipal *AzureADModeServicePrincipalConfig `yaml:"mode_service_principal,omitempty" json:"mode_service_principal,omitempty"`
35 + ModeManagedIdentity *AzureADModeManagedIdentityConfig `yaml:"mode_managed_identity,omitempty" json:"mode_managed_identity,omitempty"`
36 +}
37 +
38 +type AzureADCredentialOptions struct {
39 + ClientOptions azcore.ClientOptions
40 }
41
42 func (c AzureADAuthConfig) NormalizedMode() string {
29 - mode := strings.TrimSpace(c.Mode)
30 - if mode == "" {
31 - return AzureADAuthModeDefault
32 - }
33 - return strings.ToLower(mode)
43 + return strings.ToLower(strings.TrimSpace(c.Mode))
44 }
45
46 func (c AzureADAuthConfig) Validate() error {
37 - switch c.NormalizedMode() {
47 + return c.ValidateWithPath(azureADAuthConfigPath)
48 +}
49 +
50 +func (c AzureADAuthConfig) ValidateWithPath(path string) error {
51 + modeField := fieldPath(path, "mode")
52 + mode := c.NormalizedMode()
53 +
54 + if mode == "" {
55 + return errors.New(modeField + " is required")
56 + }
57 +
58 + switch mode {
59 case AzureADAuthModeServicePrincipal:
60 var errs []error
40 - if strings.TrimSpace(c.TenantID) == "" {
41 - errs = append(errs, errors.New("cloud_auth.azure_ad.tenant_id is required for service_principal mode"))
61 + if c.ModeServicePrincipal == nil {
62 + return fmt.Errorf("%s is required when %s is %q", fieldPath(path, "mode_service_principal"), modeField, AzureADAuthModeServicePrincipal)
63 + }
64 +
65 + if strings.TrimSpace(c.ModeServicePrincipal.TenantID) == "" {
66 + errs = append(errs, errors.New(fieldPath(path, "mode_service_principal.tenant_id")+" is required"))
67 }
43 - if strings.TrimSpace(c.ClientID) == "" {
44 - errs = append(errs, errors.New("cloud_auth.azure_ad.client_id is required for service_principal mode"))
68 + if strings.TrimSpace(c.ModeServicePrincipal.ClientID) == "" {
69 + errs = append(errs, errors.New(fieldPath(path, "mode_service_principal.client_id")+" is required"))
70 }
46 - if strings.TrimSpace(c.ClientSecret) == "" {
47 - errs = append(errs, errors.New("cloud_auth.azure_ad.client_secret is required for service_principal mode"))
71 + if strings.TrimSpace(c.ModeServicePrincipal.ClientSecret) == "" {
72 + errs = append(errs, errors.New(fieldPath(path, "mode_service_principal.client_secret")+" is required"))
73 }
74 return errors.Join(errs...)
75 case AzureADAuthModeManagedIdentity, AzureADAuthModeDefault:
76 return nil
77 default:
53 - return fmt.Errorf("cloud_auth.azure_ad.mode %q is invalid: expected one of %q, %q, %q",
54 - c.Mode, AzureADAuthModeServicePrincipal, AzureADAuthModeManagedIdentity, AzureADAuthModeDefault)
78 + return fmt.Errorf("%s %q is invalid: expected one of %q, %q, %q",
79 + modeField, c.Mode, AzureADAuthModeServicePrincipal, AzureADAuthModeManagedIdentity, AzureADAuthModeDefault)
80 }
81 }
82
@@ -60,25 +85,69 @@ func (c AzureADAuthConfig) NewCredential() (azcore.TokenCredential, error) {
85 return nil, err
86 }
87
88 + return c.newCredential(nil)
89 +}
90 +
91 +func (c AzureADAuthConfig) NewCredentialWithOptions(opts *AzureADCredentialOptions) (azcore.TokenCredential, error) {
92 + if err := c.Validate(); err != nil {
93 + return nil, err
94 + }
95 +
96 + return c.newCredential(opts)
97 +}
98 +
99 +func (c AzureADAuthConfig) newCredential(opts *AzureADCredentialOptions) (azcore.TokenCredential, error) {
100 switch c.NormalizedMode() {
101 case AzureADAuthModeServicePrincipal:
102 + cfg := c.servicePrincipalConfig()
103 + credOpts := &azidentity.ClientSecretCredentialOptions{}
104 + if opts != nil {
105 + credOpts.ClientOptions = opts.ClientOptions
106 + }
107 return azidentity.NewClientSecretCredential(
66 - strings.TrimSpace(c.TenantID),
67 - strings.TrimSpace(c.ClientID),
68 - strings.TrimSpace(c.ClientSecret),
69 - nil,
108 + strings.TrimSpace(cfg.TenantID),
109 + strings.TrimSpace(cfg.ClientID),
110 + strings.TrimSpace(cfg.ClientSecret),
111 + credOpts,
112 )
113 case AzureADAuthModeManagedIdentity:
72 - if strings.TrimSpace(c.ManagedIdentityClientID) != "" {
73 - opts := &azidentity.ManagedIdentityCredentialOptions{
74 - ID: azidentity.ClientID(strings.TrimSpace(c.ManagedIdentityClientID)),
75 - }
76 - return azidentity.NewManagedIdentityCredential(opts)
114 + cfg := c.managedIdentityConfig()
115 + credOpts := &azidentity.ManagedIdentityCredentialOptions{}
116 + if opts != nil {
117 + credOpts.ClientOptions = opts.ClientOptions
118 }
78 - return azidentity.NewManagedIdentityCredential(nil)
119 + if strings.TrimSpace(cfg.ClientID) != "" {
120 + credOpts.ID = azidentity.ClientID(strings.TrimSpace(cfg.ClientID))
121 + }
122 + return azidentity.NewManagedIdentityCredential(credOpts)
123 case AzureADAuthModeDefault:
80 - return azidentity.NewDefaultAzureCredential(nil)
124 + credOpts := &azidentity.DefaultAzureCredentialOptions{}
125 + if opts != nil {
126 + credOpts.ClientOptions = opts.ClientOptions
127 + }
128 + return azidentity.NewDefaultAzureCredential(credOpts)
129 default:
82 - return nil, fmt.Errorf("cloud_auth.azure_ad.mode %q is invalid", c.Mode)
130 + return nil, fmt.Errorf("%s %q is invalid", fieldPath(azureADAuthConfigPath, "mode"), c.Mode)
131 + }
132 +}
133 +
134 +func (c AzureADAuthConfig) servicePrincipalConfig() AzureADModeServicePrincipalConfig {
135 + if c.ModeServicePrincipal == nil {
136 + return AzureADModeServicePrincipalConfig{}
137 + }
138 + return *c.ModeServicePrincipal
139 +}
140 +
141 +func (c AzureADAuthConfig) managedIdentityConfig() AzureADModeManagedIdentityConfig {
142 + if c.ModeManagedIdentity == nil {
143 + return AzureADModeManagedIdentityConfig{}
144 + }
145 + return *c.ModeManagedIdentity
146 +}
147 +
148 +func fieldPath(path, field string) string {
149 + if path == "" {
150 + return field
151 }
152 + return path + "." + field
153 }
src/go/plugin/go.d/pkg/cloudauth/azuread_auth_config_test.go
+99 -9
@@ -5,6 +5,7 @@ package cloudauth
5 import (
6 "testing"
7
8 + "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"
10 )
11
@@ -16,25 +17,30 @@ func TestAzureADAuthConfigValidate(t *testing.T) {
17 "default mode": {
18 cfg: AzureADAuthConfig{Mode: AzureADAuthModeDefault},
19 },
19 - "empty mode defaults to default": {
20 - cfg: AzureADAuthConfig{},
20 + "empty mode": {
21 + cfg: AzureADAuthConfig{},
22 + wantErr: true,
23 },
24 "managed identity mode": {
25 cfg: AzureADAuthConfig{Mode: AzureADAuthModeManagedIdentity},
26 },
27 "service principal mode": {
28 cfg: AzureADAuthConfig{
27 - Mode: AzureADAuthModeServicePrincipal,
28 - TenantID: "tenant",
29 - ClientID: "client",
30 - ClientSecret: "secret",
29 + Mode: AzureADAuthModeServicePrincipal,
30 + ModeServicePrincipal: &AzureADModeServicePrincipalConfig{
31 + TenantID: "tenant",
32 + ClientID: "client",
33 + ClientSecret: "secret",
34 + },
35 },
36 },
37 "service principal missing secret": {
38 cfg: AzureADAuthConfig{
35 - Mode: AzureADAuthModeServicePrincipal,
36 - TenantID: "tenant",
37 - ClientID: "client",
39 + Mode: AzureADAuthModeServicePrincipal,
40 + ModeServicePrincipal: &AzureADModeServicePrincipalConfig{
41 + TenantID: "tenant",
42 + ClientID: "client",
43 + },
44 },
45 wantErr: true,
46 },
@@ -55,3 +61,87 @@ func TestAzureADAuthConfigValidate(t *testing.T) {
61 })
62 }
63 }
64 +
65 +func TestAzureADAuthConfigValidateWithPath(t *testing.T) {
66 + tests := map[string]struct {
67 + cfg AzureADAuthConfig
68 + validatePath string
69 + wantErrString string
70 + }{
71 + "cloud auth path": {
72 + cfg: AzureADAuthConfig{
73 + Mode: AzureADAuthModeServicePrincipal,
74 + ModeServicePrincipal: &AzureADModeServicePrincipalConfig{
75 + TenantID: "tenant",
76 + ClientID: "client",
77 + },
78 + },
79 + validatePath: azureADAuthConfigPath,
80 + wantErrString: "cloud_auth.azure_ad.mode_service_principal.client_secret is required",
81 + },
82 + "root path": {
83 + cfg: AzureADAuthConfig{
84 + Mode: AzureADAuthModeServicePrincipal,
85 + ModeServicePrincipal: &AzureADModeServicePrincipalConfig{
86 + TenantID: "tenant",
87 + ClientID: "client",
88 + },
89 + },
90 + validatePath: "",
91 + wantErrString: "mode_service_principal.client_secret is required",
92 + },
93 + "missing mode": {
94 + cfg: AzureADAuthConfig{},
95 + validatePath: azureADAuthConfigPath,
96 + wantErrString: "cloud_auth.azure_ad.mode is required",
97 + },
98 + }
99 +
100 + for name, tc := range tests {
101 + t.Run(name, func(t *testing.T) {
102 + err := tc.cfg.ValidateWithPath(tc.validatePath)
103 + require.Error(t, err)
104 + assert.ErrorContains(t, err, tc.wantErrString)
105 + })
106 + }
107 +}
108 +
109 +func TestAzureADAuthConfigNewCredentialWithOptions(t *testing.T) {
110 + tests := map[string]struct {
111 + cfg AzureADAuthConfig
112 + wantErr bool
113 + wantErrString string
114 + }{
115 + "valid default": {
116 + cfg: AzureADAuthConfig{
117 + Mode: AzureADAuthModeDefault,
118 + },
119 + },
120 + "invalid service principal": {
121 + cfg: AzureADAuthConfig{
122 + Mode: AzureADAuthModeServicePrincipal,
123 + ModeServicePrincipal: &AzureADModeServicePrincipalConfig{
124 + TenantID: "tenant",
125 + ClientID: "client",
126 + },
127 + },
128 + wantErr: true,
129 + wantErrString: "cloud_auth.azure_ad.mode_service_principal.client_secret is required",
130 + },
131 + }
132 +
133 + for name, tc := range tests {
134 + t.Run(name, func(t *testing.T) {
135 + cred, err := tc.cfg.NewCredentialWithOptions(nil)
136 + if tc.wantErr {
137 + require.Error(t, err)
138 + assert.Nil(t, cred)
139 + assert.ErrorContains(t, err, tc.wantErrString)
140 + return
141 + }
142 +
143 + require.NoError(t, err)
144 + require.NotNil(t, cred)
145 + })
146 + }
147 +}
src/go/plugin/go.d/pkg/cloudauth/config.go
+4 -1
@@ -31,7 +31,10 @@ func (c Config) Validate() error {
31 case ProviderNone:
32 return nil
33 case ProviderAzureAD:
34 - return c.azureADConfig().Validate()
34 + if c.AzureAD == nil {
35 + return errors.New("cloud_auth.azure_ad is required")
36 + }
37 + return c.AzureAD.Validate()
38 default:
39 return fmt.Errorf("cloud_auth.provider %q is invalid: expected one of %q, %q",
40 c.Provider, ProviderNone, ProviderAzureAD)
src/go/plugin/go.d/pkg/cloudauth/config_test.go
+18 -8
@@ -29,7 +29,7 @@ func TestConfigValidate(t *testing.T) {
29 cfg: Config{
30 Provider: ProviderNone,
31 AzureAD: &AzureADAuthConfig{
32 - Mode: "service_principal",
32 + Mode: AzureADAuthModeServicePrincipal,
33 },
34 },
35 },
@@ -37,20 +37,30 @@ func TestConfigValidate(t *testing.T) {
37 cfg: Config{
38 Provider: ProviderAzureAD,
39 AzureAD: &AzureADAuthConfig{
40 - Mode: AzureADAuthModeServicePrincipal,
41 - TenantID: "tenant",
42 - ClientID: "client",
43 - ClientSecret: "secret",
40 + Mode: AzureADAuthModeServicePrincipal,
41 + ModeServicePrincipal: &AzureADModeServicePrincipalConfig{
42 + TenantID: "tenant",
43 + ClientID: "client",
44 + ClientSecret: "secret",
45 + },
46 },
47 },
48 },
49 + "provider azure_ad missing block": {
50 + cfg: Config{
51 + Provider: ProviderAzureAD,
52 + },
53 + wantErr: true,
54 + },
55 "provider azure_ad invalid": {
56 cfg: Config{
57 Provider: ProviderAzureAD,
58 AzureAD: &AzureADAuthConfig{
51 - Mode: AzureADAuthModeServicePrincipal,
52 - TenantID: "tenant",
53 - ClientID: "client",
59 + Mode: AzureADAuthModeServicePrincipal,
60 + ModeServicePrincipal: &AzureADModeServicePrincipalConfig{
61 + TenantID: "tenant",
62 + ClientID: "client",
63 + },
64 },
65 },
66 wantErr: true,
src/go/plugin/go.d/pkg/cloudauth/sqladapter/mssql.go
+10 -4
@@ -57,18 +57,24 @@ func BuildMSSQLAzureADDSN(baseDSN string, cfg cloudauth.Config) (string, error)
57
58 switch aadCfg.NormalizedMode() {
59 case cloudauth.AzureADAuthModeServicePrincipal:
60 + sp := aadCfg.ModeServicePrincipal
61 + if sp == nil {
62 + return "", fmt.Errorf("unsupported cloud_auth.azure_ad.mode %q", aadCfg.Mode)
63 + }
64 q.Set("fedauth", mssqlFedAuthServicePrincipal)
61 - clientID := strings.TrimSpace(aadCfg.ClientID)
62 - clientSecret := strings.TrimSpace(aadCfg.ClientSecret)
65 + clientID := strings.TrimSpace(sp.ClientID)
66 + clientSecret := strings.TrimSpace(sp.ClientSecret)
67 userID := clientID
64 - if tenantID := strings.TrimSpace(aadCfg.TenantID); tenantID != "" {
68 + if tenantID := strings.TrimSpace(sp.TenantID); tenantID != "" {
69 userID = userID + "@" + tenantID
70 }
71 u.User = url.UserPassword(userID, clientSecret)
72 case cloudauth.AzureADAuthModeManagedIdentity:
73 + mi := aadCfg.ModeManagedIdentity
74 q.Set("fedauth", mssqlFedAuthManagedIdentity)
75 u.User = nil
71 - if id := strings.TrimSpace(aadCfg.ManagedIdentityClientID); id != "" {
76 + if mi != nil && strings.TrimSpace(mi.ClientID) != "" {
77 + id := strings.TrimSpace(mi.ClientID)
78 q.Set("user id", id)
79 }
80 case cloudauth.AzureADAuthModeDefault:
src/go/plugin/go.d/pkg/cloudauth/sqladapter/mssql_test.go
+10 -6
@@ -39,10 +39,12 @@ func TestBuildMSSQLAzureADDSN(t *testing.T) {
39 cfg: cloudauth.Config{
40 Provider: cloudauth.ProviderAzureAD,
41 AzureAD: &cloudauth.AzureADAuthConfig{
42 - Mode: cloudauth.AzureADAuthModeServicePrincipal,
43 - TenantID: "tenant",
44 - ClientID: "client",
45 - ClientSecret: "secret",
42 + Mode: cloudauth.AzureADAuthModeServicePrincipal,
43 + ModeServicePrincipal: &cloudauth.AzureADModeServicePrincipalConfig{
44 + TenantID: "tenant",
45 + ClientID: "client",
46 + ClientSecret: "secret",
47 + },
48 },
49 },
50 parseDSN: true,
@@ -59,8 +61,10 @@ func TestBuildMSSQLAzureADDSN(t *testing.T) {
61 cfg: cloudauth.Config{
62 Provider: cloudauth.ProviderAzureAD,
63 AzureAD: &cloudauth.AzureADAuthConfig{
62 - Mode: cloudauth.AzureADAuthModeManagedIdentity,
63 - ManagedIdentityClientID: "mi-client-id",
64 + Mode: cloudauth.AzureADAuthModeManagedIdentity,
65 + ModeManagedIdentity: &cloudauth.AzureADModeManagedIdentityConfig{
66 + ClientID: "mi-client-id",
67 + },
68 },
69 },
70 parseDSN: true,