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

feat(AzureDNS): Add support for Workload Identity #5570

Merged
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
71 changes: 70 additions & 1 deletion pkg/issuer/acme/dns/azuredns/azuredns.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ package azuredns
import (
"context"
"fmt"
"os"
"strings"

"github.com/go-logr/logr"
Expand Down Expand Up @@ -71,6 +72,35 @@ func NewDNSProviderCredentials(environment, clientID, clientSecret, subscription
}, nil
}

// getFederatedSPT prepares an SPT for a Workload Identity-enabled setup
func getFederatedSPT(env azure.Environment, options adal.ManagedIdentityOptions) (*adal.ServicePrincipalToken, error) {
// NOTE: all related environment variables are described here: https://azure.github.io/azure-workload-identity/docs/installation/mutating-admission-webhook.html
oauthConfig, err := adal.NewOAuthConfig(env.ActiveDirectoryEndpoint, os.Getenv("AZURE_TENANT_ID"))
if err != nil {
return nil, fmt.Errorf("failed to retrieve OAuth config: %v", err)
}

jwt, err := os.ReadFile(os.Getenv("AZURE_FEDERATED_TOKEN_FILE"))
if err != nil {
return nil, fmt.Errorf("failed to read a file with a federated token: %v", err)
}

// AZURE_CLIENT_ID will be empty in case azure.workload.identity/client-id annotation is not set
// Also, some users might want to use a different MSI for a particular DNS zone
// Thus, it's important to offer optional ClientID overrides
clientID := os.Getenv("AZURE_CLIENT_ID")
if options.ClientID != "" {
clientID = options.ClientID
}

token, err := adal.NewServicePrincipalTokenFromFederatedToken(*oauthConfig, clientID, string(jwt), env.ResourceManagerEndpoint)
if err != nil {
return nil, fmt.Errorf("failed to create a workload identity token: %v", err)
}

return token, nil
}

func getAuthorization(env azure.Environment, clientID, clientSecret, subscriptionID, tenantID string, ambient bool, managedIdentity *cmacme.AzureManagedIdentity) (*adal.ServicePrincipalToken, error) {
if clientID != "" {
logf.Log.V(logf.InfoLevel).Info("azuredns authenticating with clientID and secret key")
Expand All @@ -84,7 +114,7 @@ func getAuthorization(env azure.Environment, clientID, clientSecret, subscriptio
}
return spt, nil
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It took me a while to realise that the clientID parameter will be empty in the case of ambient credentials, but that in that case, the clientID may be supplied via the managedIdentity parameter.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's just the same process as for pod managed identities :)

logf.Log.V(logf.InfoLevel).Info("No ClientID found: authenticating azuredns with managed identity (MSI)")
logf.Log.V(logf.InfoLevel).Info("No ClientID found: attempting to authenticate with ambient credentials (Azure Workload Identity or Azure Managed Service Identity, in that order)")
if !ambient {
return nil, fmt.Errorf("ClientID is not set but neither `--cluster-issuer-ambient-credentials` nor `--issuer-ambient-credentials` are set. These are necessary to enable Azure Managed Identities")
}
Expand All @@ -96,6 +126,45 @@ func getAuthorization(env azure.Environment, clientID, clientSecret, subscriptio
opt.IdentityResourceID = managedIdentity.ResourceID
}

// Use Workload Identity if present
if os.Getenv("AZURE_FEDERATED_TOKEN_FILE") != "" {
spt, err := getFederatedSPT(env, opt)
if err != nil {
return nil, err
}

// adal does not offer methods to dynamically replace a federated token, thus we need to have a wrapper to make sure
// we're using up-to-date secret while requesting an access token.
// NOTE: There's no RefreshToken in the whole process (in fact, it's absent in AAD responses). An AccessToken can be
// received only in exchange for a federated token.
var refreshFunc adal.TokenRefresh = func(context context.Context, resource string) (*adal.Token, error) {
newSPT, err := getFederatedSPT(env, opt)
if err != nil {
return nil, err
}

// An AccessToken gets populated into an spt only when .Refresh() is called. Normally, it's something that happens implicitly when
// a first request to manipulate Azure resources is made. Since our goal here is only to receive a fresh AccessToken, we need to make
// an explicit call.
// .Refresh() itself results in a call to Oauth endpoint. During the process, a federated token is exchanged for an AccessToken.
// RefreshToken is absent from responses.
err = newSPT.Refresh()
if err != nil {
return nil, err
}

accessToken := newSPT.Token()

return &accessToken, nil
}

spt.SetCustomRefreshFunc(refreshFunc)

return spt, nil
}

logf.Log.V(logf.InfoLevel).Info("No Azure Workload Identity found: attempting to authenticate with an Azure Managed Service Identity (MSI)")

spt, err := adal.NewServicePrincipalTokenFromManagedIdentity(env.ServiceManagementEndpoint, &opt)
if err != nil {
return nil, fmt.Errorf("failed to create the managed service identity token: %v", err)
Expand Down
122 changes: 122 additions & 0 deletions pkg/issuer/acme/dns/azuredns/azuredns_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,16 @@ this directory.
package azuredns

import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"

"github.com/Azure/go-autorest/autorest/adal"
"github.com/Azure/go-autorest/autorest/azure"
v1 "github.com/cert-manager/cert-manager/pkg/apis/acme/v1"
"github.com/cert-manager/cert-manager/pkg/issuer/acme/dns/util"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -77,3 +83,119 @@ func TestInvalidAzureDns(t *testing.T) {
_, err := NewDNSProviderCredentials("invalid env", "cid", "secret", "", "", "", "", util.RecursiveNameservers, false, &v1.AzureManagedIdentity{})
assert.Error(t, err)
}

func populateFederatedToken(t *testing.T, filename string, content string) {
t.Helper()

f, err := os.Create(filename)
if err != nil {
assert.FailNow(t, err.Error())
}

if _, err := io.WriteString(f, content); err != nil {
assert.FailNow(t, err.Error())
}

if err := f.Close(); err != nil {
assert.FailNow(t, err.Error())
}
}

func TestGetAuthorizationFederatedSPT(t *testing.T) {
// Create a file that will be used to store a federated token
f, err := os.CreateTemp("", "")
if err != nil {
assert.FailNow(t, err.Error())
}
defer os.Remove(f.Name())

// Close the file to simplify logic within populateFederatedToken helper
if err := f.Close(); err != nil {
assert.FailNow(t, err.Error())
}

// The initial federated token is never used, so we don't care about the value yet
// Though, it's a requirement from adal to have a non-empty value set
populateFederatedToken(t, f.Name(), "random-jwt")

// Prepare environment variables adal will rely on. Skip changes for some envs if they are already defined (=live environment)
// Envs themselves are described here: https://azure.github.io/azure-workload-identity/docs/installation/mutating-admission-webhook.html
if os.Getenv("AZURE_TENANT_ID") == "" {
t.Setenv("AZURE_TENANT_ID", "fakeTenantID")
}

if os.Getenv("AZURE_CLIENT_ID") == "" {
t.Setenv("AZURE_CLIENT_ID", "fakeClientID")
}

t.Setenv("AZURE_FEDERATED_TOKEN_FILE", f.Name())

t.Run("token refresh", func(t *testing.T) {
// Basically, we want one token to be exchanged for the other (key and value respectively)
tokens := map[string]string{
"initialFederatedToken": "initialAccessToken",
"refreshedFederatedToken": "refreshedAccessToken",
}

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
assert.FailNow(t, err.Error())
}

w.Header().Set("Content-Type", "application/json")
receivedFederatedToken := r.FormValue("client_assertion")
accessToken := adal.Token{AccessToken: tokens[receivedFederatedToken]}

if err := json.NewEncoder(w).Encode(accessToken); err != nil {
assert.FailNow(t, err.Error())
}

// Expected format: http://<server>/<tenant-ID>/oauth2/token?api-version=1.0
assert.Contains(t, r.RequestURI, os.Getenv("AZURE_TENANT_ID"), "URI should contain the tenant ID exposed through env variable")

assert.Equal(t, os.Getenv("AZURE_CLIENT_ID"), r.FormValue("client_id"), "client_id should match the value exposed through env variable")
}))
defer ts.Close()

ambient := true
env := azure.Environment{ActiveDirectoryEndpoint: ts.URL, ResourceManagerEndpoint: ts.URL}
managedIdentity := &v1.AzureManagedIdentity{ClientID: ""}

spt, err := getAuthorization(env, "", "", "", "", ambient, managedIdentity)
assert.NoError(t, err)

for federatedToken, accessToken := range tokens {
populateFederatedToken(t, f.Name(), federatedToken)
assert.NoError(t, spt.Refresh(), "Token refresh failed")
assert.Equal(t, accessToken, spt.Token().AccessToken, "Access token should have been set to a value returned by the webserver")
}
})

t.Run("clientID overrides through managedIdentity section", func(t *testing.T) {
managedIdentity := &v1.AzureManagedIdentity{ClientID: "anotherClientID"}

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
assert.FailNow(t, err.Error())
}

w.Header().Set("Content-Type", "application/json")
accessToken := adal.Token{AccessToken: "abc"}

if err := json.NewEncoder(w).Encode(accessToken); err != nil {
assert.FailNow(t, err.Error())
}

assert.Equal(t, managedIdentity.ClientID, r.FormValue("client_id"), "client_id should match the value passed through managedIdentity section")
}))
defer ts.Close()

ambient := true
env := azure.Environment{ActiveDirectoryEndpoint: ts.URL, ResourceManagerEndpoint: ts.URL}

spt, err := getAuthorization(env, "", "", "", "", ambient, managedIdentity)
assert.NoError(t, err)

assert.NoError(t, spt.Refresh(), "Token refresh failed")
})
}