Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[GEP-26] Add workload identity support #167

Merged
merged 5 commits into from
Nov 13, 2024
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
11 changes: 11 additions & 0 deletions pkg/azure/access/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ func NewDefaultAccessFactory() Factory {

// GetDefaultTokenCredentials provides the azure token credentials using the ConnectConfig passed as an argument.
func GetDefaultTokenCredentials(connectConfig ConnectConfig) (azcore.TokenCredential, error) {
if len(connectConfig.WorkloadIdentityTokenFile) > 0 {
return azidentity.NewWorkloadIdentityCredential(
&azidentity.WorkloadIdentityCredentialOptions{
TenantID: connectConfig.TenantID,
ClientID: connectConfig.ClientID,
TokenFilePath: connectConfig.WorkloadIdentityTokenFile,
ClientOptions: connectConfig.ClientOptions,
},
)
}

return azidentity.NewClientSecretCredential(
connectConfig.TenantID,
connectConfig.ClientID,
Expand Down
6 changes: 5 additions & 1 deletion pkg/azure/access/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,12 @@ type ConnectConfig struct {
TenantID string
// ClientID is a unique identity assigned by azure active directory to an application.
ClientID string
// ClientSecret is a certificate issues for the ClientID.
// ClientSecret is a certificate issued for the ClientID.
// This field is mutually exclusive with WorkloadIdentityTokenFile.
ClientSecret string
// WorkloadIdentityTokenFile is the file containing a federated token for authentication against Azure.
// This field is mutually exclusive with ClientSecret.
WorkloadIdentityTokenFile string
// ClientOptions are the options to use when connecting with clients.
ClientOptions policy.ClientOptions
}
Expand Down
3 changes: 3 additions & 0 deletions pkg/azure/api/providerspec.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ const (
ClientID string = "clientID"
// ClientSecret is a constant for a key name that is part of the Azure cloud credentials.
ClientSecret string = "clientSecret"
// WorkloadIdentityTokenFile is a constant for a key name that is part of the Azure cloud credentials.
// It identifies a path to a file that contains a token that can be used for authentication against Azure.
WorkloadIdentityTokenFile string = "workloadIdentityTokenFile"
// SubscriptionID is a constant for a key name that is part of the Azure cloud credentials.
SubscriptionID string = "subscriptionID"
// TenantID is a constant for a key name that is part of the Azure cloud credentials.
Expand Down
13 changes: 11 additions & 2 deletions pkg/azure/api/validation/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,17 @@ func ValidateProviderSecret(secret *corev1.Secret) field.ErrorList {
allErrs = append(allErrs, field.Required(secretDataPath.Child("clientID"), "must provide clientID"))
}

if utils.IsEmptyString(string(secret.Data[api.ClientSecret])) && utils.IsEmptyString(string(secret.Data[api.AzureClientSecret])) && utils.IsEmptyString(string(secret.Data[api.AzureAlternativeClientSecret])) {
allErrs = append(allErrs, field.Required(secretDataPath.Child("clientSecret"), "must provide clientSecret"))
var (
emptyClientSecret = utils.IsEmptyString(string(secret.Data[api.ClientSecret])) &&
utils.IsEmptyString(string(secret.Data[api.AzureClientSecret])) &&
utils.IsEmptyString(string(secret.Data[api.AzureAlternativeClientSecret]))
emptyWorkloadIdentityTokenFile = utils.IsEmptyString(string(secret.Data[api.WorkloadIdentityTokenFile]))
)

if !emptyClientSecret && !emptyWorkloadIdentityTokenFile {
allErrs = append(allErrs, field.Required(secretDataPath.Child("clientSecret"), "clientSecret is mutually exclusive with workloadIdentityTokenFile"))
} else if emptyClientSecret && emptyWorkloadIdentityTokenFile {
allErrs = append(allErrs, field.Required(secretDataPath.Child("clientSecret"), "must provide clientSecret or workloadIdentityTokenFile"))
}

if utils.IsEmptyString(string(secret.Data[api.SubscriptionID])) && utils.IsEmptyString(string(secret.Data[api.AzureSubscriptionID])) && utils.IsEmptyString(string(secret.Data[api.AzureAlternativeSubscriptionID])) {
Expand Down
60 changes: 35 additions & 25 deletions pkg/azure/api/validation/validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,59 +30,65 @@ func TestValidateProviderSecret(t *testing.T) {
* To generate uuid use: `uuidgen | awk '{print tolower($0)}'`
* To generate random string using allowed characters and specified length use: `cat /dev/urandom | LC_ALL=C tr -dc 'a-zA-Z0-9~' | fold -w 50 | head -n 1`
*/
testClientID = "c9f8e78f-eba7-4d2d-97fe-ea4679dbbe63"
testClientSecret = "to6D2mXsZ~lNJsUi0H5lZsRgrh7FlWMTXdTfeKaMO8fCbKmUYE"
testSubscriptionID = "8edcc1ad-04bc-419c-ad63-1a989956d466"
testTenantID = "010bd0ff-5eae-446e-aea9-c1eac72e9c77"
testUserData = "May the force be with you"
testClientID = "c9f8e78f-eba7-4d2d-97fe-ea4679dbbe63"
testClientSecret = "to6D2mXsZ~lNJsUi0H5lZsRgrh7FlWMTXdTfeKaMO8fCbKmUYE"
testWorkloadIdentityTokenFile = "/tmp/test/token"
testSubscriptionID = "8edcc1ad-04bc-419c-ad63-1a989956d466"
testTenantID = "010bd0ff-5eae-446e-aea9-c1eac72e9c77"
testUserData = "May the force be with you"
)

table := []struct {
description string
clientID string
clientSecret string
subscriptionID string
tenantID string
testUserData string
expectedErrors int
matcher gomegatypes.GomegaMatcher
description string
clientID string
clientSecret string
workloadIdentityTokenFile string
subscriptionID string
tenantID string
testUserData string
expectedErrors int
matcher gomegatypes.GomegaMatcher
}{
{
"should forbid empty clientID",
"", testClientSecret, testSubscriptionID, testTenantID, testUserData, 1,
"", testClientSecret, "", testSubscriptionID, testTenantID, testUserData, 1,
ConsistOf(PointTo(MatchFields(IgnoreExtras, Fields{"Type": Equal(field.ErrorTypeRequired), "Field": Equal("data.clientID")}))),
},
// just testing one field with spaces. handling for spaces for all required fields is done the same way.
{"should forbid clientID when it only has spaces",
" ", testClientSecret, testSubscriptionID, testTenantID, testUserData, 1,
" ", testClientSecret, "", testSubscriptionID, testTenantID, testUserData, 1,
ConsistOf(PointTo(MatchFields(IgnoreExtras, Fields{"Type": Equal(field.ErrorTypeRequired), "Field": Equal("data.clientID")}))),
},
{"should forbid empty clientSecret",
testClientID, "", testSubscriptionID, testTenantID, testUserData, 1,
{"should forbid empty clientSecret and workloadIdentityTokenFile",
testClientID, "", "", testSubscriptionID, testTenantID, testUserData, 1,
ConsistOf(PointTo(MatchFields(IgnoreExtras, Fields{"Type": Equal(field.ErrorTypeRequired), "Field": Equal("data.clientSecret")}))),
},
{"should forbid setting both clientSecret and workloadIdentityTokenFile",
testClientID, testClientSecret, testWorkloadIdentityTokenFile, testSubscriptionID, testTenantID, testUserData, 1,
ConsistOf(PointTo(MatchFields(IgnoreExtras, Fields{"Type": Equal(field.ErrorTypeRequired), "Field": Equal("data.clientSecret")}))),
},
{"should forbid empty subscriptionID",
testClientID, testClientSecret, "", testTenantID, testUserData, 1,
testClientID, testClientSecret, "", "", testTenantID, testUserData, 1,
ConsistOf(PointTo(MatchFields(IgnoreExtras, Fields{"Type": Equal(field.ErrorTypeRequired), "Field": Equal("data.subscriptionID")}))),
},
{"should forbid empty tenantID",
testClientID, testClientSecret, testSubscriptionID, "", testUserData, 1,
testClientID, testClientSecret, "", testSubscriptionID, "", testUserData, 1,
ConsistOf(PointTo(MatchFields(IgnoreExtras, Fields{"Type": Equal(field.ErrorTypeRequired), "Field": Equal("data.tenantID")}))),
},
{
"should forbid empty userData",
testClientID, testClientSecret, testSubscriptionID, testTenantID, "", 1,
testClientID, testClientSecret, "", testSubscriptionID, testTenantID, "", 1,
ConsistOf(PointTo(MatchFields(IgnoreExtras, Fields{"Type": Equal(field.ErrorTypeRequired), "Field": Equal("data.userData")}))),
},
{"should forbid empty clientID and tenantID",
"", testClientSecret, testSubscriptionID, "", testUserData, 2,
"", testClientSecret, "", testSubscriptionID, "", testUserData, 2,
ConsistOf(
PointTo(MatchFields(IgnoreExtras, Fields{"Type": Equal(field.ErrorTypeRequired), "Field": Equal("data.clientID")})),
PointTo(MatchFields(IgnoreExtras, Fields{"Type": Equal(field.ErrorTypeRequired), "Field": Equal("data.tenantID")})),
),
},
{"should forbid when all required fields are absent",
"", "", "", "", "", 5,
"", "", "", "", "", "", 5,
ConsistOf(
PointTo(MatchFields(IgnoreExtras, Fields{"Type": Equal(field.ErrorTypeRequired), "Field": Equal("data.clientID")})),
PointTo(MatchFields(IgnoreExtras, Fields{"Type": Equal(field.ErrorTypeRequired), "Field": Equal("data.clientSecret")})),
Expand All @@ -91,13 +97,14 @@ func TestValidateProviderSecret(t *testing.T) {
PointTo(MatchFields(IgnoreExtras, Fields{"Type": Equal(field.ErrorTypeRequired), "Field": Equal("data.userData")})),
),
},
{"should succeed when all required fields are present", testClientID, testClientSecret, testSubscriptionID, testTenantID, testUserData, 0, nil},
{"should succeed when all required fields are present (w/o workloadIdentityTokenFile)", testClientID, testClientSecret, "", testSubscriptionID, testTenantID, testUserData, 0, nil},
{"should succeed when all required fields are present (w/o clientSecret)", testClientID, "", testWorkloadIdentityTokenFile, testSubscriptionID, testTenantID, testUserData, 0, nil},
}

g := NewWithT(t)
for _, entry := range table {
t.Run(entry.description, func(_ *testing.T) {
secret := createSecret(entry.clientID, entry.clientSecret, entry.subscriptionID, entry.tenantID, entry.testUserData)
secret := createSecret(entry.clientID, entry.clientSecret, entry.workloadIdentityTokenFile, entry.subscriptionID, entry.tenantID, entry.testUserData)
errList := ValidateProviderSecret(secret)
g.Expect(len(errList)).To(Equal(entry.expectedErrors))
if entry.matcher != nil {
Expand Down Expand Up @@ -554,14 +561,17 @@ func TestValidateTags(t *testing.T) {
))
}

func createSecret(clientID, clientSecret, subscriptionID, tenantID, userData string) *corev1.Secret {
func createSecret(clientID, clientSecret, workloadIdentityTokenFile, subscriptionID, tenantID, userData string) *corev1.Secret {
data := make(map[string][]byte, 4)
if !utils.IsEmptyString(clientID) {
data["clientID"] = encodeAndConvertToBytes(clientID)
}
if !utils.IsEmptyString(clientSecret) {
data["clientSecret"] = encodeAndConvertToBytes(clientSecret)
}
if !utils.IsEmptyString(workloadIdentityTokenFile) {
data["workloadIdentityTokenFile"] = encodeAndConvertToBytes(workloadIdentityTokenFile)
}
if !utils.IsEmptyString(subscriptionID) {
data["subscriptionID"] = encodeAndConvertToBytes(subscriptionID)
}
Expand Down
27 changes: 15 additions & 12 deletions pkg/azure/provider/helpers/connectconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ package helpers

import (
"fmt"
"strings"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud"
"strings"

"github.com/gardener/machine-controller-manager-provider-azure/pkg/azure/api/validation"
"github.com/gardener/machine-controller-manager/pkg/util/provider/machinecodes/codes"
Expand All @@ -26,19 +27,21 @@ func ValidateSecretAndCreateConnectConfig(secret *corev1.Secret, cloudConfigurat
}

var (
subscriptionID = ExtractCredentialsFromData(secret.Data, api.SubscriptionID, api.AzureSubscriptionID)
tenantID = ExtractCredentialsFromData(secret.Data, api.TenantID, api.AzureTenantID)
clientID = ExtractCredentialsFromData(secret.Data, api.ClientID, api.AzureClientID)
clientSecret = ExtractCredentialsFromData(secret.Data, api.ClientSecret, api.AzureClientSecret)
azCloudConfiguration = DetermineAzureCloudConfiguration(cloudConfiguration)
subscriptionID = ExtractCredentialsFromData(secret.Data, api.SubscriptionID, api.AzureSubscriptionID)
tenantID = ExtractCredentialsFromData(secret.Data, api.TenantID, api.AzureTenantID)
clientID = ExtractCredentialsFromData(secret.Data, api.ClientID, api.AzureClientID)
clientSecret = ExtractCredentialsFromData(secret.Data, api.ClientSecret, api.AzureClientSecret)
workloadIdentityTokenFile = ExtractCredentialsFromData(secret.Data, api.WorkloadIdentityTokenFile)
azCloudConfiguration = DetermineAzureCloudConfiguration(cloudConfiguration)
)

return access.ConnectConfig{
SubscriptionID: subscriptionID,
TenantID: tenantID,
ClientID: clientID,
ClientSecret: clientSecret,
ClientOptions: azcore.ClientOptions{Cloud: azCloudConfiguration},
SubscriptionID: subscriptionID,
TenantID: tenantID,
ClientID: clientID,
ClientSecret: clientSecret,
WorkloadIdentityTokenFile: workloadIdentityTokenFile,
ClientOptions: azcore.ClientOptions{Cloud: azCloudConfiguration},
}, nil
}

Expand Down Expand Up @@ -70,4 +73,4 @@ func DetermineAzureCloudConfiguration(cloudConfiguration *api.CloudConfiguration
}
// Fallback
return cloud.AzurePublic
}
}