1
0
Fork 0
mirror of https://github.com/external-secrets/external-secrets.git synced 2024-12-14 11:57:59 +00:00

Add support for Authentication against Azure Key Vault using Client Certificate (#3469)

* Implementation of Certificate Based Authz against Azure Key Vault

Signed-off-by: Luis Schweigard <luis.schweigard@gmail.com>

* Add tests for new Azure certificate auth functionality

Signed-off-by: Luis Schweigard <luis.schweigard@gmail.com>

* Add documentation for Azure Cert based Auth

Signed-off-by: Luis Schweigard <luis.schweigard@gmail.com>

* Generate spec.md

Signed-off-by: Luis Schweigard <luis.schweigard@gmail.com>

* Add changes from code review

Signed-off-by: Luis Schweigard <luis.schweigard@gmail.com>

* Fix naming in test error case

Signed-off-by: Luis Schweigard <luis.schweigard@gmail.com>

---------

Signed-off-by: Luis Schweigard <luis.schweigard@gmail.com>
This commit is contained in:
Luis Schweigard 2024-05-13 13:40:50 +02:00 committed by GitHub
parent 4b8b8788bf
commit 0abb3e9cc4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 420 additions and 38 deletions

View file

@ -99,4 +99,8 @@ type AzureKVAuth struct {
// The Azure ClientSecret of the service principle used for authentication.
// +optional
ClientSecret *smmeta.SecretKeySelector `json:"clientSecret,omitempty"`
// The Azure ClientCertificate of the service principle used for authentication.
// +optional
ClientCertificate *smmeta.SecretKeySelector `json:"clientCertificate,omitempty"`
}

View file

@ -329,6 +329,11 @@ func (in *AzureKVAuth) DeepCopyInto(out *AzureKVAuth) {
*out = new(metav1.SecretKeySelector)
(*in).DeepCopyInto(*out)
}
if in.ClientCertificate != nil {
in, out := &in.ClientCertificate, &out.ClientCertificate
*out = new(metav1.SecretKeySelector)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AzureKVAuth.

View file

@ -2144,6 +2144,25 @@ spec:
with Azure. Required for ServicePrincipal auth type. Optional
for WorkloadIdentity.
properties:
clientCertificate:
description: The Azure ClientCertificate of the service
principle used for authentication.
properties:
key:
description: |-
The key of the entry in the Secret resource's `data` field to be used. Some instances of this field may be
defaulted, in others it may be required.
type: string
name:
description: The name of the Secret resource being
referred to.
type: string
namespace:
description: |-
Namespace of the resource being referred to. Ignored if referent is not cluster-scoped. cluster-scoped defaults
to the namespace of the referent.
type: string
type: object
clientId:
description: The Azure clientId of the service principle
or managed identity used for authentication.

View file

@ -2144,6 +2144,25 @@ spec:
with Azure. Required for ServicePrincipal auth type. Optional
for WorkloadIdentity.
properties:
clientCertificate:
description: The Azure ClientCertificate of the service
principle used for authentication.
properties:
key:
description: |-
The key of the entry in the Secret resource's `data` field to be used. Some instances of this field may be
defaulted, in others it may be required.
type: string
name:
description: The name of the Secret resource being
referred to.
type: string
namespace:
description: |-
Namespace of the resource being referred to. Ignored if referent is not cluster-scoped. cluster-scoped defaults
to the namespace of the referent.
type: string
type: object
clientId:
description: The Azure clientId of the service principle
or managed identity used for authentication.

View file

@ -2665,6 +2665,23 @@ spec:
authSecretRef:
description: Auth configures how the operator authenticates with Azure. Required for ServicePrincipal auth type. Optional for WorkloadIdentity.
properties:
clientCertificate:
description: The Azure ClientCertificate of the service principle used for authentication.
properties:
key:
description: |-
The key of the entry in the Secret resource's `data` field to be used. Some instances of this field may be
defaulted, in others it may be required.
type: string
name:
description: The name of the Secret resource being referred to.
type: string
namespace:
description: |-
Namespace of the resource being referred to. Ignored if referent is not cluster-scoped. cluster-scoped defaults
to the namespace of the referent.
type: string
type: object
clientId:
description: The Azure clientId of the service principle or managed identity used for authentication.
properties:
@ -8010,6 +8027,23 @@ spec:
authSecretRef:
description: Auth configures how the operator authenticates with Azure. Required for ServicePrincipal auth type. Optional for WorkloadIdentity.
properties:
clientCertificate:
description: The Azure ClientCertificate of the service principle used for authentication.
properties:
key:
description: |-
The key of the entry in the Secret resource's `data` field to be used. Some instances of this field may be
defaulted, in others it may be required.
type: string
name:
description: The name of the Secret resource being referred to.
type: string
namespace:
description: |-
Namespace of the resource being referred to. Ignored if referent is not cluster-scoped. cluster-scoped defaults
to the namespace of the referent.
type: string
type: object
clientId:
description: The Azure clientId of the service principle or managed identity used for authentication.
properties:

View file

@ -869,6 +869,20 @@ External Secrets meta/v1.SecretKeySelector
<p>The Azure ClientSecret of the service principle used for authentication.</p>
</td>
</tr>
<tr>
<td>
<code>clientCertificate</code></br>
<em>
<a href="https://pkg.go.dev/github.com/external-secrets/external-secrets/apis/meta/v1#SecretKeySelector">
External Secrets meta/v1.SecretKeySelector
</a>
</em>
</td>
<td>
<em>(Optional)</em>
<p>The Azure ClientCertificate of the service principle used for authentication.</p>
</td>
</tr>
</tbody>
</table>
<h3 id="external-secrets.io/v1beta1.AzureKVProvider">AzureKVProvider

View file

@ -34,7 +34,7 @@ az keyvault set-policy --name kv-name-with-certs --object-id "$KUBELET_IDENTITY_
#### Service Principal key authentication
A service Principal client and Secret is created and the JSON keyfile is stored in a `Kind=Secret`. The `ClientID` and `ClientSecret` should be configured for the secret. This service principal should have proper access rights to the keyvault to be managed by the operator
A service Principal client and Secret is created and the JSON keyfile is stored in a `Kind=Secret`. The `ClientID` and `ClientSecret` or `ClientCertificate` (in PEM format) should be configured for the secret. This service principal should have proper access rights to the keyvault to be managed by the operator.
#### Managed Identity authentication

View file

@ -76,6 +76,7 @@ const (
errMissingClient = "missing clientID: either serviceAccountRef or service account annotation '%s' is missing"
errMissingSecretRef = "missing secretRef in provider config"
errMissingClientIDSecret = "missing accessKeyID/secretAccessKey in store config"
errInvalidClientCredentials = "both clientSecret and clientCredentials set"
errMultipleClientID = "multiple clientID found. Check secretRef and serviceAccountRef"
errMultipleTenantID = "multiple tenantID found. Check secretRef, 'spec.provider.azurekv.tenantId', and serviceAccountRef"
errFindSecret = "could not find secret %s/%s: %w"
@ -87,6 +88,7 @@ const (
errInvalidAzureProv = "invalid azure keyvault provider"
errInvalidSecRefClientID = "invalid AuthSecretRef.ClientID: %w"
errInvalidSecRefClientSecret = "invalid AuthSecretRef.ClientSecret: %w"
errInvalidSecRefClientCertificate = "invalid AuthSecretRef.ClientCertificate: %w"
errInvalidSARef = "invalid ServiceAccountRef: %w"
errMissingWorkloadEnvVars = "missing environment variables. AZURE_CLIENT_ID, AZURE_TENANT_ID and AZURE_FEDERATED_TOKEN_FILE must be set"
@ -877,7 +879,7 @@ func (a *Azure) authorizerForWorkloadIdentity(ctx context.Context, tokenProvider
}
}
}
// Check if spec.provider.azurekv.tenantId is set
// Check if spec.provider.azurekv.tenantID is set
if tenantID == "" && a.provider.TenantID != nil {
tenantID = *a.provider.TenantID
}
@ -979,31 +981,81 @@ func (a *Azure) authorizerForServicePrincipal(ctx context.Context) (autorest.Aut
if a.provider.AuthSecretRef == nil {
return nil, fmt.Errorf(errMissingSecretRef)
}
if a.provider.AuthSecretRef.ClientID == nil || a.provider.AuthSecretRef.ClientSecret == nil {
if a.provider.AuthSecretRef.ClientID == nil || (a.provider.AuthSecretRef.ClientSecret == nil && a.provider.AuthSecretRef.ClientCertificate == nil) {
return nil, fmt.Errorf(errMissingClientIDSecret)
}
if a.provider.AuthSecretRef.ClientSecret != nil && a.provider.AuthSecretRef.ClientCertificate != nil {
return nil, fmt.Errorf(errInvalidClientCredentials)
}
return a.getAuthorizerFromCredentials(ctx)
}
func (a *Azure) getAuthorizerFromCredentials(ctx context.Context) (autorest.Authorizer, error) {
clientID, err := resolvers.SecretKeyRef(
ctx,
a.crClient,
a.store.GetKind(),
a.namespace, a.provider.AuthSecretRef.ClientID)
a.namespace, a.provider.AuthSecretRef.ClientID,
)
if err != nil {
return nil, err
}
if a.provider.AuthSecretRef.ClientSecret != nil {
clientSecret, err := resolvers.SecretKeyRef(
ctx,
a.crClient,
a.store.GetKind(),
a.namespace, a.provider.AuthSecretRef.ClientSecret)
a.namespace, a.provider.AuthSecretRef.ClientSecret,
)
if err != nil {
return nil, err
}
clientCredentialsConfig := kvauth.NewClientCredentialsConfig(clientID, clientSecret, *a.provider.TenantID)
clientCredentialsConfig.Resource = kvResourceForProviderConfig(a.provider.EnvironmentType)
clientCredentialsConfig.AADEndpoint = AadEndpointForType(a.provider.EnvironmentType)
return getAuthorizerForClientSecret(
clientID,
clientSecret,
*a.provider.TenantID,
a.provider.EnvironmentType,
)
} else {
clientCertificate, err := resolvers.SecretKeyRef(
ctx,
a.crClient,
a.store.GetKind(),
a.namespace, a.provider.AuthSecretRef.ClientCertificate,
)
if err != nil {
return nil, err
}
return getAuthorizerForClientCertificate(
clientID,
[]byte(clientCertificate),
*a.provider.TenantID,
a.provider.EnvironmentType,
)
}
}
func getAuthorizerForClientSecret(clientID, clientSecret, tenantID string, environmentType esv1beta1.AzureEnvironmentType) (autorest.Authorizer, error) {
clientCredentialsConfig := kvauth.NewClientCredentialsConfig(clientID, clientSecret, tenantID)
clientCredentialsConfig.Resource = kvResourceForProviderConfig(environmentType)
clientCredentialsConfig.AADEndpoint = AadEndpointForType(environmentType)
return clientCredentialsConfig.Authorizer()
}
func getAuthorizerForClientCertificate(clientID string, certificateBytes []byte, tenantID string, environmentType esv1beta1.AzureEnvironmentType) (autorest.Authorizer, error) {
clientCertificateConfig := NewClientInMemoryCertificateConfig(clientID, certificateBytes, tenantID)
clientCertificateConfig.Resource = kvResourceForProviderConfig(environmentType)
clientCertificateConfig.AADEndpoint = AadEndpointForType(environmentType)
return clientCertificateConfig.Authorizer()
}
func (a *Azure) Close(_ context.Context) error {
return nil
}

View file

@ -37,6 +37,32 @@ import (
var vaultURL = "https://local.vault.url"
var mockCertificate = `
-----BEGIN CERTIFICATE-----
MIICBzCCAbGgAwIBAgIUSoCD1fgywDbmeRaGrkYzGWUd1wMwDQYJKoZIhvcNAQEL
BQAwcTELMAkGA1UEBhMCQVoxGTAXBgNVBAgMEE1vY2sgQ2VydGlmaWNhdGUxMzAx
BgNVBAoMKkV4dGVybmFsIFNlY3JldHMgT3BlcmF0b3IgTW9jayBDZXJ0aWZpY2F0
ZTESMBAGA1UEAwwJTW9jayBDZXJ0MB4XDTI0MDUwODA4NDkzMFoXDTI1MDUwODA4
NDkzMFowcTELMAkGA1UEBhMCQVoxGTAXBgNVBAgMEE1vY2sgQ2VydGlmaWNhdGUx
MzAxBgNVBAoMKkV4dGVybmFsIFNlY3JldHMgT3BlcmF0b3IgTW9jayBDZXJ0aWZp
Y2F0ZTESMBAGA1UEAwwJTW9jayBDZXJ0MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJB
ALkU1YgMk1Dk149F/HsHA0TjzLwfDa9tT0cfqA1u0hoJkb2r9jdWUyiugGaEz/PU
TGWrvp8aiXPrGuu5Y6PY27ECAwEAAaMhMB8wHQYDVR0OBBYEFAMB0YwnYjUm00og
kGce8Yhr4I03MA0GCSqGSIb3DQEBCwUAA0EAr0BMs/3hIOdZc0WHZUCTZ0GGor3G
ViYUPHOw8z6UZGPGN6qiAejmkT6uP3LkkSW+7TIIQ1pkQxcn5xfFJXBexw==
-----END CERTIFICATE-----
-----BEGIN PRIVATE KEY-----
MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAuRTViAyTUOTXj0X8
ewcDROPMvB8Nr21PRx+oDW7SGgmRvav2N1ZTKK6AZoTP89RMZau+nxqJc+sa67lj
o9jbsQIDAQABAkA35CnDpwCJykGqW5kuUeTT1fMK0FnioyDwuoeWXuQFxmB6Md89
+ABxyjAt3nmwRRVBrVFdNibb9asR5KFHwn1NAiEA4NlrSnJrY1xODIjEXf0fLTwu
wpyUO1lX585OjYDiOYsCIQDSuP4ttH/1Hg3f9veEE4RgDEk+QcisrzF8q4Oa5sDP
MwIgfejiTtcR0ZsPza8Mn0EuIyuPV8VMsItQUWtSy6R/ig8CIQC86cBmNUXp+HGz
8fLg46ZvfVREjjFcLwwMmq83tdvxZQIgPAbezuRCrduH19xgMO8BXndS5DAovgvE
/MpQnEyQtVA=
-----END PRIVATE KEY-----
`
func TestNewClientManagedIdentityNoNeedForCredentials(t *testing.T) {
namespace := "internal"
identityID := "1234"
@ -405,7 +431,7 @@ func TestAuth(t *testing.T) {
},
},
{
name: "correct cluster secret store",
name: "correct cluster secret store with ClientSecret",
objects: []client.Object{&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "password",
@ -432,6 +458,94 @@ func TestAuth(t *testing.T) {
},
},
},
{
name: "bad config: both clientSecret and clientCredentials are configured",
expErr: "both clientSecret and clientCredentials set",
objects: []client.Object{&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "password",
Namespace: "foo",
},
Data: map[string][]byte{
"id": []byte("foo"),
"certificate": []byte("bar"),
"secret": []byte("bar"),
},
}},
store: &esv1beta1.ClusterSecretStore{
TypeMeta: metav1.TypeMeta{
Kind: esv1beta1.ClusterSecretStoreKind,
},
Spec: esv1beta1.SecretStoreSpec{Provider: &esv1beta1.SecretStoreProvider{}},
},
provider: &esv1beta1.AzureKVProvider{
AuthType: &authType,
VaultURL: &vaultURL,
TenantID: pointer.To("mytenant"),
AuthSecretRef: &esv1beta1.AzureKVAuth{
ClientID: &v1.SecretKeySelector{Name: "password", Namespace: pointer.To("foo"), Key: "id"},
ClientCertificate: &v1.SecretKeySelector{Name: "password", Namespace: pointer.To("foo"), Key: "certificate"},
ClientSecret: &v1.SecretKeySelector{Name: "password", Namespace: pointer.To("foo"), Key: "secret"},
},
},
},
{
name: "bad config: no valid client certificate in pem file",
expErr: "failed to get oauth token from certificate auth: failed to decode certificate: no certificate found in PEM file",
objects: []client.Object{&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "password",
Namespace: "foo",
},
Data: map[string][]byte{
"id": []byte("foo"),
"certificate": []byte("bar"),
},
}},
store: &esv1beta1.ClusterSecretStore{
TypeMeta: metav1.TypeMeta{
Kind: esv1beta1.ClusterSecretStoreKind,
},
Spec: esv1beta1.SecretStoreSpec{Provider: &esv1beta1.SecretStoreProvider{}},
},
provider: &esv1beta1.AzureKVProvider{
AuthType: &authType,
VaultURL: &vaultURL,
TenantID: pointer.To("mytenant"),
AuthSecretRef: &esv1beta1.AzureKVAuth{
ClientID: &v1.SecretKeySelector{Name: "password", Namespace: pointer.To("foo"), Key: "id"},
ClientCertificate: &v1.SecretKeySelector{Name: "password", Namespace: pointer.To("foo"), Key: "certificate"},
},
},
},
{
name: "correct configuration with certificate authentication",
objects: []client.Object{&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "password",
Namespace: "foo",
},
Data: map[string][]byte{
"id": []byte("foo"),
"certificate": []byte(mockCertificate),
},
}},
store: &esv1beta1.ClusterSecretStore{
TypeMeta: metav1.TypeMeta{
Kind: esv1beta1.ClusterSecretStoreKind,
},
Spec: esv1beta1.SecretStoreSpec{Provider: &esv1beta1.SecretStoreProvider{}},
},
provider: &esv1beta1.AzureKVProvider{
AuthType: &authType,
VaultURL: &vaultURL,
TenantID: pointer.To("mytenant"),
AuthSecretRef: &esv1beta1.AzureKVAuth{
ClientID: &v1.SecretKeySelector{Name: "password", Namespace: pointer.To("foo"), Key: "id"},
ClientCertificate: &v1.SecretKeySelector{Name: "password", Namespace: pointer.To("foo"), Key: "certificate"},
},
},
},
} {
t.Run(row.name, func(t *testing.T) {
k8sClient := clientfake.NewClientBuilder().WithObjects(row.objects...).Build()

View file

@ -0,0 +1,121 @@
// /*
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// */
package keyvault
import (
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"github.com/Azure/go-autorest/autorest"
"github.com/Azure/go-autorest/autorest/adal"
"github.com/Azure/go-autorest/autorest/azure"
)
// ClientInMemoryCertificateConfig struct includes a Certificate field to hold the certificate data as a byte slice.
type ClientInMemoryCertificateConfig struct {
ClientID string
Certificate []byte // Certificate data as a byte slice
TenantID string
AuxTenants []string
AADEndpoint string
Resource string
}
func NewClientInMemoryCertificateConfig(clientID string, certificate []byte, tenantID string) ClientInMemoryCertificateConfig {
return ClientInMemoryCertificateConfig{
ClientID: clientID,
Certificate: certificate,
TenantID: tenantID,
Resource: azure.PublicCloud.ResourceManagerEndpoint,
AADEndpoint: azure.PublicCloud.ActiveDirectoryEndpoint,
}
}
// ServicePrincipalToken creates a adal.ServicePrincipalToken from client certificate using the certificate byte slice.
func (ccc ClientInMemoryCertificateConfig) ServicePrincipalToken() (*adal.ServicePrincipalToken, error) {
oauthConfig, err := adal.NewOAuthConfig(ccc.AADEndpoint, ccc.TenantID)
if err != nil {
return nil, err
}
// Use the byte slice directly instead of reading from a file
certificate, rsaPrivateKey, err := loadCertificateFromBytes(ccc.Certificate)
if err != nil {
return nil, fmt.Errorf("failed to decode certificate: %w", err)
}
return adal.NewServicePrincipalTokenFromCertificate(*oauthConfig, ccc.ClientID, certificate, rsaPrivateKey, ccc.Resource)
}
func loadCertificateFromBytes(certificateBytes []byte) (*x509.Certificate, *rsa.PrivateKey, error) {
var cert *x509.Certificate
var privateKey *rsa.PrivateKey
var err error
// Extract certificate and private key
for {
block, rest := pem.Decode(certificateBytes)
if block == nil {
break
}
if block.Type == "CERTIFICATE" {
cert, err = x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, nil, fmt.Errorf("failed to parse PEM certificate: %w", err)
}
} else {
privateKey, err = parsePrivateKey(block.Bytes)
if err != nil {
return nil, nil, fmt.Errorf("failed to extract private key from PEM certificate: %w", err)
}
}
certificateBytes = rest
}
if cert == nil {
return nil, nil, errors.New("no certificate found in PEM file")
}
if privateKey == nil {
return nil, nil, errors.New("no private key found in PEM file")
}
return cert, privateKey, nil
}
func parsePrivateKey(der []byte) (*rsa.PrivateKey, error) {
if key, err := x509.ParsePKCS1PrivateKey(der); err == nil {
return key, nil
}
if key, err := x509.ParsePKCS8PrivateKey(der); err == nil {
switch key := key.(type) {
case *rsa.PrivateKey:
return key, nil
default:
return nil, errors.New("found unknown private key type in PKCS#8 wrapping")
}
}
return nil, errors.New("failed to parse private key")
}
// Implementation of the AuthorizerConfig interface.
func (ccc ClientInMemoryCertificateConfig) Authorizer() (autorest.Authorizer, error) {
spToken, err := ccc.ServicePrincipalToken()
if err != nil {
return nil, fmt.Errorf("failed to get oauth token from certificate auth: %w", err)
}
return autorest.NewBearerAuthorizer(spToken), nil
}