Skip to content

Add ECR authentication support to image-builder #18506

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

Merged
merged 5 commits into from
Aug 14, 2023
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions components/image-builder-api/go/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ type Configuration struct {

// BuilderImage is an image ref to the workspace builder image
BuilderImage string `json:"builderImage"`

// EnableAdditionalECRAuth adds additional ECR auth using IRSA.
// This will attempt to add ECR auth for any ECR repo a user is
// trying to access.
EnableAdditionalECRAuth bool `json:"enableAdditionalECRAuth"`
}

type TLS struct {
Expand Down
18 changes: 18 additions & 0 deletions components/image-builder-mk3/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ require (
)

require (
github.com/aws/aws-sdk-go-v2/config v1.18.33
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
Expand Down Expand Up @@ -73,6 +74,23 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect
)

require (
github.com/aws/aws-sdk-go-v2 v1.20.1
github.com/aws/aws-sdk-go-v2/credentials v1.13.32 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.38 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.32 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.39 // indirect
github.com/aws/aws-sdk-go-v2/service/ecr v1.19.2
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.32 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.13.2 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.21.2 // indirect
github.com/aws/smithy-go v1.14.1 // indirect
)

require github.com/jmespath/go-jmespath v0.4.0 // indirect

replace github.com/gitpod-io/gitpod/common-go => ../common-go // leeway

replace github.com/gitpod-io/gitpod/components/scrubber => ../scrubber // leeway
Expand Down
31 changes: 31 additions & 0 deletions components/image-builder-mk3/go.sum

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

125 changes: 114 additions & 11 deletions components/image-builder-mk3/pkg/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@ import (
"encoding/base64"
"fmt"
"os"
"regexp"
"strings"
"sync"
"time"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/ecr"
"github.com/docker/cli/cli/config/configfile"
"github.com/docker/distribution/reference"
"github.com/docker/docker/api/types"
Expand All @@ -27,7 +31,7 @@ import (
// RegistryAuthenticator can provide authentication for some registries
type RegistryAuthenticator interface {
// Authenticate attempts to provide authentication for Docker registry access
Authenticate(registry string) (auth *Authentication, err error)
Authenticate(ctx context.Context, registry string) (auth *Authentication, err error)
}

// NewDockerConfigFileAuth reads a docker config file to provide authentication
Expand Down Expand Up @@ -91,7 +95,7 @@ func (a *DockerConfigFileAuth) loadFromFile(fn string) (err error) {
}

// Authenticate attempts to provide an encoded authentication string for Docker registry access
func (a *DockerConfigFileAuth) Authenticate(registry string) (auth *Authentication, err error) {
func (a *DockerConfigFileAuth) Authenticate(ctx context.Context, registry string) (auth *Authentication, err error) {
ac, err := a.C.GetAuthConfig(registry)
if err != nil {
return nil, err
Expand All @@ -108,9 +112,100 @@ func (a *DockerConfigFileAuth) Authenticate(registry string) (auth *Authenticati
}, nil
}

// CompositeAuth returns the first non-empty authentication of any of its consitutents
type CompositeAuth []RegistryAuthenticator

func (ca CompositeAuth) Authenticate(ctx context.Context, registry string) (auth *Authentication, err error) {
for _, ath := range ca {
res, err := ath.Authenticate(ctx, registry)
if err != nil {
return nil, err
}
if !res.Empty() {
return res, nil
}
}
return &Authentication{}, nil
}

func NewECRAuthenticator(ecrc *ecr.Client) *ECRAuthenticator {
return &ECRAuthenticator{
ecrc: ecrc,
}
}

type ECRAuthenticator struct {
ecrc *ecr.Client

ecrAuth string
ecrAuthLastRefreshTime time.Time
ecrAuthLock sync.Mutex
}

const (
// ECR tokens are valid for 12h [1], and we want to ensure we refresh at least twice a day before full expiry.
//
// [1] https://docs.aws.amazon.com/AmazonECR/latest/APIReference/API_GetAuthorizationToken.html
ecrTokenRefreshTime = 4 * time.Hour
)

func (ath *ECRAuthenticator) Authenticate(ctx context.Context, registry string) (auth *Authentication, err error) {
if !isECRRegistry(registry) {
return nil, nil
}

ath.ecrAuthLock.Lock()
defer ath.ecrAuthLock.Unlock()
if time.Since(ath.ecrAuthLastRefreshTime) > ecrTokenRefreshTime {
tknout, err := ath.ecrc.GetAuthorizationToken(ctx, &ecr.GetAuthorizationTokenInput{})
if err != nil {
return nil, err
}
if len(tknout.AuthorizationData) == 0 {
return nil, fmt.Errorf("no ECR authorization data received")
}

pwd, err := base64.StdEncoding.DecodeString(aws.ToString(tknout.AuthorizationData[0].AuthorizationToken))
if err != nil {
return nil, err
}

ath.ecrAuth = string(pwd)
ath.ecrAuthLastRefreshTime = time.Now()
log.Debug("refreshed ECR token")
}

segs := strings.Split(ath.ecrAuth, ":")
if len(segs) != 2 {
return nil, fmt.Errorf("cannot understand ECR token. Expected 2 segments, got %d", len(segs))
}
return &Authentication{
Username: segs[0],
Password: segs[1],
Auth: base64.StdEncoding.EncodeToString([]byte(ath.ecrAuth)),
}, nil
}

// Authentication represents docker usable authentication
type Authentication types.AuthConfig

func (a *Authentication) Empty() bool {
if a == nil {
return true
}
if a.Auth == "" && a.Password == "" {
return true
}
return false
}

var ecrRegistryRegexp = regexp.MustCompile(`\d{12}.dkr.ecr.\w+-\w+-\w+.amazonaws.com`)

// isECRRegistry returns true if the registry domain is an ECR registry
func isECRRegistry(domain string) bool {
return ecrRegistryRegexp.MatchString(domain)
}

// AllowedAuthFor describes for which repositories authentication may be provided for
type AllowedAuthFor struct {
All bool
Expand Down Expand Up @@ -197,7 +292,7 @@ func (r Resolver) ResolveRequestAuth(auth *api.BuildRegistryAuth) (authFor Allow
}

// GetAuthFor computes the base64 encoded auth format for a Docker image pull/push
func (a AllowedAuthFor) GetAuthFor(auth RegistryAuthenticator, refstr string) (res *Authentication, err error) {
func (a AllowedAuthFor) GetAuthFor(ctx context.Context, auth RegistryAuthenticator, refstr string) (res *Authentication, err error) {
if auth == nil {
return
}
Expand All @@ -211,20 +306,28 @@ func (a AllowedAuthFor) GetAuthFor(auth RegistryAuthenticator, refstr string) (r
// If we haven't found authentication using the built-in way, we'll resort to additional auth
// the user sent us.
defer func() {
if err == nil && res == nil {
res = a.additionalAuth(reg)
if err != nil || !res.Empty() {
return
}

if res != nil {
log.WithField("reg", reg).Debug("found additional auth")
}
log.WithField("reg", reg).Debug("checking for additional auth")
res = a.additionalAuth(reg)

if res != nil {
log.WithField("reg", reg).Debug("found additional auth")
}
}()

var regAllowed bool
if a.IsAllowAll() {
switch {
case a.IsAllowAll():
// free for all
regAllowed = true
} else {
case isECRRegistry(reg):
// We allow ECR registries by default to support private ECR registries OOTB.
// The AWS IAM permissions dictate what users actually have access to.
regAllowed = true
default:
for _, a := range a.Explicit {
if a == reg {
regAllowed = true
Expand All @@ -237,7 +340,7 @@ func (a AllowedAuthFor) GetAuthFor(auth RegistryAuthenticator, refstr string) (r
return nil, nil
}

return auth.Authenticate(reg)
return auth.Authenticate(ctx, reg)
}

func (a AllowedAuthFor) additionalAuth(domain string) *Authentication {
Expand Down
32 changes: 32 additions & 0 deletions components/image-builder-mk3/pkg/auth/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright (c) 2023 Gitpod GmbH. All rights reserved.
// Licensed under the GNU Affero General Public License (AGPL).
// See License.AGPL.txt in the project root for license information.

package auth

import (
"testing"

"github.com/google/go-cmp/cmp"
)

func TestIsECRRegistry(t *testing.T) {
tests := []struct {
Registry string
Expectation bool
}{
{Registry: "422899872803.dkr.ecr.eu-central-1.amazonaws.com/private-repo-demo:latest", Expectation: true},
{Registry: "422899872803.dkr.ecr.eu-central-1.amazonaws.com", Expectation: true},
{Registry: "index.docker.io/foo:bar", Expectation: false},
}

for _, test := range tests {
t.Run(test.Registry, func(t *testing.T) {
act := isECRRegistry(test.Registry)

if diff := cmp.Diff(test.Expectation, act); diff != "" {
t.Errorf("isECRRegistry() mismatch (-want +got):\n%s", diff)
}
})
}
}
Loading