summaryrefslogtreecommitdiffstats
path: root/pkg/authn/kubernetes/keychain.go
blob: 368d829a2f44d759ad1db3f742c3fc8998bf7e03 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
// Copyright 2022 Google LLC All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//    http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package kubernetes

import (
	"context"
	"encoding/json"
	"fmt"
	"net"
	"net/url"
	"path/filepath"
	"sort"
	"strings"

	"github.com/google/go-containerregistry/pkg/authn"
	"github.com/google/go-containerregistry/pkg/logs"
	corev1 "k8s.io/api/core/v1"
	k8serrors "k8s.io/apimachinery/pkg/api/errors"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/client-go/kubernetes"
	"k8s.io/client-go/rest"
)

const (
	// NoServiceAccount is a constant that can be passed via ServiceAccountName
	// to tell the keychain that looking up the service account is unnecessary.
	// This value cannot collide with an actual service account name because
	// service accounts do not allow spaces.
	NoServiceAccount = "no service account"
)

// Options holds configuration data for guiding credential resolution.
type Options struct {
	// Namespace holds the namespace inside of which we are resolving service
	// account and pull secret references to access the image.
	// If empty, "default" is assumed.
	Namespace string

	// ServiceAccountName holds the serviceaccount (within Namespace) as which a
	// Pod might access the image.  Service accounts may have image pull secrets
	// attached, so we lookup the service account to complete the keychain.
	// If empty, "default" is assumed.  To avoid a service account lookup, pass
	// NoServiceAccount explicitly.
	ServiceAccountName string

	// ImagePullSecrets holds the names of the Kubernetes secrets (scoped to
	// Namespace) containing credential data to use for the image pull.
	ImagePullSecrets []string

	// UseMountSecrets determines whether or not mount secrets in the ServiceAccount
	// should be considered. Mount secrets are those listed under the `.secrets`
	// attribute of the ServiceAccount resource. Ignored if ServiceAccountName is set
	// to NoServiceAccount.
	UseMountSecrets bool
}

// New returns a new authn.Keychain suitable for resolving image references as
// scoped by the provided Options.  It speaks to Kubernetes through the provided
// client interface.
func New(ctx context.Context, client kubernetes.Interface, opt Options) (authn.Keychain, error) {
	if opt.Namespace == "" {
		opt.Namespace = "default"
	}
	if opt.ServiceAccountName == "" {
		opt.ServiceAccountName = "default"
	}

	// Implement a Kubernetes-style authentication keychain.
	// This needs to support roughly the following kinds of authentication:
	//  1) The explicit authentication from imagePullSecrets on Pod
	//  2) The semi-implicit authentication where imagePullSecrets are on the
	//    Pod's service account.

	// First, fetch all of the explicitly declared pull secrets
	var pullSecrets []corev1.Secret
	for _, name := range opt.ImagePullSecrets {
		ps, err := client.CoreV1().Secrets(opt.Namespace).Get(ctx, name, metav1.GetOptions{})
		if k8serrors.IsNotFound(err) {
			logs.Warn.Printf("secret %s/%s not found; ignoring", opt.Namespace, name)
			continue
		} else if err != nil {
			return nil, err
		}
		pullSecrets = append(pullSecrets, *ps)
	}

	// Second, fetch all of the pull secrets attached to our service account,
	// unless the user has explicitly specified that no service account lookup
	// is desired.
	if opt.ServiceAccountName != NoServiceAccount {
		sa, err := client.CoreV1().ServiceAccounts(opt.Namespace).Get(ctx, opt.ServiceAccountName, metav1.GetOptions{})
		if k8serrors.IsNotFound(err) {
			logs.Warn.Printf("serviceaccount %s/%s not found; ignoring", opt.Namespace, opt.ServiceAccountName)
		} else if err != nil {
			return nil, err
		}
		if sa != nil {
			for _, localObj := range sa.ImagePullSecrets {
				ps, err := client.CoreV1().Secrets(opt.Namespace).Get(ctx, localObj.Name, metav1.GetOptions{})
				if k8serrors.IsNotFound(err) {
					logs.Warn.Printf("secret %s/%s not found; ignoring", opt.Namespace, localObj.Name)
					continue
				} else if err != nil {
					return nil, err
				}
				pullSecrets = append(pullSecrets, *ps)
			}

			if opt.UseMountSecrets {
				for _, obj := range sa.Secrets {
					s, err := client.CoreV1().Secrets(opt.Namespace).Get(ctx, obj.Name, metav1.GetOptions{})
					if k8serrors.IsNotFound(err) {
						logs.Warn.Printf("secret %s/%s not found; ignoring", opt.Namespace, obj.Name)
						continue
					} else if err != nil {
						return nil, err
					}
					pullSecrets = append(pullSecrets, *s)
				}
			}
		}
	}

	return NewFromPullSecrets(ctx, pullSecrets)
}

// NewInCluster returns a new authn.Keychain suitable for resolving image references as
// scoped by the provided Options, constructing a kubernetes.Interface based on in-cluster
// authentication.
func NewInCluster(ctx context.Context, opt Options) (authn.Keychain, error) {
	clusterConfig, err := rest.InClusterConfig()
	if err != nil {
		return nil, err
	}

	client, err := kubernetes.NewForConfig(clusterConfig)
	if err != nil {
		return nil, err
	}
	return New(ctx, client, opt)
}

type dockerConfigJSON struct {
	Auths map[string]authn.AuthConfig
}

// NewFromPullSecrets returns a new authn.Keychain suitable for resolving image references as
// scoped by the pull secrets.
func NewFromPullSecrets(ctx context.Context, secrets []corev1.Secret) (authn.Keychain, error) {
	keyring := &keyring{
		index: make([]string, 0),
		creds: make(map[string][]authn.AuthConfig),
	}

	var cfg dockerConfigJSON

	// From: https://github.com/kubernetes/kubernetes/blob/0dcafb1f37ee522be3c045753623138e5b907001/pkg/credentialprovider/keyring.go
	for _, secret := range secrets {
		if b, exists := secret.Data[corev1.DockerConfigJsonKey]; secret.Type == corev1.SecretTypeDockerConfigJson && exists && len(b) > 0 {
			if err := json.Unmarshal(b, &cfg); err != nil {
				return nil, err
			}
		}
		if b, exists := secret.Data[corev1.DockerConfigKey]; secret.Type == corev1.SecretTypeDockercfg && exists && len(b) > 0 {
			if err := json.Unmarshal(b, &cfg.Auths); err != nil {
				return nil, err
			}
		}

		for registry, v := range cfg.Auths {
			value := registry
			if !strings.HasPrefix(value, "https://") && !strings.HasPrefix(value, "http://") {
				value = "https://" + value
			}
			parsed, err := url.Parse(value)
			if err != nil {
				return nil, fmt.Errorf("Entry %q in dockercfg invalid (%w)", value, err)
			}

			// The docker client allows exact matches:
			//    foo.bar.com/namespace
			// Or hostname matches:
			//    foo.bar.com
			// It also considers /v2/  and /v1/ equivalent to the hostname
			// See ResolveAuthConfig in docker/registry/auth.go.
			effectivePath := parsed.Path
			if strings.HasPrefix(effectivePath, "/v2/") || strings.HasPrefix(effectivePath, "/v1/") {
				effectivePath = effectivePath[3:]
			}
			var key string
			if (len(effectivePath) > 0) && (effectivePath != "/") {
				key = parsed.Host + effectivePath
			} else {
				key = parsed.Host
			}

			if _, ok := keyring.creds[key]; !ok {
				keyring.index = append(keyring.index, key)
			}

			keyring.creds[key] = append(keyring.creds[key], v)

		}

		// We reverse sort in to give more specific (aka longer) keys priority
		// when matching for creds
		sort.Sort(sort.Reverse(sort.StringSlice(keyring.index)))
	}
	return keyring, nil
}

type keyring struct {
	index []string
	creds map[string][]authn.AuthConfig
}

func (keyring *keyring) Resolve(target authn.Resource) (authn.Authenticator, error) {
	image := target.String()
	auths := []authn.AuthConfig{}

	for _, k := range keyring.index {
		// both k and image are schemeless URLs because even though schemes are allowed
		// in the credential configurations, we remove them when constructing the keyring
		if matched, _ := urlsMatchStr(k, image); matched {
			auths = append(auths, keyring.creds[k]...)
		}
	}

	if len(auths) == 0 {
		return authn.Anonymous, nil
	}

	return toAuthenticator(auths)
}

// urlsMatchStr is wrapper for URLsMatch, operating on strings instead of URLs.
func urlsMatchStr(glob string, target string) (bool, error) {
	globURL, err := parseSchemelessURL(glob)
	if err != nil {
		return false, err
	}
	targetURL, err := parseSchemelessURL(target)
	if err != nil {
		return false, err
	}
	return urlsMatch(globURL, targetURL)
}

// parseSchemelessURL parses a schemeless url and returns a url.URL
// url.Parse require a scheme, but ours don't have schemes.  Adding a
// scheme to make url.Parse happy, then clear out the resulting scheme.
func parseSchemelessURL(schemelessURL string) (*url.URL, error) {
	parsed, err := url.Parse("https://" + schemelessURL)
	if err != nil {
		return nil, err
	}
	// clear out the resulting scheme
	parsed.Scheme = ""
	return parsed, nil
}

// splitURL splits the host name into parts, as well as the port
func splitURL(url *url.URL) (parts []string, port string) {
	host, port, err := net.SplitHostPort(url.Host)
	if err != nil {
		// could not parse port
		host, port = url.Host, ""
	}
	return strings.Split(host, "."), port
}

// urlsMatch checks whether the given target url matches the glob url, which may have
// glob wild cards in the host name.
//
// Examples:
//
//	globURL=*.docker.io, targetURL=blah.docker.io => match
//	globURL=*.docker.io, targetURL=not.right.io   => no match
//
// Note that we don't support wildcards in ports and paths yet.
func urlsMatch(globURL *url.URL, targetURL *url.URL) (bool, error) {
	globURLParts, globPort := splitURL(globURL)
	targetURLParts, targetPort := splitURL(targetURL)
	if globPort != targetPort {
		// port doesn't match
		return false, nil
	}
	if len(globURLParts) != len(targetURLParts) {
		// host name does not have the same number of parts
		return false, nil
	}
	if !strings.HasPrefix(targetURL.Path, globURL.Path) {
		// the path of the credential must be a prefix
		return false, nil
	}
	for k, globURLPart := range globURLParts {
		targetURLPart := targetURLParts[k]
		matched, err := filepath.Match(globURLPart, targetURLPart)
		if err != nil {
			return false, err
		}
		if !matched {
			// glob mismatch for some part
			return false, nil
		}
	}
	// everything matches
	return true, nil
}

func toAuthenticator(configs []authn.AuthConfig) (authn.Authenticator, error) {
	cfg := configs[0]

	if cfg.Auth != "" {
		cfg.Auth = ""
	}

	return authn.FromConfig(cfg), nil
}