Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 83 additions & 2 deletions cli/azd/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,20 @@
package config

import (
"encoding/base64"
"encoding/json"
"fmt"
"path/filepath"
"regexp"
"strings"

"github.com/google/uuid"
)

//
//nolint:lll
var vaultPattern = regexp.MustCompile(
`^vault://[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}/[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$`,
)

// Azd configuration for the current user
Expand All @@ -20,6 +31,7 @@ type Config interface {
GetString(path string) (string, bool)
GetSection(path string, section any) (bool, error)
Set(path string, value any) error
SetSecret(path string, value string) error
Unset(path string) error
IsEmpty() bool
}
Expand All @@ -43,7 +55,9 @@ func NewConfig(data map[string]any) Config {

// Top level AZD configuration
type config struct {
data map[string]any
vaultId string
vault Config
data map[string]any
}

// Returns a value indicating whether the configuration is empty
Expand All @@ -56,6 +70,25 @@ func (c *config) Raw() map[string]any {
return c.data
}

// SetSecret stores the secrets at the specified path within a local user vault
func (c *config) SetSecret(path string, value string) error {
if c.vaultId == "" {
c.vault = NewConfig(nil)
c.vaultId = uuid.New().String()
if err := c.Set("vault", c.vaultId); err != nil {
return fmt.Errorf("failed setting vault id: %w", err)
}
}

pathId := uuid.New().String()
vaultRef := fmt.Sprintf("vault://%s/%s", c.vaultId, pathId)
if err := c.vault.Set(pathId, base64.StdEncoding.EncodeToString([]byte(value))); err != nil {
Comment thread
wbreza marked this conversation as resolved.
return fmt.Errorf("failed setting secret value: %w", err)
}

return c.Set(path, vaultRef)
Comment thread
wbreza marked this conversation as resolved.
}

// Sets a value at the specified location
func (c *config) Set(path string, value any) error {
depth := 1
Expand Down Expand Up @@ -129,9 +162,15 @@ func (c *config) Get(path string) (any, bool) {
currentNode := c.data
parts := strings.Split(path, ".")
for _, part := range parts {
// When the depth is equal to the number of parts, we have reached the desired node path
// At this point we can perform any final processing on the node and return the result
if depth == len(parts) {
value, ok := currentNode[part]
return value, ok
if !ok {
return value, ok
}

return c.interpolateNodeValue(value)
}
value, ok := currentNode[part]
if !ok {
Expand Down Expand Up @@ -178,3 +217,45 @@ func (c *config) GetSection(path string, section any) (bool, error) {

return true, nil
}

// getSecret retrieves the secret stored at the specified path from a local user vault
func (c *config) getSecret(vaultRef string) (string, bool) {
encodedValue, ok := c.vault.GetString(filepath.Base(vaultRef))
if !ok {
return "", false
}

bytes, err := base64.StdEncoding.DecodeString(encodedValue)
if err != nil {
return "", false
}

return string(bytes), true
}

// interpolateNodeValue processes the node, iterates on any nested nodes and interpolates any vault references
func (c *config) interpolateNodeValue(value any) (any, bool) {
// Check if the value is a vault reference
// If it is, retrieve the secret from the vault
if vaultRef, isString := value.(string); isString && vaultPattern.MatchString(vaultRef) {
return c.getSecret(vaultRef)
}

// If the value is a map, recursively iterate over the map and interpolate the values
if node, isMap := value.(map[string]any); isMap {
// We want to ensure we return a cloned map so that we don't modify the original data
// stored within the config map data structure
cloneMap := map[string]any{}

for key, val := range node {
if nodeValue, ok := c.interpolateNodeValue(val); ok {
cloneMap[key] = nodeValue
}
}

return cloneMap, true
}

// Finally, if the value is not handled above we can return the value as is
return value, true
}
44 changes: 44 additions & 0 deletions cli/azd/pkg/config/file_config_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,29 @@ func (m *fileConfigManager) Load(filePath string) (Config, error) {
return nil, err
}

// If the configuration contains a vault, then also load the vault configuration
vaultId, ok := azdConfig.GetString("vault")
if ok {
configPath, err := GetUserConfigDir()
if err != nil {
return nil, fmt.Errorf("failed getting user config directory: %w", err)
}

vaultPath := filepath.Join(configPath, "vaults", fmt.Sprintf("%s.json", vaultId))
vaultConfig, err := m.Load(vaultPath)
if err != nil {
return nil, fmt.Errorf("failed loading vault configuration from '%s': %w", vaultPath, err)
}

baseConfig, ok := azdConfig.(*config)
if !ok {
return nil, fmt.Errorf("failed casting azd configuration to config")
}

baseConfig.vaultId = vaultId
baseConfig.vault = vaultConfig
}

return azdConfig, nil
}

Expand All @@ -62,5 +85,26 @@ func (m *fileConfigManager) Save(c Config, filePath string) error {
return err
}

baseConfig, ok := c.(*config)
if !ok {
return fmt.Errorf("failed casting azd configuration to config")
}

// If the configuration contains a vault, then also save the vault configuration
// Vault configuration always gets saved in a separate file in the users HOME directory.
if baseConfig.vaultId != "" {
configPath, err := GetUserConfigDir()
if err != nil {
return fmt.Errorf("failed getting user config directory: %w", err)
}

vaultPath := filepath.Join(configPath, "vaults", fmt.Sprintf("%s.json", baseConfig.vaultId))
if err = os.MkdirAll(filepath.Dir(vaultPath), osutil.PermissionDirectory); err != nil {
return fmt.Errorf("failed creating vaults directory: %w", err)
}

return m.Save(baseConfig.vault, vaultPath)
}

return nil
}
99 changes: 99 additions & 0 deletions cli/azd/pkg/config/file_config_manager_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package config

import (
"fmt"
"os"
"path/filepath"
"testing"

"github.com/google/uuid"
"github.com/stretchr/testify/require"
)

Expand Down Expand Up @@ -41,3 +44,99 @@ func Test_FileConfigManager_SaveAndLoadEmptyConfig(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, existingConfig)
}

func Test_FileConfigManager_GetSetSecrets(t *testing.T) {
tempDir := t.TempDir()
azdConfigDir := filepath.Join(tempDir, ".azd")

err := os.Setenv("AZD_CONFIG_DIR", azdConfigDir)
require.NoError(t, err)

// Set and save secrets
configFilePath := filepath.Join(tempDir, "config.json")
configManager := NewFileConfigManager(NewManager())
azdConfig := NewConfig(nil)

// Standard secrets
expectedPassword := "P@55w0rd!"
err = azdConfig.SetSecret("secrets.password", expectedPassword)
require.NoError(t, err)

err = azdConfig.SetSecret("infra.provisioning.sqlPassword", expectedPassword)
require.NoError(t, err)

// Missing vault reference
missingVaultRef := fmt.Sprintf("vault://%s/%s", uuid.New().String(), uuid.New().String())
err = azdConfig.Set("secrets.misingVaultRef", missingVaultRef)
require.NoError(t, err)

err = configManager.Save(azdConfig, configFilePath)
require.NoError(t, err)

baseConfig, ok := azdConfig.(*config)
require.True(t, ok)

expectedVaultPath := filepath.Join(azdConfigDir, "vaults", fmt.Sprintf("%s.json", baseConfig.vaultId))
require.FileExists(t, expectedVaultPath)

// Load and retrieve secrets
updatedConfig, err := configManager.Load(configFilePath)
require.NoError(t, err)
require.NotNil(t, updatedConfig)

userPassword, ok := updatedConfig.GetString("secrets.password")
require.True(t, ok)
require.Equal(t, expectedPassword, userPassword)

sqlPassword, ok := updatedConfig.GetString("infra.provisioning.sqlPassword")
require.True(t, ok)
require.Equal(t, expectedPassword, sqlPassword)

// Missing vault reference will return empty string
// even though the value appears to be a vault reference
missingPassword, ok := updatedConfig.GetString("secrets.misingVaultRef")
require.False(t, ok)
require.Empty(t, missingPassword)
}

func Test_FileConfigManager_GetSetSecretsInSection(t *testing.T) {
tempDir := t.TempDir()
azdConfigDir := filepath.Join(tempDir, ".azd")

err := os.Setenv("AZD_CONFIG_DIR", azdConfigDir)
require.NoError(t, err)

// Set and save secrets
configFilePath := filepath.Join(tempDir, "config.json")
configManager := NewFileConfigManager(NewManager())
azdConfig := NewConfig(nil)

err = azdConfig.SetSecret("infra.provisioning.secret1", "secrect1Value")
require.NoError(t, err)

err = azdConfig.SetSecret("infra.provisioning.secret2", "secrect2Value")
require.NoError(t, err)

err = azdConfig.Set("infra.provisioning.normalValue", "normalValue")
require.NoError(t, err)

err = configManager.Save(azdConfig, configFilePath)
require.NoError(t, err)

var provisioningParams map[string]string
ok, err := azdConfig.GetSection("infra.provisioning", &provisioningParams)
require.NoError(t, err)
require.True(t, ok)

secret1, ok := provisioningParams["secret1"]
require.True(t, ok)
require.Equal(t, "secrect1Value", secret1)

secret2, ok := provisioningParams["secret2"]
require.True(t, ok)
require.Equal(t, "secrect2Value", secret2)

normalValue, ok := provisioningParams["normalValue"]
require.True(t, ok)
require.Equal(t, "normalValue", normalValue)
}