Skip to content

Commit

Permalink
Add Auth Options: User-Managed Identity, System-Managed Identity, and…
Browse files Browse the repository at this point in the history
… AzDO Workload Identity Federation (#545)

Adding User-Managed Identity, System-Managed Identity, and AzDO Workload Identity Federation auth options

* Update templates/index.md.tmpl

Co-authored-by: Matt Dotson <[email protected]>

* Update templates/index.md.tmpl

Co-authored-by: Matt Dotson <[email protected]>

* Update templates/index.md.tmpl

Co-authored-by: Matt Dotson <[email protected]>

* Update templates/index.md.tmpl

Co-authored-by: Matt Dotson <[email protected]>

* Update templates/index.md.tmpl

Co-authored-by: Matt Dotson <[email protected]>

* Update docs/index.md

Co-authored-by: Matt Dotson <[email protected]>

* Update docs/index.md

Co-authored-by: Matt Dotson <[email protected]>

* Documentation updates per review notes

* Documentation updates per review notes

* Update docs/index.md

Co-authored-by: Matt Dotson <[email protected]>

* Update docs/index.md

Co-authored-by: Matt Dotson <[email protected]>

* Update docs/index.md

Co-authored-by: Matt Dotson <[email protected]>

* More documentation fixes

* Fixes per review comment

* Adding errors per review feedback

* Adding new auth errors and related tests

* Changing one of the expected errors to match the switch behavior

* Updating error testing to match actual (expected) behavior

* Update internal/api/client_test.go

Co-authored-by: mawasile <[email protected]>

* Update internal/api/client_test.go

Co-authored-by: mawasile <[email protected]>

* Update internal/api/client_test.go

Co-authored-by: mawasile <[email protected]>

* Update internal/api/client_test.go

Co-authored-by: mawasile <[email protected]>

* Update internal/api/client_test.go

Co-authored-by: mawasile <[email protected]>

* Update internal/api/client_test.go

Co-authored-by: mawasile <[email protected]>

---------

Co-authored-by: github-actions[bot] <[email protected]>
Co-authored-by: mawasile <[email protected]>
Co-authored-by: Matt Dotson <[email protected]>
  • Loading branch information
4 people authored Feb 5, 2025
1 parent 9e23e96 commit a731f46
Show file tree
Hide file tree
Showing 8 changed files with 439 additions and 61 deletions.
5 changes: 5 additions & 0 deletions .changes/unreleased/added-20241218.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: added
body: Added Managed identity and Workload Identity Federation authentication
time: 2024-12-18T08:58:50.826689481Z
custom:
Issue: "243"
73 changes: 61 additions & 12 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ Terraform supports a number of different methods for authenticating to Power Pla
* [Authenticating to Power Platform using the Azure CLI](#authenticating-to-power-platform-using-the-azure-cli)
* [Authenticating to Power Platform using a Service Principal with OIDC](#authenticating-to-power-platform-using-a-service-principal-with-oidc)
* [Authenticating to Power Platform using a Service Principal and a Client Secret](#authenticating-to-power-platform-using-a-service-principal-and-a-client-secret)
* [Authenticating to Power Platform using a Managed Identity](#authenticating-to-power-platform-using-a-managed-identity)
* [Authenticating to Power Platform using Azure DevOps Workload Identity Federation](#authenticating-to-power-platform-using-workload-identity-federation)

We recommend using either a Service Principal when running Terraform non-interactively (such as when running Terraform in a CI server) - and authenticating using the Azure CLI when running Terraform locally.

Expand Down Expand Up @@ -70,18 +72,18 @@ The Power Platform provider can use the [Azure CLI](https://learn.microsoft.com/

### Authenticating to Power Platform using a Service Principal with OIDC

The Power Platform provider can use a Service Principal with OpenID Connect (OIDC) to authenticate to Power Platform services. By using [Microsoft Entra's workload identity federation](https://learn.microsoft.com/entra/workload-id/workload-identity-federation) your CI/CD pipelines in GitHub or Azure DevOps can access Power Platform resources without needing to manage secrets.
The Power Platform provider can use a Service Principal with OpenID Connect (OIDC) to authenticate to Power Platform services. By using [Microsoft Entra's workload identity federation](https://learn.microsoft.com/entra/workload-id/workload-identity-federation), your CI/CD pipelines in GitHub or Azure DevOps can access Power Platform resources without needing to manage secrets.
1. [Create an app registration for the Power Platform Terraform Provider](guides/app_registration.md)
1. [Register your app registration with Power Platform](https://learn.microsoft.com/power-platform/admin/powerplatform-api-create-service-principal#registering-an-admin-management-application)
1. [Create a trust relationship between your CI/CD pipeline and the app registration](https://learn.microsoft.com/entra/workload-id/workload-identity-federation-create-trust?pivots=identity-wif-apps-methods-azp)
1. Configure the provider to use OIDC with the following code:
```terraform
provider "powerplatform" {
use_oidc = true
}
```
```terraform
provider "powerplatform" {
use_oidc = true
}
```
Additional Resources about OIDC:
Expand Down Expand Up @@ -139,9 +141,56 @@ The Power Platform provider can use a Service Principal with Client Secret to au
}
```
#### Using Environment Variables
### Authenticating to Power Platform Using a Managed Identity
The Power Platform provider can use a [Managed Identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview) (previously called Managed Service Identity, or MSI) to authenticate to Power Platform services for keyless authentication in scenarios where the provider is being executed in select Azure services, such as Microsoft-hosted or self-hosted Azure DevOps pipelines.
#### System-Managed Identity
1. [Enable system-managed identity on an Azure resource](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview)
1. Register the managed identity with the Power Platform using the Application ID from the enterprise application for the system-managed identity resource. This task can be performed using either [the Power Platform Terraform Provider itself](https://registry.terraform.io/providers/microsoft/power-platform/latest/docs/resources/admin_management_application), or [PowerShell]([Register the managed identity with the Power Platform](https://learn.microsoft.com/en-us/power-platform/admin/powershell-create-service-principal).
1. Configure the provider to use the system-managed identity. Note that no Client ID is required as the Client ID is derived from the Azure resource running the provider.
```terraform
provider "powerplatform" {
use_msi = true
}
```
#### User-Managed Identity
We recomend using Environment Variables to pass the credentials to the provider.
1. [Create a User-Managed Identity resource](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview)
1. Register the managed identity with the Power Platform using the Application ID from the enterprise application for the system-managed identity resource. This task can be performed using either [the Power Platform Terraform Provider itself](https://registry.terraform.io/providers/microsoft/power-platform/latest/docs/resources/admin_management_application), or [PowerShell]([Register the managed identity with the Power Platform](https://learn.microsoft.com/en-us/power-platform/admin/powershell-create-service-principal).
1. Configure the provider to use the System-Managed Identity. Note that this example sets the Client ID in the provider configuration, but it could also be set using the POWER_PLATFORM_CLIENT_ID environment variable.
```terraform
provider "powerplatform" {
use_msi = true
client_id = var.client_id # This should be the Client ID from the user-managed identity resource.
}
```
### Authenticating to Power Platform Using Azure DevOps Workload Identity Federation
The Power Platform provider can use [Azure DevOps Workload Identity Federation](https://devblogs.microsoft.com/devops/introduction-to-azure-devops-workload-identity-federation-oidc-with-terraform/) with Azure DeOps pipelines to authenticate to Power Platform services.
*Note: For similar hands-off authentication in GitHub and Azure DevOps, the Power Platform Provider also supports the [OIDC authentication method](#authenticating-to-power-platform-using-a-service-principal-with-oidc).*
1. Create an [App Registration](guides/app_registration.md) or a [User-Managed Identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview). This resource will be used to manage the identity federation with Azure DevOps.
1. Register the App Registration or Managed Identity with the Power Platform. This task can be performed using [the provider itself](/resources/admin_management_application.md) or [PowerShell](https://learn.microsoft.com/en-us/power-platform/admin/powershell-create-service-principal).
1. [Complete the service connection configuration in Azure and Azure DevOps](https://learn.microsoft.com/en-us/azure/devops/pipelines/release/configure-workload-identity?view=azure-devops&tabs=managed-identity). Note that Azure DevOps may automatically generate the federated credential in Azure, depending on your permissions and Azure Subscription configuration.
1. Configure the provider to use Azure DevOps Workload Identity Federation. This authentication option also requires values to be set in the ARM_OIDC_REQUEST_TOKEN and POWER_PLATFORM_AZDO_SERVICE_CONNECTION_ID environment variables, which should be configured in the AzDO pipeline itself. Note that this example sets some of the required properties in the provider configuration, but the whole configuration could also be performed using just environment variables.
```terraform
provider "powerplatform" {
tenant_id = var.tenant_id
client_id = var.client_id # The client ID for the Azure resource containing the federated credentials for Azure DevOps. Should be an App Registration or a Managed Identity.
}
```
### Using Environment Variables
We recommend using Environment Variables to pass the credentials to the provider.
| Name | Description | Default Value |
|------|-------------|---------------|
Expand All @@ -151,9 +200,10 @@ We recomend using Environment Variables to pass the credentials to the provider.
| `POWER_PLATFORM_CLOUD` | override for the cloud used (default is `public`) | |
| `POWER_PLATFORM_USE_OIDC` | if set to `true` then OIDC authentication will be used | |
| `POWER_PLATFORM_USE_CLI` | if set to `true` then Azure CLI authentication will be used | |
| `POWER_PLATFORM_CLIENT_CERTIFICATE` | The Base64 format of your certificate that will be used to certificate based authentication | |
| `POWER_PLATFORM_CLIENT_CERTIFICATE_FILE_PATH` | The path to the certificate that will be used to certificate based authentication | |
| `POWER_PLATFORM_CLIENT_CERTIFICATE_PASSWORD` | Password for the provider certificate | |
| `POWER_PLATFORM_USE_MSI` | if set to `true` then Managed Identity authentication will be used | |
| `POWER_PLATFORM_CLIENT_CERTIFICATE` | The Base64 format of your certificate that will be used for certificate-based authentication | |
| `POWER_PLATFORM_CLIENT_CERTIFICATE_FILE_PATH` | The path to the certificate that will be used for certificate-based authentication | |
| `POWER_PLATFORM_AZDO_SERVICE_CONNECTION_ID` | The GUID of the Azure DevOps service connection to be used for Azure DevOps Workload Identity Federation | |
-> Variables passed into the provider will override the environment variables.
Expand Down Expand Up @@ -192,7 +242,6 @@ More detailed examples can be found in the [Power Platform Terraform Quickstarts

A full list of released versions of the Power Platform Terraform Provider can be found [here](https://github.com/microsoft/terraform-provider-power-platform/releases). Starting from v3.0.0, a summary of the changes to the provider in each release are documented the [CHANGELOG.md file in the GitHub repository](https://github.com/microsoft/terraform-provider-power-platform/blob/main/CHANGELOG.md). This provider follows Semantic Versioning for releases. The provider version is incremented based on the type of changes included in the release.


## Contributing

Contributions to this provider are always welcome! Please see the [Contribution Guidelines](https://github.com/microsoft/terraform-provider-power-platform/)
76 changes: 76 additions & 0 deletions internal/api/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,76 @@ func (client *Auth) AuthenticateOIDC(ctx context.Context, scopes []string) (stri
return accessToken.Token, accessToken.ExpiresOn, nil
}

func (client *Auth) AuthenticateUserManagedIdentity(ctx context.Context, scopes []string) (string, time.Time, error) {
userManagedIdentityCredential, err := azidentity.NewManagedIdentityCredential(&azidentity.ManagedIdentityCredentialOptions{
ID: azidentity.ClientID(client.config.ClientId),
ClientOptions: azcore.ClientOptions{
Cloud: client.config.Cloud,
},
})
if err != nil {
return "", time.Time{}, err
}

accessToken, err := userManagedIdentityCredential.GetToken(ctx, policy.TokenRequestOptions{Scopes: scopes})
if err != nil {
return "", time.Time{}, err
}

return accessToken.Token, accessToken.ExpiresOn, nil
}

func (client *Auth) AuthenticateSystemManagedIdentity(ctx context.Context, scopes []string) (string, time.Time, error) {
systemManagedIdentityCredential, err := azidentity.NewManagedIdentityCredential(&azidentity.ManagedIdentityCredentialOptions{
ClientOptions: azcore.ClientOptions{
Cloud: client.config.Cloud,
},
})
if err != nil {
return "", time.Time{}, err
}

accessToken, err := systemManagedIdentityCredential.GetToken(ctx, policy.TokenRequestOptions{Scopes: scopes})
if err != nil {
return "", time.Time{}, err
}

return accessToken.Token, accessToken.ExpiresOn, nil
}

func (client *Auth) AuthenticateAzDOWorkloadIdentityFederation(ctx context.Context, scopes []string) (string, time.Time, error) {
if client.config.TenantId == "" {
return "", time.Time{}, fmt.Errorf("tenant ID must be provided to use Azure DevOps Workload Identity Federation")
}
if client.config.ClientId == "" {
return "", time.Time{}, fmt.Errorf("client ID must be provided to use Azure DevOps Workload Identity Federation")
}
if client.config.AzDOServiceConnectionID == "" {
return "", time.Time{}, fmt.Errorf("the Azure DevOps service connection ID could not be found")
}
if client.config.OidcRequestToken == "" {
return "", time.Time{}, fmt.Errorf("could not obtain an OIDC request token for Azure DevOps Workload Identity Federation")
}

azdoWorkloadIdentityCredential, err := azidentity.NewAzurePipelinesCredential(
client.config.TenantId,
client.config.ClientId,
client.config.AzDOServiceConnectionID,
client.config.OidcRequestToken,
&azidentity.AzurePipelinesCredentialOptions{}, // Auxiliary tenants could be defined here
)
if err != nil {
return "", time.Time{}, err
}

accessToken, err := azdoWorkloadIdentityCredential.GetToken(ctx, policy.TokenRequestOptions{Scopes: scopes})
if err != nil {
return "", time.Time{}, err
}

return accessToken.Token, accessToken.ExpiresOn, nil
}

func (w *OidcCredential) getAssertion(ctx context.Context) (string, error) {
if w.token != "" {
return w.token, nil
Expand Down Expand Up @@ -281,10 +351,16 @@ func (client *Auth) GetTokenForScopes(ctx context.Context, scopes []string) (*st
token, tokenExpiry, err = client.AuthenticateClientSecret(ctx, scopes)
case client.config.IsCliProvided():
token, tokenExpiry, err = client.AuthenticateUsingCli(ctx, scopes)
case client.config.IsAzDOWorkloadIdentityFederationProvided():
token, tokenExpiry, err = client.AuthenticateAzDOWorkloadIdentityFederation(ctx, scopes)
case client.config.IsOidcProvided():
token, tokenExpiry, err = client.AuthenticateOIDC(ctx, scopes)
case client.config.IsClientCertificateCredentialsProvided():
token, tokenExpiry, err = client.AuthenticateClientCertificate(ctx, scopes)
case client.config.IsUserManagedIdentityProvided():
token, tokenExpiry, err = client.AuthenticateUserManagedIdentity(ctx, scopes)
case client.config.IsSystemManagedIdentityProvided():
token, tokenExpiry, err = client.AuthenticateSystemManagedIdentity(ctx, scopes)
default:
return nil, errors.New("no credentials provided")
}
Expand Down
133 changes: 132 additions & 1 deletion internal/api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"context"
"net/http"
"reflect"
"strings"
"testing"
"time"

Expand All @@ -23,6 +24,7 @@ func TestUnitApiClient_GetConfig(t *testing.T) {
cfg := config.ProviderConfig{
UseCli: false,
UseOidc: false,
UseMsi: false,
TenantId: uuid.NewString(),
ClientId: uuid.NewString(),
ClientSecret: uuid.NewString(),
Expand All @@ -32,7 +34,7 @@ func TestUnitApiClient_GetConfig(t *testing.T) {
x := api.NewApiClientBase(&cfg, api.NewAuthBase(&cfg))
_, err := x.Execute(ctx, []string{"test"}, "GET", "/relativeurl", http.Header{}, nil, []int{http.StatusOK}, nil)
if err == nil {
t.Error("Expected an error for relatvieurl but got nil error")
t.Error("Expected an error for relativeurl but got nil error")
}

switch err.(type) {
Expand Down Expand Up @@ -76,3 +78,132 @@ func TestUnitSleepWithContext_HappyPath(t *testing.T) {

cancel()
}

func TestUnitApiClient_SystemManagedIdentity_No_Identity(t *testing.T) {
expectedError := "ManagedIdentityCredential: failed to authenticate a system assigned identity."

ctx := context.Background()
cfg := config.ProviderConfig{
UseMsi: true,
}

x := api.NewApiClientBase(&cfg, api.NewAuthBase(&cfg))
_, err := x.Execute(ctx, []string{"test"}, "GET", "https://api.bap.microsoft.com", http.Header{}, nil, []int{http.StatusOK}, nil)
if err == nil {
t.Error("Expected an authentication error but got nil error")
}

if !strings.HasPrefix(err.Error(), expectedError) {
t.Errorf("Expected error message '%s' but got '%s'", expectedError, err.Error())
}
}

func TestUnitApiClient_UserManagedIdentity_No_Identity(t *testing.T) {
expectedError := "ManagedIdentityCredential authentication failed. the requested identity isn't assigned to this resource"

ctx := context.Background()
cfg := config.ProviderConfig{
UseMsi: true,
ClientId: uuid.NewString(),
}

x := api.NewApiClientBase(&cfg, api.NewAuthBase(&cfg))
_, err := x.Execute(ctx, []string{"test"}, "GET", "https://api.bap.microsoft.com", http.Header{}, nil, []int{http.StatusOK}, nil)
if err == nil {
t.Error("Expected an authentication error but got nil error")
}

if !strings.HasPrefix(err.Error(), expectedError) {
t.Errorf("Expected error message '%s' but got '%s'", expectedError, err.Error())
}
}

func TestUnitApiClient_AzDOWorkloadIdentity_No_TenantId(t *testing.T) {
expectedError := "tenant ID must be provided to use Azure DevOps Workload Identity Federation"

ctx := context.Background()
cfg := config.ProviderConfig{
UseOidc: true,
AzDOServiceConnectionID: "test",
ClientId: "test",
OidcRequestToken: "test",
}

x := api.NewApiClientBase(&cfg, api.NewAuthBase(&cfg))
_, err := x.Execute(ctx, []string{"test"}, "GET", "https://api.bap.microsoft.com", http.Header{}, nil, []int{http.StatusOK}, nil)
if err == nil {
t.Error("Expected an authentication error but got nil error")
}

if !strings.HasPrefix(err.Error(), expectedError) {
t.Errorf("Expected error message '%s' but got '%s'", expectedError, err.Error())
}
}

func TestUnitApiClient_AzDOWorkloadIdentity_No_ClientId(t *testing.T) {
expectedError := "client ID must be provided to use Azure DevOps Workload Identity Federation"

ctx := context.Background()
cfg := config.ProviderConfig{
UseOidc: true,
AzDOServiceConnectionID: "test",
TenantId: "test",
OidcRequestToken: "test",
}

x := api.NewApiClientBase(&cfg, api.NewAuthBase(&cfg))
_, err := x.Execute(ctx, []string{"test"}, "GET", "https://api.bap.microsoft.com", http.Header{}, nil, []int{http.StatusOK}, nil)
if err == nil {
t.Error("Expected an authentication error but got nil error")
}

if !strings.HasPrefix(err.Error(), expectedError) {
t.Errorf("Expected error message '%s' but got '%s'", expectedError, err.Error())
}
}

// This is technically not possible with the current control flow but it's still worth testing for.
func TestUnitApiClient_AzDOWorkloadIdentity_No_AzDOServiceConnection(t *testing.T) {
expectedError := "request URL is required for OIDC credential"

ctx := context.Background()
cfg := config.ProviderConfig{
UseOidc: true,
ClientId: "test",
TenantId: "test",
OidcRequestToken: "test",
}

x := api.NewApiClientBase(&cfg, api.NewAuthBase(&cfg))
_, err := x.Execute(ctx, []string{"test"}, "GET", "https://api.bap.microsoft.com", http.Header{}, nil, []int{http.StatusOK}, nil)
if err == nil {
t.Error("Expected an authentication error but got nil error")
}

if !strings.HasPrefix(err.Error(), expectedError) {
t.Errorf("Expected error message '%s' but got '%s'", expectedError, err.Error())
}
}

// This should fall through to base OIDC.
func TestUnitApiClient_AzDOWorkloadIdentity_No_OIDC_Token(t *testing.T) {
expectedError := "could not obtain an OIDC request token for Azure DevOps Workload Identity Federation"

ctx := context.Background()
cfg := config.ProviderConfig{
UseOidc: true,
ClientId: "test",
TenantId: "test",
AzDOServiceConnectionID: "test",
}

x := api.NewApiClientBase(&cfg, api.NewAuthBase(&cfg))
_, err := x.Execute(ctx, []string{"test"}, "GET", "https://api.bap.microsoft.com", http.Header{}, nil, []int{http.StatusOK}, nil)
if err == nil {
t.Error("Expected an authentication error but got nil error")
}

if !strings.HasPrefix(err.Error(), expectedError) {
t.Errorf("Expected error message '%s' but got '%s'", expectedError, err.Error())
}
}
Loading

0 comments on commit a731f46

Please sign in to comment.