Azure Key Vault integration to resolve secrets (#4090)

This commit is contained in:
Nikola Jokic
2025-06-11 15:53:33 +02:00
committed by GitHub
parent d4af75d82e
commit e46c929241
48 changed files with 2013 additions and 599 deletions

View File

@@ -0,0 +1,39 @@
package azurekeyvault
import (
"context"
"fmt"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets"
)
// AzureKeyVault is a struct that holds the Azure Key Vault client.
type AzureKeyVault struct {
client *azsecrets.Client
}
func New(cfg Config) (*AzureKeyVault, error) {
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("failed to validate config: %v", err)
}
client, err := cfg.Client()
if err != nil {
return nil, fmt.Errorf("failed to create azsecrets client from config: %v", err)
}
return &AzureKeyVault{client: client}, nil
}
// GetSecret retrieves a secret from Azure Key Vault.
func (v *AzureKeyVault) GetSecret(ctx context.Context, name string) (string, error) {
secret, err := v.client.GetSecret(ctx, name, "", nil)
if err != nil {
return "", fmt.Errorf("failed to get secret: %w", err)
}
if secret.Value == nil {
return "", fmt.Errorf("secret value is nil")
}
return *secret.Value, nil
}

View File

@@ -0,0 +1,120 @@
package azurekeyvault
import (
"errors"
"fmt"
"net/http"
"net/url"
"os"
"time"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets"
"github.com/hashicorp/go-retryablehttp"
"golang.org/x/net/http/httpproxy"
)
// AzureKeyVault is a struct that holds the Azure Key Vault client.
type Config struct {
TenantID string `json:"tenant_id"`
ClientID string `json:"client_id"`
URL string `json:"url"`
CertificatePath string `json:"certificate_path"`
Proxy *httpproxy.Config `json:"proxy,omitempty"`
}
func (c *Config) Validate() error {
if c.TenantID == "" {
return errors.New("tenant_id is not set")
}
if c.ClientID == "" {
return errors.New("client_id is not set")
}
if _, err := url.ParseRequestURI(c.URL); err != nil {
return fmt.Errorf("failed to parse url: %v", err)
}
if c.CertificatePath == "" {
return errors.New("cert path must be provided")
}
if _, err := os.Stat(c.CertificatePath); err != nil {
return fmt.Errorf("cert path %q does not exist: %v", c.CertificatePath, err)
}
if c.Proxy != nil {
if c.Proxy.HTTPProxy == "" && c.Proxy.HTTPSProxy == "" && c.Proxy.NoProxy == "" {
return errors.New("proxy configuration is empty, at least one proxy must be set")
}
}
return nil
}
// Client creates a new Azure Key Vault client using the provided configuration.
func (c *Config) Client() (*azsecrets.Client, error) {
return c.certClient()
}
func (c *Config) certClient() (*azsecrets.Client, error) {
data, err := os.ReadFile(c.CertificatePath)
if err != nil {
return nil, fmt.Errorf("failed to read cert file from path %q: %v", c.CertificatePath, err)
}
certs, key, err := azidentity.ParseCertificates(data, nil)
if err != nil {
return nil, fmt.Errorf("failed to parse certificates: %w", err)
}
httpClient, err := c.httpClient()
if err != nil {
return nil, fmt.Errorf("failed to instantiate http client: %v", err)
}
cred, err := azidentity.NewClientCertificateCredential(
c.TenantID,
c.ClientID,
certs,
key,
&azidentity.ClientCertificateCredentialOptions{
ClientOptions: policy.ClientOptions{
Transport: httpClient,
},
},
)
if err != nil {
return nil, fmt.Errorf("failed to create client certificate credential: %v", err)
}
client, err := azsecrets.NewClient(c.URL, cred, &azsecrets.ClientOptions{
ClientOptions: policy.ClientOptions{
Transport: httpClient,
},
})
if err != nil {
return nil, fmt.Errorf("failed to instantiate client for azsecrets: %v", err)
}
return client, nil
}
func (c *Config) httpClient() (*http.Client, error) {
retryClient := retryablehttp.NewClient()
retryClient.RetryMax = 4
retryClient.RetryWaitMax = 30 * time.Second
retryClient.HTTPClient.Timeout = 5 * time.Minute
transport, ok := retryClient.HTTPClient.Transport.(*http.Transport)
if !ok {
return nil, fmt.Errorf("failed to get http transport")
}
if c.Proxy != nil {
transport.Proxy = func(req *http.Request) (*url.URL, error) {
return c.Proxy.ProxyFunc()(req.URL)
}
}
return retryClient.StandardClient(), nil
}

View File

@@ -0,0 +1,99 @@
package azurekeyvault
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/net/http/httpproxy"
)
func TestConfigValidate_invalid(t *testing.T) {
tenantID := "tenantID"
clientID := "clientID"
url := "https://example.com"
cp, err := os.CreateTemp("", "")
require.NoError(t, err)
err = cp.Close()
require.NoError(t, err)
certPath := cp.Name()
t.Cleanup(func() {
os.Remove(certPath)
})
tt := map[string]*Config{
"empty": {},
"no tenant id": {
TenantID: "",
ClientID: clientID,
URL: url,
CertificatePath: certPath,
},
"no client id": {
TenantID: tenantID,
ClientID: "",
URL: url,
CertificatePath: certPath,
},
"no url": {
TenantID: tenantID,
ClientID: clientID,
URL: "",
CertificatePath: certPath,
},
"no jwt and no cert path": {
TenantID: tenantID,
ClientID: clientID,
URL: url,
CertificatePath: "",
},
"invalid proxy": {
TenantID: tenantID,
ClientID: clientID,
URL: url,
CertificatePath: certPath,
Proxy: &httpproxy.Config{},
},
}
for name, cfg := range tt {
t.Run(name, func(t *testing.T) {
err := cfg.Validate()
require.Error(t, err)
})
}
}
func TestValidate_valid(t *testing.T) {
tenantID := "tenantID"
clientID := "clientID"
url := "https://example.com"
certPath, err := filepath.Abs("testdata/server.crt")
require.NoError(t, err)
tt := map[string]*Config{
"with cert": {
TenantID: tenantID,
ClientID: clientID,
URL: url,
CertificatePath: certPath,
},
"without proxy": {
TenantID: tenantID,
ClientID: clientID,
URL: url,
CertificatePath: certPath,
},
}
for name, cfg := range tt {
t.Run(name, func(t *testing.T) {
err := cfg.Validate()
require.NoError(t, err)
})
}
}

20
vault/azurekeyvault/testdata/server.crt vendored Normal file
View File

@@ -0,0 +1,20 @@
-----BEGIN CERTIFICATE-----
MIIDOjCCAiKgAwIBAgIUQr7R8yN5+2and6ucUOPF6oIbD48wDQYJKoZIhvcNAQEL
BQAwFzEVMBMGA1UEAwwMVGVzdCBSb290IENBMB4XDTI1MDIyODEyMDEzMFoXDTI2
MDcxMzEyMDEzMFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0B
AQEFAAOCAQ8AMIIBCgKCAQEA4oL2hAPQlDVaNJru5fIstkpoVSuam0vpswC7ciRc
XQRjF3q8kjtIA7+jdySsKJqOLGnybDX3awvRyKMEjq11IfnZLjZc+FzTlA+x4z0h
MHb0GiBFXKNzrExGI9F0KEPtFxcMIqZ119LY2ReexxWkZBQYlgTepaevp71za4c2
n4Zy1+0iS5+uklZ4ANKMTBGlN76Qgt530VnpNiIeUbiUzY58Vx4q7kFcUv/oSz8p
rbXr+/GGpAjrOc6/JsezRE8YK2po60dvV80TJ2Jt6pduvF7OSQnq/v4mJl1xuXKl
Byo9HLbeu3BuVRWQs2/EwEzx5kX3Ugysl9Bm44K2yKe9/QIDAQABo4GAMH4wHwYD
VR0jBBgwFoAUfd/q0BY4fkVBV3X+HWzXH0toW08wCQYDVR0TBAIwADALBgNVHQ8E
BAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDwYDVR0RBAgwBocEfwAAATAdBgNV
HQ4EFgQUe0rTTfWjho3hgeLTnajTCpddo2MwDQYJKoZIhvcNAQELBQADggEBAIR2
5zkA7rPnddxCunsz8Jjq3wyhR/KiAFz+RGeFeiXDkF2fWr7QIQ9KbFbv8tpfXR7P
B75bY0sXwutHMB2sZDi92cH5sthNBfp19fI35cxcU4oTPxp4UZJKEiA3Qx8y73CX
NJu1009nPdOJNlIboDGAFdZ5SH6RCh+YcQZ68kjHPWBIpXxLbs9FN3QmpbAvtLh1
PoPaSy7IjKmxm1u+Lf6tyIn2IiB3MiynaB3OKvbkLCseM/5SZKMk6WKSDWopOCJr
xciPOc+yeLz5I2Omn0uViOIIciqjlgxncWAyNtDgvJcecwqB2cPiIhk6GY0QZ1uM
e7KoqGzWXvWLqJ13a9U=
-----END CERTIFICATE-----

38
vault/vault.go Normal file
View File

@@ -0,0 +1,38 @@
package vault
import (
"context"
"fmt"
"github.com/actions/actions-runner-controller/vault/azurekeyvault"
)
// Vault is the interface every vault implementation needs to adhere to
type Vault interface {
GetSecret(ctx context.Context, name string) (string, error)
}
// VaultType represents the type of vault that can be used in the application.
// It is used to identify which vault integration should be used to resolve secrets.
type VaultType string
// VaultType is the type of vault supported
const (
VaultTypeAzureKeyVault VaultType = "azure_key_vault"
)
func (t VaultType) String() string {
return string(t)
}
func (t VaultType) Validate() error {
switch t {
case VaultTypeAzureKeyVault:
return nil
default:
return fmt.Errorf("unknown vault type: %q", t)
}
}
// Compile-time checks
var _ Vault = (*azurekeyvault.AzureKeyVault)(nil)