Skip to content

Commit 59900e8

Browse files
authored
Add ECR authentication support to image-builder (#18506)
* Add ECR auth support * Add EnableAdditionalECRAuth to Gitpod config * Simplify ECR integration * Make TrustedValue interface implementable * Allow ECR registries by default
1 parent 12d7430 commit 59900e8

File tree

11 files changed

+229
-27
lines changed

11 files changed

+229
-27
lines changed

components/image-builder-api/go/config/config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ type Configuration struct {
4141

4242
// BuilderImage is an image ref to the workspace builder image
4343
BuilderImage string `json:"builderImage"`
44+
45+
// EnableAdditionalECRAuth adds additional ECR auth using IRSA.
46+
// This will attempt to add ECR auth for any ECR repo a user is
47+
// trying to access.
48+
EnableAdditionalECRAuth bool `json:"enableAdditionalECRAuth"`
4449
}
4550

4651
type TLS struct {

components/image-builder-mk3/go.mod

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ require (
3131
)
3232

3333
require (
34+
github.com/aws/aws-sdk-go-v2/config v1.18.33
3435
github.com/beorn7/perks v1.0.1 // indirect
3536
github.com/cespare/xxhash/v2 v2.2.0 // indirect
3637
github.com/davecgh/go-spew v1.1.1 // indirect
@@ -73,6 +74,23 @@ require (
7374
gopkg.in/yaml.v3 v3.0.1 // indirect
7475
)
7576

77+
require (
78+
github.com/aws/aws-sdk-go-v2 v1.20.1
79+
github.com/aws/aws-sdk-go-v2/credentials v1.13.32 // indirect
80+
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.8 // indirect
81+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.38 // indirect
82+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.32 // indirect
83+
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.39 // indirect
84+
github.com/aws/aws-sdk-go-v2/service/ecr v1.19.2
85+
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.32 // indirect
86+
github.com/aws/aws-sdk-go-v2/service/sso v1.13.2 // indirect
87+
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.2 // indirect
88+
github.com/aws/aws-sdk-go-v2/service/sts v1.21.2 // indirect
89+
github.com/aws/smithy-go v1.14.1 // indirect
90+
)
91+
92+
require github.com/jmespath/go-jmespath v0.4.0 // indirect
93+
7694
replace github.com/gitpod-io/gitpod/common-go => ../common-go // leeway
7795

7896
replace github.com/gitpod-io/gitpod/components/scrubber => ../scrubber // leeway

components/image-builder-mk3/go.sum

Lines changed: 31 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

components/image-builder-mk3/pkg/auth/auth.go

Lines changed: 114 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,13 @@ import (
1111
"encoding/base64"
1212
"fmt"
1313
"os"
14+
"regexp"
1415
"strings"
1516
"sync"
17+
"time"
1618

19+
"github.com/aws/aws-sdk-go-v2/aws"
20+
"github.com/aws/aws-sdk-go-v2/service/ecr"
1721
"github.com/docker/cli/cli/config/configfile"
1822
"github.com/docker/distribution/reference"
1923
"github.com/docker/docker/api/types"
@@ -27,7 +31,7 @@ import (
2731
// RegistryAuthenticator can provide authentication for some registries
2832
type RegistryAuthenticator interface {
2933
// Authenticate attempts to provide authentication for Docker registry access
30-
Authenticate(registry string) (auth *Authentication, err error)
34+
Authenticate(ctx context.Context, registry string) (auth *Authentication, err error)
3135
}
3236

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

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

115+
// CompositeAuth returns the first non-empty authentication of any of its consitutents
116+
type CompositeAuth []RegistryAuthenticator
117+
118+
func (ca CompositeAuth) Authenticate(ctx context.Context, registry string) (auth *Authentication, err error) {
119+
for _, ath := range ca {
120+
res, err := ath.Authenticate(ctx, registry)
121+
if err != nil {
122+
return nil, err
123+
}
124+
if !res.Empty() {
125+
return res, nil
126+
}
127+
}
128+
return &Authentication{}, nil
129+
}
130+
131+
func NewECRAuthenticator(ecrc *ecr.Client) *ECRAuthenticator {
132+
return &ECRAuthenticator{
133+
ecrc: ecrc,
134+
}
135+
}
136+
137+
type ECRAuthenticator struct {
138+
ecrc *ecr.Client
139+
140+
ecrAuth string
141+
ecrAuthLastRefreshTime time.Time
142+
ecrAuthLock sync.Mutex
143+
}
144+
145+
const (
146+
// ECR tokens are valid for 12h [1], and we want to ensure we refresh at least twice a day before full expiry.
147+
//
148+
// [1] https://docs.aws.amazon.com/AmazonECR/latest/APIReference/API_GetAuthorizationToken.html
149+
ecrTokenRefreshTime = 4 * time.Hour
150+
)
151+
152+
func (ath *ECRAuthenticator) Authenticate(ctx context.Context, registry string) (auth *Authentication, err error) {
153+
if !isECRRegistry(registry) {
154+
return nil, nil
155+
}
156+
157+
ath.ecrAuthLock.Lock()
158+
defer ath.ecrAuthLock.Unlock()
159+
if time.Since(ath.ecrAuthLastRefreshTime) > ecrTokenRefreshTime {
160+
tknout, err := ath.ecrc.GetAuthorizationToken(ctx, &ecr.GetAuthorizationTokenInput{})
161+
if err != nil {
162+
return nil, err
163+
}
164+
if len(tknout.AuthorizationData) == 0 {
165+
return nil, fmt.Errorf("no ECR authorization data received")
166+
}
167+
168+
pwd, err := base64.StdEncoding.DecodeString(aws.ToString(tknout.AuthorizationData[0].AuthorizationToken))
169+
if err != nil {
170+
return nil, err
171+
}
172+
173+
ath.ecrAuth = string(pwd)
174+
ath.ecrAuthLastRefreshTime = time.Now()
175+
log.Debug("refreshed ECR token")
176+
}
177+
178+
segs := strings.Split(ath.ecrAuth, ":")
179+
if len(segs) != 2 {
180+
return nil, fmt.Errorf("cannot understand ECR token. Expected 2 segments, got %d", len(segs))
181+
}
182+
return &Authentication{
183+
Username: segs[0],
184+
Password: segs[1],
185+
Auth: base64.StdEncoding.EncodeToString([]byte(ath.ecrAuth)),
186+
}, nil
187+
}
188+
111189
// Authentication represents docker usable authentication
112190
type Authentication types.AuthConfig
113191

192+
func (a *Authentication) Empty() bool {
193+
if a == nil {
194+
return true
195+
}
196+
if a.Auth == "" && a.Password == "" {
197+
return true
198+
}
199+
return false
200+
}
201+
202+
var ecrRegistryRegexp = regexp.MustCompile(`\d{12}.dkr.ecr.\w+-\w+-\w+.amazonaws.com`)
203+
204+
// isECRRegistry returns true if the registry domain is an ECR registry
205+
func isECRRegistry(domain string) bool {
206+
return ecrRegistryRegexp.MatchString(domain)
207+
}
208+
114209
// AllowedAuthFor describes for which repositories authentication may be provided for
115210
type AllowedAuthFor struct {
116211
All bool
@@ -197,7 +292,7 @@ func (r Resolver) ResolveRequestAuth(auth *api.BuildRegistryAuth) (authFor Allow
197292
}
198293

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

217-
if res != nil {
218-
log.WithField("reg", reg).Debug("found additional auth")
219-
}
313+
log.WithField("reg", reg).Debug("checking for additional auth")
314+
res = a.additionalAuth(reg)
315+
316+
if res != nil {
317+
log.WithField("reg", reg).Debug("found additional auth")
220318
}
221319
}()
222320

223321
var regAllowed bool
224-
if a.IsAllowAll() {
322+
switch {
323+
case a.IsAllowAll():
225324
// free for all
226325
regAllowed = true
227-
} else {
326+
case isECRRegistry(reg):
327+
// We allow ECR registries by default to support private ECR registries OOTB.
328+
// The AWS IAM permissions dictate what users actually have access to.
329+
regAllowed = true
330+
default:
228331
for _, a := range a.Explicit {
229332
if a == reg {
230333
regAllowed = true
@@ -237,7 +340,7 @@ func (a AllowedAuthFor) GetAuthFor(auth RegistryAuthenticator, refstr string) (r
237340
return nil, nil
238341
}
239342

240-
return auth.Authenticate(reg)
343+
return auth.Authenticate(ctx, reg)
241344
}
242345

243346
func (a AllowedAuthFor) additionalAuth(domain string) *Authentication {
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) 2023 Gitpod GmbH. All rights reserved.
2+
// Licensed under the GNU Affero General Public License (AGPL).
3+
// See License.AGPL.txt in the project root for license information.
4+
5+
package auth
6+
7+
import (
8+
"testing"
9+
10+
"github.com/google/go-cmp/cmp"
11+
)
12+
13+
func TestIsECRRegistry(t *testing.T) {
14+
tests := []struct {
15+
Registry string
16+
Expectation bool
17+
}{
18+
{Registry: "422899872803.dkr.ecr.eu-central-1.amazonaws.com/private-repo-demo:latest", Expectation: true},
19+
{Registry: "422899872803.dkr.ecr.eu-central-1.amazonaws.com", Expectation: true},
20+
{Registry: "index.docker.io/foo:bar", Expectation: false},
21+
}
22+
23+
for _, test := range tests {
24+
t.Run(test.Registry, func(t *testing.T) {
25+
act := isECRRegistry(test.Registry)
26+
27+
if diff := cmp.Diff(test.Expectation, act); diff != "" {
28+
t.Errorf("isECRRegistry() mismatch (-want +got):\n%s", diff)
29+
}
30+
})
31+
}
32+
}

0 commit comments

Comments
 (0)