mirror of
https://github.com/actions/actions-runner-controller.git
synced 2025-12-15 22:47:13 +00:00
Azure Key Vault integration to resolve secrets (#4090)
This commit is contained in:
39
vault/azurekeyvault/azurekeyvault.go
Normal file
39
vault/azurekeyvault/azurekeyvault.go
Normal 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
|
||||
}
|
||||
120
vault/azurekeyvault/config.go
Normal file
120
vault/azurekeyvault/config.go
Normal 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
|
||||
}
|
||||
99
vault/azurekeyvault/config_test.go
Normal file
99
vault/azurekeyvault/config_test.go
Normal 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
20
vault/azurekeyvault/testdata/server.crt
vendored
Normal 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
38
vault/vault.go
Normal 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)
|
||||
Reference in New Issue
Block a user