Skip to content

Commit

Permalink
Add support for Azure workload identity for Git and OCI repositories.
Browse files Browse the repository at this point in the history
Signed-off-by: Jagpreet Singh Tamber <[email protected]>
  • Loading branch information
jagpreetstamber committed Dec 10, 2024
1 parent 2f51067 commit 2dfe9a2
Show file tree
Hide file tree
Showing 14 changed files with 1,141 additions and 741 deletions.
4 changes: 4 additions & 0 deletions assets/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ go 1.22.0

require (
code.gitea.io/sdk/gitea v0.19.0
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.14.0
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0
github.com/Azure/kubelogin v0.1.5
github.com/Masterminds/semver/v3 v3.3.1
github.com/Masterminds/sprig/v3 v3.3.0
Expand Down Expand Up @@ -114,8 +116,6 @@ require (

require (
dario.cat/mergo v1.0.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.14.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 // indirect
github.com/aws/aws-sdk-go-v2 v1.24.1 // indirect
Expand Down
1,352 changes: 693 additions & 659 deletions pkg/apis/application/v1alpha1/generated.pb.go

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions pkg/apis/application/v1alpha1/generated.proto

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 15 additions & 1 deletion pkg/apis/application/v1alpha1/repository_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ type Repository struct {
ForceHttpBasicAuth bool `json:"forceHttpBasicAuth,omitempty" protobuf:"bytes,22,opt,name=forceHttpBasicAuth"`
// NoProxy specifies a list of targets where the proxy isn't used, applies only in cases where the proxy is applied
NoProxy string `json:"noProxy,omitempty" protobuf:"bytes,23,opt,name=noProxy"`
// UseAzureWorkloadIdentity specifies whether to use Azure Workload Identity for authentication
UseAzureWorkloadIdentity bool `json:"useAzureWorkloadIdentity,omitempty" protobuf:"bytes,24,opt,name=useAzureWorkloadIdentity"`
}

// IsInsecure returns true if the repository has been configured to skip server verification
Expand Down Expand Up @@ -212,12 +214,24 @@ func (repo *Repository) GetGitCreds(store git.CredsStore) git.Creds {
if repo.GCPServiceAccountKey != "" {
return git.NewGoogleCloudCreds(repo.GCPServiceAccountKey, store)
}
if repo.UseAzureWorkloadIdentity {
return git.NewAzureWorkloadIdentityCreds(store)
}
return git.NopCreds{}
}

// GetHelmCreds returns the credentials from a repository configuration used to authenticate at a Helm repository
func (repo *Repository) GetHelmCreds() helm.Creds {
return helm.Creds{
if repo.UseAzureWorkloadIdentity {
return helm.NewAzureWorkloadIdentityCreds(
getCAPath(repo.Repo),
[]byte(repo.TLSClientCertData),
[]byte(repo.TLSClientCertKey),
repo.Insecure,
)
}

return helm.HelmCreds{
Username: repo.Username,
Password: repo.Password,
CAPath: getCAPath(repo.Repo),
Expand Down
8 changes: 4 additions & 4 deletions reposerver/repository/repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3174,7 +3174,7 @@ func TestGetHelmRepos_OCIDependenciesWithHelmRepo(t *testing.T) {
require.NoError(t, err)

assert.Len(t, helmRepos, 1)
assert.Equal(t, "test", helmRepos[0].Username)
assert.Equal(t, "test", helmRepos[0].GetUsername())
assert.True(t, helmRepos[0].EnableOci)
assert.Equal(t, "example.com/myrepo", helmRepos[0].Repo)
}
Expand All @@ -3187,7 +3187,7 @@ func TestGetHelmRepos_OCIDependenciesWithRepo(t *testing.T) {
require.NoError(t, err)

assert.Len(t, helmRepos, 1)
assert.Equal(t, "test", helmRepos[0].Username)
assert.Equal(t, "test", helmRepos[0].GetUsername())
assert.True(t, helmRepos[0].EnableOci)
assert.Equal(t, "example.com/myrepo", helmRepos[0].Repo)
}
Expand All @@ -3204,7 +3204,7 @@ func TestGetHelmRepo_NamedRepos(t *testing.T) {
require.NoError(t, err)

assert.Len(t, helmRepos, 1)
assert.Equal(t, "test", helmRepos[0].Username)
assert.Equal(t, "test", helmRepos[0].GetUsername())
assert.Equal(t, "https://example.com", helmRepos[0].Repo)
}

Expand All @@ -3220,7 +3220,7 @@ func TestGetHelmRepo_NamedReposAlias(t *testing.T) {
require.NoError(t, err)

assert.Len(t, helmRepos, 1)
assert.Equal(t, "test-alias", helmRepos[0].Username)
assert.Equal(t, "test-alias", helmRepos[0].GetUsername())
assert.Equal(t, "https://example.com", helmRepos[0].Repo)
}

Expand Down
80 changes: 80 additions & 0 deletions util/git/creds.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,20 @@ import (
log "github.com/sirupsen/logrus"

"github.com/argoproj/argo-cd/v2/common"
argoutils "github.com/argoproj/argo-cd/v2/util"
certutil "github.com/argoproj/argo-cd/v2/util/cert"
argoioutils "github.com/argoproj/argo-cd/v2/util/io"
"github.com/argoproj/argo-cd/v2/util/workloadidentity"
)

var (
// In memory cache for storing github APP api token credentials
githubAppTokenCache *gocache.Cache
// In memory cache for storing oauth2.TokenSource used to generate Google Cloud OAuth tokens
googleCloudTokenSource *gocache.Cache

// In memory cache for storing Azure tokens
azureTokenCache *gocache.Cache
)

const (
Expand All @@ -54,6 +59,7 @@ func init() {
githubAppTokenCache = gocache.New(githubAppCredsExp, 1*time.Minute)
// oauth2.TokenSource handles fetching new Tokens once they are expired. The oauth2.TokenSource itself does not expire.
googleCloudTokenSource = gocache.New(gocache.NoExpiration, 0)
azureTokenCache = gocache.New(gocache.NoExpiration, 0)
}

type NoopCredsStore struct{}
Expand Down Expand Up @@ -569,3 +575,77 @@ func (c GoogleCloudCreds) getAccessToken() (string, error) {

return token.AccessToken, nil
}

type AzureWorkloadIdentityCreds struct {
store CredsStore
}

func NewAzureWorkloadIdentityCreds(store CredsStore) AzureWorkloadIdentityCreds {
return AzureWorkloadIdentityCreds{
store: store,
}
}

func (c AzureWorkloadIdentityCreds) Environ() (io.Closer, []string, error) {
token, err := c.GetAzureDevOpsAccessToken()
if err != nil {
return NopCloser{}, nil, err
}
nonce := c.store.Add("", token)
env := c.store.Environ(nonce)

return argoioutils.NewCloser(func() error {
c.store.Remove(nonce)
return NopCloser{}.Close()
}), env, nil
}

func (c AzureWorkloadIdentityCreds) getAccessToken(scope string) (string, error) {
// Compute hash of creds for lookup in cache
key, err := argoutils.GenerateCacheKey("%s", scope)
if err != nil {
return "", fmt.Errorf("failed to get get SHA256 hash for Azure credentials: %w", err)
}

t, found := azureTokenCache.Get(key)
if found {
return t.(string), nil
}

token, err := workloadidentity.GetWorkloadIdentityToken(scope)
if err != nil {
return "", fmt.Errorf("failed to get Azure access token: %w", err)
}

azureTokenCache.Set(key, token, gocache.DefaultExpiration)
return token, nil
}

func (c AzureWorkloadIdentityCreds) GetAzureDevOpsAccessToken() (string, error) {
accessToken, err := c.getAccessToken("499b84ac-1321-427f-aa17-267ca6975798/.default") // wellknown resourceid of Azure DevOps
return accessToken, err
}

func (c AzureWorkloadIdentityCreds) GetAcrAccessToken(azureContainerRegistry string) (string, error) {
// Compute hash as key for refresh token in the cache
key, err := argoutils.GenerateCacheKey("accesstoken-%s", azureContainerRegistry)
if err != nil {
return "", fmt.Errorf("failed to compute key for cache: %w", err)
}

// Check cache for GitHub transport which helps fetch an API token
t, found := azureTokenCache.Get(key)
if found {
fmt.Println("access token found token in cache")
return t.(string), nil
}

token, err := workloadidentity.GetWorkloadIdentityToken(fmt.Sprintf("https://%s/.default", azureContainerRegistry))
if err != nil {
return "", fmt.Errorf("failed to get Azure access token: %w", err)
}

// We assume that the access token has a lifetime of 3 hours
azureTokenCache.Set(key, token, 2*time.Hour+30*time.Minute)
return token, nil
}
35 changes: 13 additions & 22 deletions util/helm/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,6 @@ var (
OCINotEnabledErr = errors.New("could not perform the action when oci is not enabled")
)

type Creds struct {
Username string
Password string
CAPath string
CertData []byte
KeyData []byte
InsecureSkipVerify bool
}

type indexCache interface {
SetHelmIndex(repo string, indexData []byte) error
GetHelmIndex(repo string, indexData *[]byte) error
Expand Down Expand Up @@ -188,7 +179,7 @@ func (c *nativeHelmChart) ExtractChart(chart string, version string, project str
defer func() { _ = os.RemoveAll(tempDest) }()

if c.enableOci {
if c.creds.Password != "" && c.creds.Username != "" {
if c.creds.GetPassword() != "" && c.creds.GetUsername() != "" {
_, err = helmCmd.RegistryLogin(c.repoURL, c.creds)
if err != nil {
_ = os.RemoveAll(tempDir)
Expand Down Expand Up @@ -294,7 +285,7 @@ func (c *nativeHelmChart) TestHelmOCI() (bool, error) {

// Looks like there is no good way to test access to OCI repo if credentials are not provided
// just assume it is accessible
if c.creds.Username != "" && c.creds.Password != "" {
if c.creds.GetUsername() != "" && c.creds.GetPassword() != "" {
_, err = helmCmd.RegistryLogin(c.repoURL, c.creds)
if err != nil {
return false, fmt.Errorf("error logging into OCI registry: %w", err)
Expand All @@ -318,9 +309,9 @@ func (c *nativeHelmChart) loadRepoIndex(maxIndexSize int64) ([]byte, error) {
if err != nil {
return nil, fmt.Errorf("error creating HTTP request: %w", err)
}
if c.creds.Username != "" || c.creds.Password != "" {
if c.creds.GetUsername() != "" || c.creds.GetPassword() != "" {
// only basic supported
req.SetBasicAuth(c.creds.Username, c.creds.Password)
req.SetBasicAuth(c.creds.GetUsername(), c.creds.GetPassword())
}

tlsConf, err := newTLSConfig(c.creds)
Expand All @@ -347,21 +338,21 @@ func (c *nativeHelmChart) loadRepoIndex(maxIndexSize int64) ([]byte, error) {
}

func newTLSConfig(creds Creds) (*tls.Config, error) {
tlsConfig := &tls.Config{InsecureSkipVerify: creds.InsecureSkipVerify}
tlsConfig := &tls.Config{InsecureSkipVerify: creds.GetInsecureSkipVerify()}

if creds.CAPath != "" {
caData, err := os.ReadFile(creds.CAPath)
if creds.GetCAPath() != "" {
caData, err := os.ReadFile(creds.GetCAPath())
if err != nil {
return nil, fmt.Errorf("error reading CA file %s: %w", creds.CAPath, err)
return nil, fmt.Errorf("error reading CA file %s: %w", creds.GetCAPath(), err)
}
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caData)
tlsConfig.RootCAs = caCertPool
}

// If a client cert & key is provided then configure TLS config accordingly.
if len(creds.CertData) > 0 && len(creds.KeyData) > 0 {
cert, err := tls.X509KeyPair(creds.CertData, creds.KeyData)
if len(creds.GetCertData()) > 0 && len(creds.GetKeyData()) > 0 {
cert, err := tls.X509KeyPair(creds.GetCertData(), creds.GetKeyData())
if err != nil {
return nil, fmt.Errorf("error creating X509 key pair: %w", err)
}
Expand Down Expand Up @@ -449,12 +440,12 @@ func (c *nativeHelmChart) GetTags(chart string, noCache bool) (*TagsList, error)

repoHost, _, _ := strings.Cut(tagsURL, "/")
credential := auth.StaticCredential(repoHost, auth.Credential{
Username: c.creds.Username,
Password: c.creds.Password,
Username: c.creds.GetUsername(),
Password: c.creds.GetPassword(),
})

// Try to fallback to the environment config, but we shouldn't error if the file is not set
if c.creds.Username == "" && c.creds.Password == "" {
if c.creds.GetUsername() == "" && c.creds.GetPassword() == "" {
store, _ := credentials.NewStoreFromDocker(credentials.StoreOptions{})
if store != nil {
credential = credentials.Credential(store)
Expand Down
Loading

0 comments on commit 2dfe9a2

Please sign in to comment.