From 5372a733b73b7b999a1e20dbbadca0a8a5985d86 Mon Sep 17 00:00:00 2001 From: Lionel ORRY Date: Thu, 10 Jul 2025 16:24:35 +0200 Subject: [PATCH 1/3] MS Entra Workload Identities implementation --- README.md | 16 +++++++++++++-- config/config.go | 47 ++++++++++++++++++++++++++++++++++++-------- testdata/config.toml | 13 ++++++++++-- 3 files changed, 64 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 74a4dc9..f53515a 100644 --- a/README.md +++ b/README.md @@ -23,15 +23,27 @@ Copy the binary on the same system where garm is running, and [point to it in th The config file for this external provider is a simple toml used to configure the azure credentials it needs to spin up virtual machines. -For now, only service principles credentials and azure managed identity are supported. An example can be found [in the testdata folder](./testdata/config.toml). +For now, service principals, azure managed identities and workload identities are supported. An example can be found [in the testdata folder](./testdata/config.toml). ```toml location = "westeurope" +use_ephemeral_storage = true +virtual_network_cidr = "10.0.0.0/16" +use_accelerated_networking = true [credentials] subscription_id = "sample_sub_id" - # The service principle service credentials can be used when azure managed identity + # The workload identity can be used when other methods are not available + # or if a more precise selection of the workload on which to apply permissions is needed. + # This seems to be the latest recommended method from Azure. + # More information here: https://learn.microsoft.com/en-us/entra/workload-id/workload-identities-overview + [credentials.workload_identity] + tenant_id = "sample_tenant_id" + client_id = "sample_client_id" + federated_token_file = "/path/to/federated_token_file" + + # The service principal credentials can be used when azure managed identity # is not available. [credentials.service_principal] # you can create a SP using: diff --git a/config/config.go b/config/config.go index 9bd43f2..df4b2cf 100644 --- a/config/config.go +++ b/config/config.go @@ -17,6 +17,7 @@ package config import ( "fmt" "net" + "os" "regexp" "github.com/Azure/azure-sdk-for-go/sdk/azcore" @@ -76,9 +77,10 @@ func (c *Config) Validate() error { } type Credentials struct { - SubscriptionID string `toml:"subscription_id"` - SPCredentials ServicePrincipalCredentials `toml:"service_principal"` - ManagedIdentity ManagedIdentityCredentials `toml:"managed_identity"` + SubscriptionID string `toml:"subscription_id"` + SPCredentials ServicePrincipalCredentials `toml:"service_principal"` + WorkloadIdentity WorkloadIdentityCredentials `toml:"workload_identity"` + ManagedIdentity ManagedIdentityCredentials `toml:"managed_identity"` // ClientOptions is the azure identity client options that will be used to authenticate // against an azure cloud. This is a heavy handed approach for now, defining the entire // ClientOptions here, but should allow users to use this provider with AzureStack or any @@ -117,17 +119,38 @@ func (c Credentials) GetCredentials() (azcore.TokenCredential, error) { creds = append(creds, spCreds) } - o := &azidentity.ManagedIdentityCredentialOptions{ClientOptions: c.ClientOptions} + // Let's try with Workload Identity first, since this is the preferred method nowadays. + if c.WorkloadIdentity.ClientID != "" { + wiOpts := &azidentity.WorkloadIdentityCredentialOptions{ClientOptions: c.ClientOptions} + wiOpts.ClientID = c.WorkloadIdentity.ClientID + if c.WorkloadIdentity.TenantID != "" { + wiOpts.TenantID = c.WorkloadIdentity.TenantID + } + // NOTE: AuthorityHost is ignored for now + if c.WorkloadIdentity.FederatedTokenFile != "" { + if _, err := os.Stat(c.WorkloadIdentity.FederatedTokenFile); err != nil { + return nil, fmt.Errorf("federated token file %s does not exist: %w", c.WorkloadIdentity.FederatedTokenFile, err) + } + wiOpts.TokenFilePath = c.WorkloadIdentity.FederatedTokenFile + } + wiCred, err := azidentity.NewWorkloadIdentityCredential(wiOpts) + if err == nil { + creds = append(creds, wiCred) + } + } + + // After Workload Identity is configured (or not), we can try Managed Identity. + miOpts := &azidentity.ManagedIdentityCredentialOptions{ClientOptions: c.ClientOptions} if c.ManagedIdentity.ClientID != "" { - o.ID = azidentity.ClientID(c.ManagedIdentity.ClientID) + miOpts.ID = azidentity.ClientID(c.ManagedIdentity.ClientID) } - miCred, err := azidentity.NewManagedIdentityCredential(o) + miCred, err := azidentity.NewManagedIdentityCredential(miOpts) if err == nil { creds = append(creds, miCred) } if len(creds) == 0 { - return nil, fmt.Errorf("failed to get credentials") + return nil, fmt.Errorf("failed to get credentials: %w", err) } chain, err := azidentity.NewChainedTokenCredential(creds, nil) @@ -154,7 +177,7 @@ func (c ServicePrincipalCredentials) Validate() error { } if c.ClientSecret == "" { - return fmt.Errorf("missing subscription_id") + return fmt.Errorf("missing client_secret") } return nil @@ -173,6 +196,14 @@ func (c ServicePrincipalCredentials) Auth(opts azcore.ClientOptions) (azcore.Tok return cred, nil } +type WorkloadIdentityCredentials struct { + TenantID string `toml:"tenant_id"` + ClientID string `toml:"client_id"` + FederatedTokenFile string `toml:"federated_token_file"` + // AuthorityHost is not handled yet. + // AuthorityHost string `toml:"authority_host"` +} + type ManagedIdentityCredentials struct { ClientID string `toml:"client_id"` } diff --git a/testdata/config.toml b/testdata/config.toml index 8d8aa19..4ee7642 100644 --- a/testdata/config.toml +++ b/testdata/config.toml @@ -6,7 +6,16 @@ use_accelerated_networking = true [credentials] subscription_id = "sample_sub_id" - # The service principle service credentials can be used when azure managed identity + # The workload identity can be used when other methods are not available + # or if a more precise selection of the workload on which to apply permissions is needed. + # This seems to be the latest recommended method from Azure. + # More information here: https://learn.microsoft.com/en-us/entra/workload-id/workload-identities-overview + [credentials.workload_identity] + tenant_id = "sample_tenant_id" + client_id = "sample_client_id" + federated_token_file = "/path/to/federated_token_file" + + # The service principal credentials can be used when azure managed identity # is not available. [credentials.service_principal] # you can create a SP using: @@ -19,4 +28,4 @@ subscription_id = "sample_sub_id" # sources. The client ID can be overwritten if needed. [credentials.managed_identity] # The client ID to use. This config value is optional. - client_id = "sample_client_id" \ No newline at end of file + client_id = "sample_client_id" From 6830c20c2611453c771930d8550acb150e2f798c Mon Sep 17 00:00:00 2001 From: Lionel ORRY Date: Thu, 10 Jul 2025 17:12:33 +0200 Subject: [PATCH 2/3] adding test cases --- README.md | 2 +- config/config_test.go | 69 ++++++++++++++++++++++++++++++++++--------- testdata/config.toml | 2 +- 3 files changed, 57 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index f53515a..9e28721 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ subscription_id = "sample_sub_id" client_secret = "super secret client secret" # The managed identity token source is always added to the chain of possible authentication - # sources. The client ID can be overwritten if needed. + # sources. The client ID can be overwritten if needed. [credentials.managed_identity] # The client ID to use. This config value is optional. client_id = "sample_client_id" diff --git a/config/config_test.go b/config/config_test.go index 5fd1d00..3053c66 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -157,20 +157,25 @@ func TestNewConfig(t *testing.T) { [credentials] subscription_id = "sample_sub_id" - # The service principle service credentials can be used when azure managed identity - # is not available. - [credentials.service_principal] - # you can create a SP using: - # az ad sp create-for-rbac --scopes /subscriptions/ --role Contributor - tenant_id = "sample_tenant_id" - client_id = "sample_client_id" - client_secret = "super secret client secret" - - # The managed identity token source is always added to the chain of possible authentication - # sources. The client ID can be overwritten if needed. - [credentials.managed_identity] - # The client ID to use. This config value is optional. - client_id = "sample_client_id" + [credentials.workload_identity] + tenant_id = "sample_tenant_id" + client_id = "sample_client_id" + federated_token_file = "/dev/null" + + # The service principle service credentials can be used when azure managed identity + # is not available. + [credentials.service_principal] + # you can create a SP using: + # az ad sp create-for-rbac --scopes /subscriptions/ --role Contributor + tenant_id = "sample_tenant_id" + client_id = "sample_client_id" + client_secret = "super secret client secret" + + # The managed identity token source is always added to the chain of possible authentication + # sources. The client ID can be overwritten if needed. + [credentials.managed_identity] + # The client ID to use. This config value is optional. + client_id = "sample_client_id" ` // Create a temporary file @@ -194,6 +199,9 @@ func TestNewConfig(t *testing.T) { require.Equal(t, "sample_client_id", cfg.Credentials.SPCredentials.ClientID, "ClientID is not as expected") require.Equal(t, "super secret client secret", cfg.Credentials.SPCredentials.ClientSecret, "ClientSecret is not as expected") require.Equal(t, "sample_client_id", cfg.Credentials.ManagedIdentity.ClientID, "ManagedIdentity ClientID is not as expected") + require.Equal(t, "sample_tenant_id", cfg.Credentials.WorkloadIdentity.TenantID, "WorkloadIdentity TenantID is not as expected") + require.Equal(t, "sample_client_id", cfg.Credentials.WorkloadIdentity.ClientID, "WorkloadIdentity ClientID is not as expected") + require.Equal(t, "/dev/null", cfg.Credentials.WorkloadIdentity.FederatedTokenFile, "WorkloadIdentity FederatedTokenFile is not as expected") require.True(t, cfg.UseEphemeralStorage, "UseEphemeralStorage is not as expected") require.Equal(t, "10.10.0.0/24", cfg.VirtualNetworkCIDR, "VirtualNetworkCIDR is not as expected") @@ -201,3 +209,36 @@ func TestNewConfig(t *testing.T) { require.True(t, cfg.UseAcceleratedNetworking, "UseAcceleratedNetworking is not as expected") require.True(t, cfg.DisableIsolatedNetworks, "DisableIsolatedNetworks is not as expected") } + +func TestAbsentTokenFile(t *testing.T) { + mockData := ` + location = "westeurope" + use_ephemeral_storage = true + virtual_network_cidr = "10.10.0.0/24" + use_accelerated_networking = true + vnet_subnet_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-Network/providers/Microsoft.Network/virtualNetworks/vnet-Default/subnets/snet-default" + disable_isolated_networks = true + + [credentials] + subscription_id = "sample_sub_id" + + [credentials.workload_identity] + tenant_id = "sample_tenant_id" + client_id = "sample_client_id" + federated_token_file = "/path/to/non_existent_token_file" + ` + + // Create a temporary file + tmpFile, err := os.CreateTemp("", "config-*.toml") + require.NoError(t, err, "Failed to create temporary file") + defer os.Remove(tmpFile.Name()) + + _, err = tmpFile.WriteString(mockData) + require.NoError(t, err, "Failed to write to temporary file") + err = tmpFile.Close() + require.NoError(t, err, "Failed to close temporary file") + + // Use the temporary file path as the argument to NewConfig + _, err = NewConfig(tmpFile.Name()) + require.Error(t, err, "NewConfig should return an error") +} diff --git a/testdata/config.toml b/testdata/config.toml index 4ee7642..5ba41ec 100644 --- a/testdata/config.toml +++ b/testdata/config.toml @@ -25,7 +25,7 @@ subscription_id = "sample_sub_id" client_secret = "super secret client secret" # The managed identity token source is always added to the chain of possible authentication - # sources. The client ID can be overwritten if needed. + # sources. The client ID can be overwritten if needed. [credentials.managed_identity] # The client ID to use. This config value is optional. client_id = "sample_client_id" From d01de1317d3428607e63c45cb1079208435df061 Mon Sep 17 00:00:00 2001 From: Lionel ORRY Date: Fri, 11 Jul 2025 14:39:48 +0200 Subject: [PATCH 3/3] use resource from testdata --- config/config_test.go | 31 ++------------------- testdata/config_with_workload_identity.toml | 14 ++++++++++ 2 files changed, 16 insertions(+), 29 deletions(-) create mode 100644 testdata/config_with_workload_identity.toml diff --git a/config/config_test.go b/config/config_test.go index 3053c66..91b0b39 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -211,34 +211,7 @@ func TestNewConfig(t *testing.T) { } func TestAbsentTokenFile(t *testing.T) { - mockData := ` - location = "westeurope" - use_ephemeral_storage = true - virtual_network_cidr = "10.10.0.0/24" - use_accelerated_networking = true - vnet_subnet_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-Network/providers/Microsoft.Network/virtualNetworks/vnet-Default/subnets/snet-default" - disable_isolated_networks = true - - [credentials] - subscription_id = "sample_sub_id" - - [credentials.workload_identity] - tenant_id = "sample_tenant_id" - client_id = "sample_client_id" - federated_token_file = "/path/to/non_existent_token_file" - ` - - // Create a temporary file - tmpFile, err := os.CreateTemp("", "config-*.toml") - require.NoError(t, err, "Failed to create temporary file") - defer os.Remove(tmpFile.Name()) - - _, err = tmpFile.WriteString(mockData) - require.NoError(t, err, "Failed to write to temporary file") - err = tmpFile.Close() - require.NoError(t, err, "Failed to close temporary file") - - // Use the temporary file path as the argument to NewConfig - _, err = NewConfig(tmpFile.Name()) + conf, err := NewConfig("../testdata/config_with_workload_identity.toml") require.Error(t, err, "NewConfig should return an error") + require.Nil(t, conf) } diff --git a/testdata/config_with_workload_identity.toml b/testdata/config_with_workload_identity.toml new file mode 100644 index 0000000..d47c1ab --- /dev/null +++ b/testdata/config_with_workload_identity.toml @@ -0,0 +1,14 @@ +location = "westeurope" +use_ephemeral_storage = true +virtual_network_cidr = "10.10.0.0/24" +use_accelerated_networking = true +vnet_subnet_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-Network/providers/Microsoft.Network/virtualNetworks/vnet-Default/subnets/snet-default" +disable_isolated_networks = true + +[credentials] +subscription_id = "sample_sub_id" + + [credentials.workload_identity] + tenant_id = "sample_tenant_id" + client_id = "sample_client_id" + federated_token_file = "/path/to/federated_token_file"