Skip to content

Commit

Permalink
Add credential consumption for protected buildpacks
Browse files Browse the repository at this point in the history
Co-authored-by: Nicolas Bender <[email protected]>
  • Loading branch information
pbusko and nicolasbender committed Jun 11, 2024
1 parent 2a38840 commit 9283ee5
Show file tree
Hide file tree
Showing 8 changed files with 388 additions and 2 deletions.
15 changes: 14 additions & 1 deletion cmd/builder/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

"code.cloudfoundry.org/cnbapplifecycle/pkg/archive"
"code.cloudfoundry.org/cnbapplifecycle/pkg/errors"
"code.cloudfoundry.org/cnbapplifecycle/pkg/keychain"
"code.cloudfoundry.org/cnbapplifecycle/pkg/log"
"code.cloudfoundry.org/cnbapplifecycle/pkg/staging"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -100,7 +101,19 @@ var builderCmd = &cobra.Command{
return errors.ErrGenericBuild
}

err = staging.DownloadBuildpacks(buildpacks, buildpacksDir, image.NewFetcher(logger, nil), blob.NewDownloader(logger, downloadCacheDir), orderFile, logger)
creds, err := keychain.FromEnv()
if err != nil {
logger.Errorf("failed to parse %s environment variable, error: %s\n", keychain.CnbCredentialsEnv, err.Error())
return errors.ErrGenericBuild
}
err = staging.DownloadBuildpacks(
buildpacks,
buildpacksDir,
image.NewFetcher(logger, nil, image.WithKeychain(creds)),
blob.NewDownloader(logger, downloadCacheDir, blob.WithClient(keychain.NewHTTPClient(creds))),
orderFile,
logger,
)
if err != nil {
logger.Errorf("failed to download buildpacks, error: %s\n", err.Error())
return errors.ErrDownloadingBuildpack
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ require (
github.com/buildpacks/lifecycle v0.19.7
github.com/buildpacks/pack v0.34.2
github.com/docker/docker v26.1.3+incompatible
github.com/google/go-containerregistry v0.19.1
github.com/jarcoal/httpmock v1.3.1
github.com/onsi/ginkgo/v2 v2.19.0
github.com/onsi/gomega v1.33.1
github.com/spf13/cobra v1.8.0
Expand Down Expand Up @@ -72,7 +74,6 @@ require (
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/go-containerregistry v0.19.1 // indirect
github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/mux v1.8.0 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,8 @@ github.com/heroku/color v0.0.6/go.mod h1:ZBvOcx7cTF2QKOv4LbmoBtNl5uB17qWxGuzZrsi
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww=
github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
Expand Down Expand Up @@ -255,6 +257,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g=
github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
Expand Down
63 changes: 63 additions & 0 deletions pkg/keychain/http.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package keychain

import (
"fmt"
"net/http"
"net/url"

"github.com/google/go-containerregistry/pkg/authn"
)

func NewHTTPClient(keychain authn.Keychain) *http.Client {
return &http.Client{
Transport: &roundTripper{
keychain: keychain,
inner: http.DefaultTransport,
},
}
}

type roundTripper struct {
keychain authn.Keychain
inner http.RoundTripper
}

func (rt *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if rt.keychain == nil {
return rt.inner.RoundTrip(req)
}

authenticator, err := rt.keychain.Resolve(&urlResource{url: req.URL})
if err != nil {
return nil, err
}

if authenticator == authn.Anonymous {
return rt.inner.RoundTrip(req)
}

conf, err := authenticator.Authorization()
if err != nil {
return nil, err
}

if conf.RegistryToken != "" {
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", conf.RegistryToken))
} else {
req.SetBasicAuth(conf.Username, conf.Password)
}

return rt.inner.RoundTrip(req)
}

type urlResource struct {
url *url.URL
}

func (r *urlResource) RegistryStr() string {
return r.url.Hostname()
}

func (r *urlResource) String() string {
return r.RegistryStr()
}
113 changes: 113 additions & 0 deletions pkg/keychain/http_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package keychain_test

import (
"bytes"
"fmt"
"io"
"net/http"
"os"

"code.cloudfoundry.org/cnbapplifecycle/pkg/keychain"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/jarcoal/httpmock"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

var _ = Describe("HTTP RoundTripper", func() {
var (
creds authn.Keychain
client *http.Client
err error
)

BeforeEach(func() {
creds, err = keychain.FromEnv()
Expect(err).ToNot(HaveOccurred())
client = keychain.NewHTTPClient(creds)
})

Describe("RoundTrip", func() {
It("works when keychain is nil", func() {
httpmock.RegisterResponder("GET", "https://test.io", func(r *http.Request) (*http.Response, error) {
if a := r.Header.Get("Authorization"); a != "" {
return httpmock.NewStringResponse(http.StatusBadRequest, fmt.Sprintf("found authorization header: %q", a)), nil
}
return httpmock.NewStringResponse(http.StatusOK, ""), nil
})

client = keychain.NewHTTPClient(nil)

res, err := client.Get("https://test.io")
Expect(err).ToNot(HaveOccurred())
defer res.Body.Close()
Expect(res.StatusCode).To(Equal(http.StatusOK))
})

It("works with the default keychain (anonymous)", func() {
httpmock.RegisterResponder("GET", "https://test.io", func(r *http.Request) (*http.Response, error) {
if a := r.Header.Get("Authorization"); a != "" {
return httpmock.NewStringResponse(http.StatusBadRequest, fmt.Sprintf("found authorization header: %q", a)), nil
}
return httpmock.NewStringResponse(http.StatusOK, ""), nil
})
res, err := client.Get("https://test.io")
Expect(err).ToNot(HaveOccurred())
defer res.Body.Close()
Expect(res.StatusCode).To(Equal(http.StatusOK))
})

Describe("with credentials", func() {
BeforeEach(func() {
Expect(os.Setenv(keychain.CnbCredentialsEnv, `{"bearer.test":{"token":"foo"},"basic.test":{"username":"foo","password":"bar"}}`)).To(Succeed())
creds, err = keychain.FromEnv()
Expect(err).ToNot(HaveOccurred())
client = keychain.NewHTTPClient(creds)
})

AfterEach(func() {
Expect(os.Unsetenv(keychain.CnbCredentialsEnv)).To(Succeed())
})

It("sets Authorization header (token)", func() {
httpmock.RegisterResponder("GET", "https://bearer.test", func(r *http.Request) (*http.Response, error) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return httpmock.NewStringResponse(http.StatusBadRequest, "authorization header not found"), nil
}

return httpmock.NewStringResponse(http.StatusOK, authHeader), nil
})

res, err := client.Get("https://bearer.test")
Expect(err).ToNot(HaveOccurred())
defer res.Body.Close()
Expect(res.StatusCode).To(Equal(http.StatusOK))

body := bytes.NewBuffer(nil)
io.Copy(body, res.Body)
Expect(body.String()).To(Equal("Bearer foo"))
})

It("sets Authorization header (basic)", func() {
httpmock.RegisterResponder("GET", "https://basic.test", func(r *http.Request) (*http.Response, error) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return httpmock.NewStringResponse(http.StatusBadRequest, "authorization header not found"), nil
}

return httpmock.NewStringResponse(http.StatusOK, authHeader), nil
})

res, err := client.Get("https://basic.test")
Expect(err).ToNot(HaveOccurred())
defer res.Body.Close()
Expect(res.StatusCode).To(Equal(http.StatusOK))

body := bytes.NewBuffer(nil)
io.Copy(body, res.Body)
Expect(body.String()).To(Equal("Basic Zm9vOmJhcg=="))
})
})
})
})
66 changes: 66 additions & 0 deletions pkg/keychain/keychain.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package keychain

import (
"encoding/json"
"errors"
"os"

"github.com/google/go-containerregistry/pkg/authn"
)

const CnbCredentialsEnv = "CNB_REGISTRY_CREDS"

type auth struct {
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
Token string `json:"token,omitempty"`
}

func (a auth) config() (authn.AuthConfig, error) {
if !((a.Username != "" && a.Password != "" && a.Token == "") || (a.Token != "" && a.Username == "" && a.Password == "")) {
return authn.AuthConfig{}, errors.New("invalid credential combination")
}

if a.Token != "" {
return authn.AuthConfig{
RegistryToken: a.Token,
}, nil
}

return authn.AuthConfig{
Username: a.Username,
Password: a.Password,
}, nil
}

type envKeyChain struct {
credentials map[string]auth
}

func FromEnv() (authn.Keychain, error) {
value, ok := os.LookupEnv(CnbCredentialsEnv)
if !ok {
return authn.DefaultKeychain, nil
}

e := &envKeyChain{}
if err := json.Unmarshal([]byte(value), &e.credentials); err != nil {
return nil, err
}

return authn.NewMultiKeychain(e, authn.DefaultKeychain), nil
}

func (e *envKeyChain) Resolve(resource authn.Resource) (authn.Authenticator, error) {
creds, ok := e.credentials[resource.RegistryStr()]
if !ok {
return authn.Anonymous, nil
}

config, err := creds.config()
if err != nil {
return nil, err
}

return authn.FromConfig(config), nil
}
28 changes: 28 additions & 0 deletions pkg/keychain/keychain_suite_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package keychain_test

import (
"testing"

"github.com/jarcoal/httpmock"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

var _ = BeforeSuite(func() {
// block all HTTP requests
httpmock.Activate()
})

var _ = BeforeEach(func() {
// remove any mocks
httpmock.Reset()
})

var _ = AfterSuite(func() {
httpmock.DeactivateAndReset()
})

func TestKeychain(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Keychain Suite")
}
Loading

0 comments on commit 9283ee5

Please sign in to comment.