diff --git a/pkg/azure/access/factory.go b/pkg/azure/access/factory.go index 980746ef..a5fc31eb 100644 --- a/pkg/azure/access/factory.go +++ b/pkg/azure/access/factory.go @@ -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, diff --git a/pkg/azure/access/types.go b/pkg/azure/access/types.go index 70a133c0..158e2219 100644 --- a/pkg/azure/access/types.go +++ b/pkg/azure/access/types.go @@ -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 } diff --git a/pkg/azure/api/providerspec.go b/pkg/azure/api/providerspec.go index 9c4caa95..c5df3e30 100644 --- a/pkg/azure/api/providerspec.go +++ b/pkg/azure/api/providerspec.go @@ -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. diff --git a/pkg/azure/api/validation/validation.go b/pkg/azure/api/validation/validation.go index 21f6e2b0..51364b2c 100644 --- a/pkg/azure/api/validation/validation.go +++ b/pkg/azure/api/validation/validation.go @@ -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])) { diff --git a/pkg/azure/api/validation/validation_test.go b/pkg/azure/api/validation/validation_test.go index 747c9e1d..74230d4d 100644 --- a/pkg/azure/api/validation/validation_test.go +++ b/pkg/azure/api/validation/validation_test.go @@ -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")})), @@ -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 { @@ -554,7 +561,7 @@ 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) @@ -562,6 +569,9 @@ func createSecret(clientID, clientSecret, subscriptionID, tenantID, userData str if !utils.IsEmptyString(clientSecret) { data["clientSecret"] = encodeAndConvertToBytes(clientSecret) } + if !utils.IsEmptyString(workloadIdentityTokenFile) { + data["workloadIdentityTokenFile"] = encodeAndConvertToBytes(workloadIdentityTokenFile) + } if !utils.IsEmptyString(subscriptionID) { data["subscriptionID"] = encodeAndConvertToBytes(subscriptionID) } diff --git a/pkg/azure/provider/helpers/connectconfig.go b/pkg/azure/provider/helpers/connectconfig.go index 0aca7525..44ca08c6 100644 --- a/pkg/azure/provider/helpers/connectconfig.go +++ b/pkg/azure/provider/helpers/connectconfig.go @@ -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" @@ -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 } @@ -70,4 +73,4 @@ func DetermineAzureCloudConfiguration(cloudConfiguration *api.CloudConfiguration } // Fallback return cloud.AzurePublic -} \ No newline at end of file +}