Skip to content

Commit

Permalink
Tweak token credential composition once again
Browse files Browse the repository at this point in the history
- `authorityHost` and `clientCertificateSendChain` can now be set where
  applicable.
- AZ CLI fields have been removed.
- Fallback to `ChainedTokenCredential` with `EnvironmentCredential` and
  `ManagedIdentityCredential` with defaults if no Secret is given.

Signed-off-by: Hidde Beydals <hello@hidde.co>
  • Loading branch information
hiddeco committed Mar 8, 2022
1 parent f5d7a37 commit 26f00da
Show file tree
Hide file tree
Showing 2 changed files with 125 additions and 92 deletions.
171 changes: 113 additions & 58 deletions pkg/azure/blob.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,14 @@ var (
)

const (
resourceIDField = "resourceId"
clientIDField = "clientId"
tenantIDField = "tenantId"
clientSecretField = "clientSecret"
clientCertificateField = "clientCertificate"
clientCertificatePasswordField = "clientCertificatePassword"
accountKeyField = "accountKey"

// Ref: https://docs.microsoft.com/en-us/azure/aks/kubernetes-service-principal?tabs=azure-cli#manually-create-a-service-principal
tenantField = "tenant"
appIDField = "appId"
passwordField = "password"
clientIDField = "clientId"
tenantIDField = "tenantId"
clientSecretField = "clientSecret"
clientCertificateField = "clientCertificate"
clientCertificatePasswordField = "clientCertificatePassword"
clientCertificateSendChainField = "clientCertificateSendChain"
authorityHostField = "authorityHost"
accountKeyField = "accountKey"
)

// BlobClient is a minimal Azure Blob client for fetching objects.
Expand Down Expand Up @@ -83,39 +79,53 @@ type BlobClient struct {
// - azblob.SharedKeyCredential when an `accountKey` field is found.
// The account name is extracted from the endpoint specified on the Bucket
// object.
// - azidentity.ChainedTokenCredential with azidentity.EnvironmentCredential
// and azidentity.ManagedIdentityCredential with defaults if no Secret is
// given.
//
// If no credentials are found, a simple client without credentials is
// returned.
// If no credentials are found, and the azidentity.ChainedTokenCredential can
// not be established. A simple client without credentials is returned.
func NewClient(obj *sourcev1.Bucket, secret *corev1.Secret) (c *BlobClient, err error) {
c = &BlobClient{}

// Without a Secret, we can return a simple client.
if secret == nil || len(secret.Data) == 0 {
c.ServiceClient, err = azblob.NewServiceClientWithNoCredential(obj.Spec.Endpoint, nil)
return
}

// Attempt AAD Token Credential options first.
var token azcore.TokenCredential
if token, err = tokenCredentialFromSecret(secret); err != nil {
return
}
if token != nil {
c.ServiceClient, err = azblob.NewServiceClient(obj.Spec.Endpoint, token, nil)
return

if secret != nil && len(secret.Data) > 0 {
// Attempt AAD Token Credential options first.
if token, err = tokenCredentialFromSecret(secret); err != nil {
err = fmt.Errorf("failed to create token credential from '%s' Secret: %w", secret.Name, err)
return
}
if token != nil {
c.ServiceClient, err = azblob.NewServiceClient(obj.Spec.Endpoint, token, nil)
return
}

// Fallback to Shared Key Credential.
var cred *azblob.SharedKeyCredential
if cred, err = sharedCredentialFromSecret(obj.Spec.Endpoint, secret); err != nil {
return
}
if cred != nil {
c.ServiceClient, err = azblob.NewServiceClientWithSharedKey(obj.Spec.Endpoint, cred, &azblob.ClientOptions{})
return
}
}

// Fallback to Shared Key Credential.
cred, err := sharedCredentialFromSecret(obj.Spec.Endpoint, secret)
// Compose token chain based on environment.
// This functions as a replacement for azidentity.NewDefaultAzureCredential
// to not shell out.
token, err = chainCredentialWithSecret(secret)
if err != nil {
return
err = fmt.Errorf("failed to create environment credential chain: %w", err)
return nil, err
}
if cred != nil {
c.ServiceClient, err = azblob.NewServiceClientWithSharedKey(obj.Spec.Endpoint, cred, &azblob.ClientOptions{})
if token != nil {
c.ServiceClient, err = azblob.NewServiceClient(obj.Spec.Endpoint, token, nil)
return
}

// Secret does not contain a valid set of credentials, fallback to simple client.
// Fallback to simple client.
c.ServiceClient, err = azblob.NewServiceClientWithNoCredential(obj.Spec.Endpoint, nil)
return
}
Expand All @@ -138,26 +148,19 @@ func ValidateSecret(secret *corev1.Secret) error {
}
}
}
if _, hasTenant := secret.Data[tenantField]; hasTenant {
if _, hasAppID := secret.Data[appIDField]; hasAppID {
if _, hasPassword := secret.Data[passwordField]; hasPassword {
valid = true
}
}
}
if _, hasResourceID := secret.Data[resourceIDField]; hasResourceID {
valid = true
}
if _, hasClientID := secret.Data[clientIDField]; hasClientID {
valid = true
}
if _, hasAccountKey := secret.Data[accountKeyField]; hasAccountKey {
valid = true
}
if _, hasAuthorityHost := secret.Data[authorityHostField]; hasAuthorityHost {
valid = true
}

if !valid {
return fmt.Errorf("invalid '%s' secret data: requires a '%s', '%s', or '%s' field, a combination of '%s', '%s' and '%s', or '%s', '%s' and '%s'",
secret.Name, resourceIDField, clientIDField, accountKeyField, tenantIDField, clientIDField, clientSecretField, tenantIDField, clientIDField, clientCertificateField)
return fmt.Errorf("invalid '%s' secret data: requires a '%s' or '%s' field, a combination of '%s', '%s' and '%s', or '%s', '%s' and '%s'",
secret.Name, clientIDField, accountKeyField, tenantIDField, clientIDField, clientSecretField, tenantIDField, clientIDField, clientCertificateField)
}
return nil
}
Expand Down Expand Up @@ -285,40 +288,61 @@ func (c *BlobClient) ObjectIsNotFound(err error) bool {
return false
}

// tokenCredentialsFromSecret attempts to create an azcore.TokenCredential
// based on the data fields of the given Secret. It returns, in order:
// - azidentity.ClientSecretCredential when `tenantId`, `clientId` and
// `clientSecret` fields are found.
// - azidentity.ClientSecretCredential when `tenant`, `appId` and `password`
// fields are found. To match with the JSON from:
// https://docs.microsoft.com/en-us/azure/aks/kubernetes-service-principal?tabs=azure-cli#manually-create-a-service-principal
// - azidentity.ClientCertificateCredential when `tenantId`,
// `clientCertificate` (and optionally `clientCertificatePassword`) fields
// are found.
// - azidentity.ManagedIdentityCredential for a User ID, when a `clientId`
// field but no `tenantId` is found.
// - azidentity.ManagedIdentityCredential for a Resource ID, when a
// `resourceId` field is found.
// - Nil, if no valid set of credential fields was found.
func tokenCredentialFromSecret(secret *corev1.Secret) (azcore.TokenCredential, error) {
if secret == nil {
return nil, nil
}

clientID, hasClientID := secret.Data[clientIDField]
if tenantID, hasTenantID := secret.Data[tenantIDField]; hasTenantID && hasClientID {
if clientSecret, hasClientSecret := secret.Data[clientSecretField]; hasClientSecret && len(clientSecret) > 0 {
return azidentity.NewClientSecretCredential(string(tenantID), string(clientID), string(clientSecret), nil)
opts := &azidentity.ClientSecretCredentialOptions{}
if authorityHost, hasAuthorityHost := secret.Data[authorityHostField]; hasAuthorityHost {
opts.AuthorityHost = azidentity.AuthorityHost(authorityHost)
}
return azidentity.NewClientSecretCredential(string(tenantID), string(clientID), string(clientSecret), opts)
}
if clientCertificate, hasClientCertificate := secret.Data[clientCertificateField]; hasClientCertificate && len(clientCertificate) > 0 {
certs, key, err := azidentity.ParseCertificates(clientCertificate, secret.Data[clientCertificatePasswordField])
if err != nil {
return nil, fmt.Errorf("failed to parse client certificates: %w", err)
}
return azidentity.NewClientCertificateCredential(string(tenantID), string(clientID), certs, key, nil)
}
}
if tenant, hasTenant := secret.Data[tenantField]; hasTenant {
if appId, hasAppID := secret.Data[appIDField]; hasAppID {
if password, hasPassword := secret.Data[passwordField]; hasPassword {
return azidentity.NewClientSecretCredential(string(tenant), string(appId), string(password), nil)
opts := &azidentity.ClientCertificateCredentialOptions{}
if authorityHost, hasAuthorityHost := secret.Data[authorityHostField]; hasAuthorityHost {
opts.AuthorityHost = azidentity.AuthorityHost(authorityHost)
}
if v, sendChain := secret.Data[clientCertificateSendChainField]; sendChain {
opts.SendCertificateChain = string(v) == "1" || strings.ToLower(string(v)) == "true"
}
return azidentity.NewClientCertificateCredential(string(tenantID), string(clientID), certs, key, opts)
}
}
if hasClientID {
return azidentity.NewManagedIdentityCredential(&azidentity.ManagedIdentityCredentialOptions{
ID: azidentity.ClientID(clientID),
})
}
if resourceID, hasResourceID := secret.Data[resourceIDField]; hasResourceID {
return azidentity.NewManagedIdentityCredential(&azidentity.ManagedIdentityCredentialOptions{
ID: azidentity.ResourceID(resourceID),
})
}
return nil, nil
}

// sharedCredentialFromSecret attempts to create an azblob.SharedKeyCredential
// based on the data fields of the given Secret. It returns nil if the Secret
// does not contain a valid set of credentials.
func sharedCredentialFromSecret(endpoint string, secret *corev1.Secret) (*azblob.SharedKeyCredential, error) {
if accountKey, hasAccountKey := secret.Data[accountKeyField]; hasAccountKey {
accountName, err := extractAccountNameFromEndpoint(endpoint)
Expand All @@ -330,6 +354,37 @@ func sharedCredentialFromSecret(endpoint string, secret *corev1.Secret) (*azblob
return nil, nil
}

// chainCredentialWithSecret tries to create a set of tokens, and returns an
// azidentity.ChainedTokenCredential if at least one of the following tokens was
// successfully created:
// - azidentity.EnvironmentCredential
// - azidentity.ManagedIdentityCredential
// If a Secret with an `authorityHost` is provided, this is set on the
// azidentity.EnvironmentCredentialOptions. It may return nil.
func chainCredentialWithSecret(secret *corev1.Secret) (azcore.TokenCredential, error) {
var creds []azcore.TokenCredential

credOpts := &azidentity.EnvironmentCredentialOptions{}
if secret != nil {
if authorityHost, hasAuthorityHost := secret.Data[authorityHostField]; hasAuthorityHost {
credOpts.AuthorityHost = azidentity.AuthorityHost(authorityHost)
}
}

if token, _ := azidentity.NewEnvironmentCredential(credOpts); token != nil {
creds = append(creds, token)
}
if token, _ := azidentity.NewManagedIdentityCredential(nil); token != nil {
creds = append(creds, token)
}

if len(creds) > 0 {
return azidentity.NewChainedTokenCredential(creds, nil)
}

return nil, nil
}

// extractAccountNameFromEndpoint extracts the Azure account name from the
// provided endpoint URL. It parses the endpoint as a URL, and returns the
// first subdomain as the assumed account name.
Expand Down
46 changes: 12 additions & 34 deletions pkg/azure/blob_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,6 @@ func TestValidateSecret(t *testing.T) {
secret *corev1.Secret
wantErr bool
}{
{
name: "valid SystemManagedIdentity Secret",
secret: &corev1.Secret{
Data: map[string][]byte{
resourceIDField: []byte("/some/resource/id"),
},
},
},
{
name: "valid UserManagedIdentity Secret",
secret: &corev1.Secret{
Expand Down Expand Up @@ -77,20 +69,18 @@ func TestValidateSecret(t *testing.T) {
},
},
{
name: "valid ServicePrincipal Secret",
name: "valid SharedKey Secret",
secret: &corev1.Secret{
Data: map[string][]byte{
tenantField: []byte("some-tenant-id-"),
appIDField: []byte("some-client-id-"),
passwordField: []byte("some-client-secret-"),
accountKeyField: []byte("some-account-key"),
},
},
},
{
name: "valid SharedKey Secret",
name: "valid AuthorityHost Secret",
secret: &corev1.Secret{
Data: map[string][]byte{
accountKeyField: []byte("some-account-key"),
authorityHostField: []byte("some.host.com"),
},
},
},
Expand Down Expand Up @@ -200,15 +190,6 @@ func Test_tokenCredentialFromSecret(t *testing.T) {
want azcore.TokenCredential
wantErr bool
}{
{
name: "with ResourceID field",
secret: &corev1.Secret{
Data: map[string][]byte{
resourceIDField: []byte("resource-id"),
},
},
want: &azidentity.ManagedIdentityCredential{},
},
{
name: "with ClientID field",
secret: &corev1.Secret{
Expand Down Expand Up @@ -240,17 +221,6 @@ func Test_tokenCredentialFromSecret(t *testing.T) {
},
want: &azidentity.ClientSecretCredential{},
},
{
name: "with Tenant, AppID and Password fields",
secret: &corev1.Secret{
Data: map[string][]byte{
appIDField: []byte("client-id"),
tenantField: []byte("tenant-id"),
passwordField: []byte("client-secret"),
},
},
want: &azidentity.ClientSecretCredential{},
},
{
name: "empty secret",
secret: &corev1.Secret{},
Expand Down Expand Up @@ -322,6 +292,14 @@ func Test_sharedCredentialFromSecret(t *testing.T) {
}
}

func Test_chainCredentialWithSecret(t *testing.T) {
g := NewWithT(t)

got, err := chainCredentialWithSecret(nil)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(got).To(BeAssignableToTypeOf(&azidentity.ChainedTokenCredential{}))
}

func Test_extractAccountNameFromEndpoint1(t *testing.T) {
tests := []struct {
name string
Expand Down

0 comments on commit 26f00da

Please sign in to comment.