summaryrefslogtreecommitdiffstats
path: root/pkg/authn/kubernetes/keychain.go
diff options
context:
space:
mode:
Diffstat (limited to 'pkg/authn/kubernetes/keychain.go')
-rw-r--r--pkg/authn/kubernetes/keychain.go331
1 files changed, 331 insertions, 0 deletions
diff --git a/pkg/authn/kubernetes/keychain.go b/pkg/authn/kubernetes/keychain.go
new file mode 100644
index 0000000..368d829
--- /dev/null
+++ b/pkg/authn/kubernetes/keychain.go
@@ -0,0 +1,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
+}