Skip to content

Commit 7b8f079

Browse files
authored
Merge pull request #1605 from mattiasthalen/feature/add-support-for-azure-keyvault
Add Azure Key Vault support as secrets backend
2 parents 539245b + d09b257 commit 7b8f079

File tree

8 files changed

+730
-5
lines changed

8 files changed

+730
-5
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,5 @@ test-glossary.yml
162162
.venv
163163
logs/queries
164164
*.py[cod]
165+
166+
.devcontainer

cmd/run.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -587,7 +587,7 @@ func Run(isDebug *bool) *cli.Command {
587587
&cli.StringFlag{
588588
Name: "secrets-backend",
589589
Sources: cli.EnvVars("BRUIN_SECRETS_BACKEND"),
590-
Usage: "the source of secrets if different from .bruin.yml. Possible values: 'vault', 'doppler', 'aws'",
590+
Usage: "the source of secrets if different from .bruin.yml. Possible values: 'vault', 'doppler', 'aws', 'azure'",
591591
},
592592
&cli.BoolFlag{
593593
Name: "no-validation",
@@ -969,6 +969,11 @@ func Run(isDebug *bool) *cli.Command {
969969
if err != nil {
970970
errs = append(errs, errors.Wrap(err, "failed to initialize AWS Secrets Manager client"))
971971
}
972+
case "azure":
973+
connectionManager, err = secrets.NewAzureKeyVaultClientFromEnv(logger)
974+
if err != nil {
975+
errs = append(errs, errors.Wrap(err, "failed to initialize Azure Key Vault client"))
976+
}
972977
default:
973978
connectionManager, errs = connection.NewManagerFromConfigWithContext(ctx, cm)
974979
}

go.mod

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ require (
1414
cloud.google.com/go/logging v1.13.0
1515
cloud.google.com/go/longrunning v0.6.7
1616
cloud.google.com/go/storage v1.56.0
17+
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0
18+
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0
19+
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.3.0
1720
github.com/BurntSushi/toml v1.5.0
1821
github.com/ClickHouse/clickhouse-go/v2 v2.37.1
1922
github.com/DATA-DOG/go-sqlmock v1.5.2
@@ -98,6 +101,7 @@ require (
98101
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 // indirect
99102
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0 // indirect
100103
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect
104+
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.0 // indirect
101105
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.1 // indirect
102106
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect
103107
github.com/ClickHouse/ch-go v0.66.0 // indirect

go.sum

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,10 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.0
9494
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.0/go.mod h1:DWAciXemNf++PQJLeXUB4HHH5OpsAh12HZnu2wXE1jA=
9595
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1 h1:MyVTgWR8qd/Jw1Le0NZebGBUCLbtak3bJ3z1OlqZBpw=
9696
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY=
97-
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0 h1:D3occbWoio4EBLkbkevetNMAVX197GkzbUMtqjGWn80=
98-
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI=
97+
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.3.0 h1:WLUIpeyv04H0RCcQHaA4TNoyrQ39Ox7V+re+iaqzTe0=
98+
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.3.0/go.mod h1:hd8hTTIY3VmUVPRHNH7GVCHO3SHgXkJKZHReby/bnUQ=
99+
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.0 h1:eXnN9kaS8TiDwXjoie3hMRLuwdUBUMW9KRgOqB3mCaw=
100+
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.0/go.mod h1:XIpam8wumeZ5rVMuhdDQLMfIPDf1WO3IzrCRO3e3e3o=
99101
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.1 h1:lhZdRq7TIx0GJQvSyX2Si406vrYsov2FXGp/RnSEtcs=
100102
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.1/go.mod h1:8cl44BDmi+effbARHMQjgOKA2AYvcohNm7KEt42mSV8=
101103
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=

pkg/secrets/aws_secretsmanager_test.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import (
1111
"github.com/stretchr/testify/require"
1212
)
1313

14+
const testGenericSecretJSON = `{"details": {"value": "somevalue"}, "type": "generic"}`
15+
1416
func TestNewAWSSecretsManagerClient(t *testing.T) {
1517
t.Parallel()
1618
log := &mockLogger{}
@@ -76,7 +78,7 @@ func TestAWSSecretsManagerClient_GetConnection_ReturnsConnection(t *testing.T) {
7678

7779
func TestAWSSecretsManagerClient_GetConnection_ReturnsGenericConnection(t *testing.T) {
7880
t.Parallel()
79-
secretString := `{"details": {"value": "somevalue"}, "type": "generic"}`
81+
secretString := testGenericSecretJSON
8082
c := &AWSSecretsManagerClient{
8183
client: &mockAWSSecretsManagerClient{
8284
response: &secretsmanager.GetSecretValueOutput{
@@ -126,7 +128,7 @@ func TestAWSSecretsManagerClient_GetConnection_ReturnsFromCache(t *testing.T) {
126128

127129
func TestAWSSecretsManagerClient_GetConnectionDetails_ReturnsDetails(t *testing.T) {
128130
t.Parallel()
129-
secretString := `{"details": {"value": "somevalue"}, "type": "generic"}`
131+
secretString := testGenericSecretJSON
130132
c := &AWSSecretsManagerClient{
131133
client: &mockAWSSecretsManagerClient{
132134
response: &secretsmanager.GetSecretValueOutput{

pkg/secrets/azure_keyvault.go

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
package secrets
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/url"
7+
"os"
8+
"strings"
9+
"sync"
10+
"time"
11+
12+
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
13+
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
14+
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets"
15+
"github.com/bruin-data/bruin/pkg/config"
16+
"github.com/bruin-data/bruin/pkg/connection"
17+
"github.com/bruin-data/bruin/pkg/logger"
18+
"github.com/pkg/errors"
19+
)
20+
21+
// azureKeyVaultSecretsClient defines the interface for Azure Key Vault operations.
22+
type azureKeyVaultSecretsClient interface {
23+
GetSecret(ctx context.Context, name string, version string, options *azsecrets.GetSecretOptions) (azsecrets.GetSecretResponse, error)
24+
}
25+
26+
// AzureKeyVaultClient manages secrets from Azure Key Vault.
27+
type AzureKeyVaultClient struct {
28+
client azureKeyVaultSecretsClient
29+
logger logger.Logger
30+
cacheMu sync.RWMutex
31+
cacheConnections map[string]any
32+
cacheConnectionsDetails map[string]any
33+
}
34+
35+
// NewAzureKeyVaultClientFromEnv creates a new Azure Key Vault client from environment variables.
36+
func NewAzureKeyVaultClientFromEnv(logger logger.Logger) (*AzureKeyVaultClient, error) {
37+
vaultURL := os.Getenv("BRUIN_AZURE_KEYVAULT_URL")
38+
if vaultURL == "" {
39+
return nil, errors.New("BRUIN_AZURE_KEYVAULT_URL env variable not set")
40+
}
41+
42+
authMethod := os.Getenv("BRUIN_AZURE_AUTH_METHOD")
43+
if authMethod == "" {
44+
authMethod = "default"
45+
}
46+
47+
switch authMethod {
48+
case "client_credentials":
49+
tenantID := os.Getenv("BRUIN_AZURE_TENANT_ID")
50+
if tenantID == "" {
51+
return nil, errors.New("BRUIN_AZURE_TENANT_ID env variable not set for client_credentials auth")
52+
}
53+
clientID := os.Getenv("BRUIN_AZURE_CLIENT_ID")
54+
if clientID == "" {
55+
return nil, errors.New("BRUIN_AZURE_CLIENT_ID env variable not set for client_credentials auth")
56+
}
57+
clientSecret := os.Getenv("BRUIN_AZURE_CLIENT_SECRET")
58+
if clientSecret == "" {
59+
return nil, errors.New("BRUIN_AZURE_CLIENT_SECRET env variable not set for client_credentials auth")
60+
}
61+
return NewAzureKeyVaultClient(logger, vaultURL, tenantID, clientID, clientSecret)
62+
63+
case "managed_identity":
64+
clientID := os.Getenv("BRUIN_AZURE_CLIENT_ID") // Optional for user-assigned identity
65+
return NewAzureKeyVaultClientWithManagedIdentity(logger, vaultURL, clientID)
66+
67+
case "cli":
68+
return NewAzureKeyVaultClientWithCLI(logger, vaultURL)
69+
70+
case "default":
71+
return NewAzureKeyVaultClientWithDefaultCredential(logger, vaultURL)
72+
73+
default:
74+
return nil, errors.Errorf("unsupported Azure auth method: %s", authMethod)
75+
}
76+
}
77+
78+
// NewAzureKeyVaultClient creates a new Azure Key Vault client with client credentials.
79+
func NewAzureKeyVaultClient(logger logger.Logger, vaultURL, tenantID, clientID, clientSecret string) (*AzureKeyVaultClient, error) {
80+
if err := validateVaultURL(vaultURL); err != nil {
81+
return nil, err
82+
}
83+
if tenantID == "" {
84+
return nil, errors.New("tenant ID required for client credentials authentication")
85+
}
86+
if clientID == "" {
87+
return nil, errors.New("client ID required for client credentials authentication")
88+
}
89+
if clientSecret == "" {
90+
return nil, errors.New("client secret required for client credentials authentication")
91+
}
92+
93+
cred, err := azidentity.NewClientSecretCredential(tenantID, clientID, clientSecret, nil)
94+
if err != nil {
95+
return nil, errors.Wrap(err, "failed to create Azure client secret credential")
96+
}
97+
98+
return newAzureKeyVaultClientWithCredential(logger, vaultURL, cred)
99+
}
100+
101+
// NewAzureKeyVaultClientWithManagedIdentity creates client using managed identity.
102+
func NewAzureKeyVaultClientWithManagedIdentity(logger logger.Logger, vaultURL, clientID string) (*AzureKeyVaultClient, error) {
103+
if err := validateVaultURL(vaultURL); err != nil {
104+
return nil, err
105+
}
106+
107+
var opts *azidentity.ManagedIdentityCredentialOptions
108+
if clientID != "" {
109+
opts = &azidentity.ManagedIdentityCredentialOptions{
110+
ID: azidentity.ClientID(clientID),
111+
}
112+
}
113+
114+
cred, err := azidentity.NewManagedIdentityCredential(opts)
115+
if err != nil {
116+
return nil, errors.Wrap(err, "failed to create Azure managed identity credential")
117+
}
118+
119+
return newAzureKeyVaultClientWithCredential(logger, vaultURL, cred)
120+
}
121+
122+
// NewAzureKeyVaultClientWithCLI creates client using Azure CLI credentials.
123+
func NewAzureKeyVaultClientWithCLI(logger logger.Logger, vaultURL string) (*AzureKeyVaultClient, error) {
124+
if err := validateVaultURL(vaultURL); err != nil {
125+
return nil, err
126+
}
127+
128+
cred, err := azidentity.NewAzureCLICredential(nil)
129+
if err != nil {
130+
return nil, errors.Wrap(err, "failed to create Azure CLI credential")
131+
}
132+
133+
return newAzureKeyVaultClientWithCredential(logger, vaultURL, cred)
134+
}
135+
136+
// NewAzureKeyVaultClientWithDefaultCredential creates client using DefaultAzureCredential.
137+
func NewAzureKeyVaultClientWithDefaultCredential(logger logger.Logger, vaultURL string) (*AzureKeyVaultClient, error) {
138+
if err := validateVaultURL(vaultURL); err != nil {
139+
return nil, err
140+
}
141+
142+
cred, err := azidentity.NewDefaultAzureCredential(nil)
143+
if err != nil {
144+
return nil, errors.Wrap(err, "failed to create Azure default credential")
145+
}
146+
147+
return newAzureKeyVaultClientWithCredential(logger, vaultURL, cred)
148+
}
149+
150+
func validateVaultURL(vaultURL string) error {
151+
if vaultURL == "" {
152+
return errors.New("empty Azure Key Vault URL provided")
153+
}
154+
parsed, err := url.Parse(vaultURL)
155+
if err != nil || parsed.Scheme != "https" || !strings.HasSuffix(parsed.Host, ".vault.azure.net") {
156+
return errors.New("invalid Azure Key Vault URL: must be https://<name>.vault.azure.net")
157+
}
158+
return nil
159+
}
160+
161+
func newAzureKeyVaultClientWithCredential(logger logger.Logger, vaultURL string, cred azcore.TokenCredential) (*AzureKeyVaultClient, error) {
162+
client, err := azsecrets.NewClient(vaultURL, cred, nil)
163+
if err != nil {
164+
return nil, errors.Wrap(err, "failed to create Azure Key Vault secrets client")
165+
}
166+
167+
return &AzureKeyVaultClient{
168+
client: client,
169+
logger: logger,
170+
cacheConnections: make(map[string]any),
171+
cacheConnectionsDetails: make(map[string]any),
172+
}, nil
173+
}
174+
175+
// GetConnection retrieves a connection by name from Azure Key Vault.
176+
func (c *AzureKeyVaultClient) GetConnection(name string) any {
177+
c.cacheMu.RLock()
178+
if conn, ok := c.cacheConnections[name]; ok {
179+
c.cacheMu.RUnlock()
180+
return conn
181+
}
182+
c.cacheMu.RUnlock()
183+
184+
manager, err := c.getAzureKeyVaultManager(name)
185+
if err != nil {
186+
c.logger.Errorf("%v", err)
187+
return nil
188+
}
189+
190+
conn := manager.GetConnection(name)
191+
192+
c.cacheMu.Lock()
193+
c.cacheConnections[name] = conn
194+
c.cacheMu.Unlock()
195+
196+
return conn
197+
}
198+
199+
// GetConnectionDetails retrieves connection details by name from Azure Key Vault.
200+
func (c *AzureKeyVaultClient) GetConnectionDetails(name string) any {
201+
c.cacheMu.RLock()
202+
if deets, ok := c.cacheConnectionsDetails[name]; ok {
203+
c.cacheMu.RUnlock()
204+
return deets
205+
}
206+
c.cacheMu.RUnlock()
207+
208+
manager, err := c.getAzureKeyVaultManager(name)
209+
if err != nil {
210+
c.logger.Errorf("%v", err)
211+
return nil
212+
}
213+
214+
deets := manager.GetConnectionDetails(name)
215+
216+
c.cacheMu.Lock()
217+
c.cacheConnectionsDetails[name] = deets
218+
c.cacheMu.Unlock()
219+
220+
return deets
221+
}
222+
223+
func (c *AzureKeyVaultClient) getAzureKeyVaultManager(name string) (config.ConnectionAndDetailsGetter, error) {
224+
ctx, cancelFunc := context.WithTimeout(context.Background(), 30*time.Second)
225+
defer cancelFunc()
226+
227+
// Empty string for version gets the latest version
228+
result, err := c.client.GetSecret(ctx, name, "", nil)
229+
if err != nil {
230+
return nil, errors.Wrapf(err, "failed to read secret '%s' from Azure Key Vault", name)
231+
}
232+
233+
if result.Value == nil {
234+
return nil, errors.Errorf("secret '%s' has no value", name)
235+
}
236+
237+
var secretData map[string]any
238+
if err := json.Unmarshal([]byte(*result.Value), &secretData); err != nil {
239+
return nil, errors.Wrap(err, "failed to parse secret as JSON")
240+
}
241+
242+
detailsRaw, okDetails := secretData["details"]
243+
secretType, okType := secretData["type"].(string)
244+
245+
details, detailsIsMap := detailsRaw.(map[string]any)
246+
if !okDetails || !detailsIsMap || !okType || secretType == "" {
247+
return nil, errors.Errorf("secret '%s' must contain both 'type' (non-empty string) and 'details' (object)", name)
248+
}
249+
250+
details["name"] = name
251+
252+
connectionsMap := map[string][]map[string]any{
253+
secretType: {
254+
details,
255+
},
256+
}
257+
258+
serialized, err := json.Marshal(connectionsMap)
259+
if err != nil {
260+
return nil, errors.Wrapf(err, "failed to process secret '%s'", name)
261+
}
262+
263+
var connections config.Connections
264+
265+
if err := json.Unmarshal(serialized, &connections); err != nil {
266+
return nil, errors.Wrapf(err, "failed to parse secret '%s' configuration", name)
267+
}
268+
269+
environment := config.Environment{
270+
Connections: &connections,
271+
}
272+
273+
cfg := config.Config{
274+
Environments: map[string]config.Environment{
275+
"default": environment,
276+
},
277+
SelectedEnvironmentName: "default",
278+
SelectedEnvironment: &environment,
279+
DefaultEnvironmentName: "default",
280+
}
281+
282+
manager, errs := connection.NewManagerFromConfig(&cfg)
283+
if len(errs) > 0 {
284+
return nil, errors.Wrapf(errs[0], "failed to configure connection '%s'", name)
285+
}
286+
287+
return manager, nil
288+
}

0 commit comments

Comments
 (0)