Repository: deislabs/ratify (go)

Issue:
Support Kubernetes secrets as a new Authentication Provider
Create a Kubernetes secrets authentication provider to authenticate with private registry

Pull request description:
Add K8 Secret Auth Provider
- Adds a Kubernetes Secrets Auth Provider
- Allows DockerConfigJson or DockerCfg secret types to be referenced in config and loaded for ORAS authentication to private registry

Design document: https://hackmd.io/@akashsinghal/r1m_7rVJ9

Closes #131 

Patch:
diff --git a/charts/ratify/templates/deployment.yaml b/charts/ratify/templates/deployment.yaml
index 6a203fe..0cce93c 100644
--- a/charts/ratify/templates/deployment.yaml
+++ b/charts/ratify/templates/deployment.yaml
@@ -47,10 +47,16 @@ spec:
             - mountPath: "/usr/local/docker"
               name: dockerconfig
               readOnly: true
+          {{- end }}
           env:
+          {{- if $dockerAuthMode }}
             - name: DOCKER_CONFIG
               value: "/usr/local/docker"
           {{- end }}
+            - name: RATIFY_NAMESPACE
+              valueFrom:
+                fieldRef:
+                  fieldPath: metadata.namespace
           resources:
             {{- toYaml .Values.resources | nindent 12 }}
       volumes:
diff --git a/charts/ratify/templates/roles.yaml b/charts/ratify/templates/roles.yaml
new file mode 100644
index 0000000..c314727
--- /dev/null
+++ b/charts/ratify/templates/roles.yaml
@@ -0,0 +1,24 @@
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  name: secret-reader
+rules:
+- apiGroups: [""]
+  resources: ["secrets", "serviceaccounts"]
+  verbs: ["get"]
+
+---
+
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+  name: read-secrets
+  namespace: default
+subjects:
+- kind: ServiceAccount
+  name: default 
+  namespace: default
+roleRef:
+  kind: ClusterRole
+  name: secret-reader
+  apiGroup: rbac.authorization.k8s.io
diff --git a/go.mod b/go.mod
index bf245f5..0c1e290 100644
--- a/go.mod
+++ b/go.mod
@@ -21,5 +21,8 @@ require (
 	github.com/spdx/tools-golang v0.2.0
 	github.com/spf13/cobra v1.3.0
 	github.com/xlab/treeprint v1.1.0
+	k8s.io/api v0.22.5
+	k8s.io/apimachinery v0.22.5
+	k8s.io/client-go v0.22.5
 	oras.land/oras-go v0.5.1-0.20211129233629-3b1e924da9d7
 )
diff --git a/pkg/referrerstore/oras/authprovider/k8secret_authprovider.go b/pkg/referrerstore/oras/authprovider/k8secret_authprovider.go
new file mode 100644
index 0000000..bec886e
--- /dev/null
+++ b/pkg/referrerstore/oras/authprovider/k8secret_authprovider.go
@@ -0,0 +1,214 @@
+/*
+Copyright The Ratify Authors.
+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 authprovider
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"os"
+
+	"github.com/docker/cli/cli/config"
+	"github.com/docker/cli/cli/config/configfile"
+	core "k8s.io/api/core/v1"
+	meta "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/client-go/kubernetes"
+	"k8s.io/client-go/rest"
+)
+
+type k8SecretProviderFactory struct{}
+type k8SecretAuthProvider struct {
+	ratifyNamespace  string
+	config           k8SecretAuthProviderConf
+	clusterClientSet *kubernetes.Clientset
+}
+
+type secretConfig struct {
+	SecretName string `json:"secretName"`
+	Namespace  string `json:"namespace,omitempty"`
+}
+
+type k8SecretAuthProviderConf struct {
+	Name               string         `json:"name"`
+	ServiceAccountName string         `json:"serviceAccountName,omitempty"`
+	Secrets            []secretConfig `json:"secrets,omitempty"`
+}
+
+const defaultName = "default"
+const ratifyNamespaceEnv = "RATIFY_NAMESPACE"
+
+var ErrorNoMatchingCredential = errors.New("no matching credential found for k8 secret")
+
+// init calls Register for our k8s-secrets provider
+func init() {
+	Register("k8s-secrets", &k8SecretProviderFactory{})
+}
+
+// Create returns a k8AuthProvider instance after parsing auth config and resolving
+// named K8 secrets
+func (s *k8SecretProviderFactory) Create(authProviderConfig AuthProviderConfig) (AuthProvider, error) {
+
+	conf := k8SecretAuthProviderConf{}
+	authProviderConfigBytes, err := json.Marshal(authProviderConfig)
+	if err != nil {
+		return nil, fmt.Errorf("failed to marshal authentication provider config: %v", err)
+	}
+
+	if err := json.Unmarshal(authProviderConfigBytes, &conf); err != nil {
+		return nil, fmt.Errorf("failed to parse authentication provider configuration: %v", err)
+	}
+
+	clusterConfig, err := rest.InClusterConfig()
+	if err != nil {
+		return nil, fmt.Errorf("failed to generate cluster configuration: %v", err)
+	}
+
+	clientSet, err := kubernetes.NewForConfig(clusterConfig)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create kubernetes client set from config: %v", err)
+	}
+
+	if conf.ServiceAccountName == "" {
+		conf.ServiceAccountName = defaultName
+	}
+
+	// get name of namespace ratify is running in
+	namespace := os.Getenv(ratifyNamespaceEnv)
+	if namespace == "" {
+		return nil, fmt.Errorf("environment variable %s not set", ratifyNamespaceEnv)
+	}
+
+	return &k8SecretAuthProvider{
+		ratifyNamespace:  namespace,
+		config:           conf,
+		clusterClientSet: clientSet,
+	}, nil
+}
+
+// Enabled checks if ratify namespace, config, or cluster client set is nil
+func (d *k8SecretAuthProvider) Enabled(ctx context.Context) bool {
+	if d.ratifyNamespace == "" || d.clusterClientSet == nil {
+		return false
+	}
+
+	return true
+}
+
+// Provide finds secret corresponding to artifact's registry host name, extracts
+// the authentication credentials from k8 secret, and returns AuthConfig
+func (d *k8SecretAuthProvider) Provide(ctx context.Context, artifact string) (AuthConfig, error) {
+	if !d.Enabled(ctx) {
+		return AuthConfig{}, fmt.Errorf("k8 auth provider not properly enabled")
+	}
+
+	hostName, err := GetRegistryHostName(artifact)
+	if err != nil {
+		return AuthConfig{}, err
+	}
+
+	// iterate through config secrets, resolve each secret, and store in map
+	for _, k8secret := range d.config.Secrets {
+		// default value of secret is assumed to be ratify namespace
+		if k8secret.Namespace == "" {
+			k8secret.Namespace = d.ratifyNamespace
+		}
+
+		secret, err := d.clusterClientSet.CoreV1().Secrets(k8secret.Namespace).Get(ctx, k8secret.SecretName, meta.GetOptions{})
+		if err != nil {
+			return AuthConfig{}, fmt.Errorf("failed to pull secret %s from cluster: %v", k8secret.SecretName, err)
+		}
+
+		// only dockercfg or docker config json secret type allowed
+		if secret.Type == core.SecretTypeDockercfg || secret.Type == core.SecretTypeDockerConfigJson {
+			authConfig, err := d.resolveCredentialFromSecret(hostName, secret)
+			if err != nil && err != ErrorNoMatchingCredential {
+				return AuthConfig{}, err
+			}
+			// if a resolved AuthConfig is returned
+			if err == nil {
+				return authConfig, nil
+			}
+		} else {
+			return AuthConfig{}, fmt.Errorf("secret with unsupported type %s provided in config", secret.Type)
+		}
+	}
+
+	// get the the service account for ratify
+	serviceAccount, err := d.clusterClientSet.CoreV1().ServiceAccounts(d.ratifyNamespace).Get(ctx, d.config.ServiceAccountName, meta.GetOptions{})
+	if err != nil {
+		return AuthConfig{}, err
+	}
+
+	// extract the imagePullSecrets linked to service account
+	for _, imagePullSecret := range serviceAccount.ImagePullSecrets {
+		secret, err := d.clusterClientSet.CoreV1().Secrets(d.ratifyNamespace).Get(ctx, imagePullSecret.Name, meta.GetOptions{})
+		if err != nil {
+			return AuthConfig{}, err
+		}
+
+		// only dockercfg or docker config json secret type allowed
+		if secret.Type == core.SecretTypeDockercfg || secret.Type == core.SecretTypeDockerConfigJson {
+			authConfig, err := d.resolveCredentialFromSecret(hostName, secret)
+			if err != nil && err != ErrorNoMatchingCredential {
+				return AuthConfig{}, err
+			}
+			// if a resolved AuthConfig is returned
+			if err == nil {
+				return authConfig, nil
+			}
+		}
+	}
+
+	return AuthConfig{}, fmt.Errorf("could not find credentials for %s", artifact)
+}
+
+func (d *k8SecretAuthProvider) resolveCredentialFromSecret(hostName string, secret *core.Secret) (AuthConfig, error) {
+	var secretDataKey string
+	var configFileFn func(io.Reader) (*configfile.ConfigFile, error)
+	if secret.Type == core.SecretTypeDockercfg {
+		// if secret is a legacy docker config type
+		secretDataKey = core.DockerConfigKey
+		configFileFn = config.LegacyLoadFromReader
+	} else {
+		// if secret is a docker config json type
+		secretDataKey = core.DockerConfigJsonKey
+		configFileFn = config.LoadFromReader
+	}
+
+	dockercfg, exists := secret.Data[secretDataKey]
+	if !exists {
+		return AuthConfig{}, fmt.Errorf("could not extract auth configs from docker config")
+	}
+
+	configFile, err := configFileFn(bytes.NewReader(dockercfg))
+	if err != nil {
+		return AuthConfig{}, err
+	}
+
+	authConfig, exist := configFile.AuthConfigs[hostName]
+	if !exist {
+		return AuthConfig{}, ErrorNoMatchingCredential
+	}
+
+	return AuthConfig{
+		Username: authConfig.Username,
+		Password: authConfig.Password,
+		Provider: d,
+	}, nil
+}


Test patch:
diff --git a/pkg/referrerstore/oras/authprovider/k8secret_authprovider_test.go b/pkg/referrerstore/oras/authprovider/k8secret_authprovider_test.go
new file mode 100644
index 0000000..9a961dc
--- /dev/null
+++ b/pkg/referrerstore/oras/authprovider/k8secret_authprovider_test.go
@@ -0,0 +1,94 @@
+/*
+Copyright The Ratify Authors.
+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 authprovider
+
+import (
+	"testing"
+
+	core "k8s.io/api/core/v1"
+)
+
+// Checks K8 Docker Json Config Secret is properly extracted and
+// credentials returned when Provide is called
+func TestProvide_K8SecretDockerConfigJson_ReturnsExpected(t *testing.T) {
+	var testSecret core.Secret
+	js := `{
+		"auths": {
+			"index.docker.io": {
+				"auth": "am9lam9lOmhlbGxv"
+			}
+		}
+	}`
+	testSecret.Data = make(map[string][]byte)
+	testSecret.Data[core.DockerConfigJsonKey] = []byte(js)
+	testSecret.Type = core.SecretTypeDockerConfigJson
+
+	var k8secretprovider k8SecretAuthProvider
+
+	authConfig, err := k8secretprovider.resolveCredentialFromSecret("index.docker.io", &testSecret)
+	if err != nil {
+		t.Fatalf("resolveCredentialFromSecret failed to get credential with err %v", err)
+	}
+
+	if authConfig.Username != "joejoe" || authConfig.Password != "hello" {
+		t.Fatalf("resolveCredentialFromSecret returned incorrect credentials (username: %s, password: %s)", authConfig.Username, authConfig.Password)
+	}
+}
+
+// Checks K8 DockerCfg Secret is properly extracted and
+// credentials returned when Provide is called
+func TestProvide_K8SecretDockerCfg_ReturnsExpected(t *testing.T) {
+	var testSecret core.Secret
+	js := `{
+		"index.docker.io": {
+			"auth": "am9lam9lOmhlbGxv"
+		}
+	}`
+	testSecret.Data = make(map[string][]byte)
+	testSecret.Data[core.DockerConfigKey] = []byte(js)
+	testSecret.Type = core.SecretTypeDockercfg
+
+	var k8secretprovider k8SecretAuthProvider
+
+	authConfig, err := k8secretprovider.resolveCredentialFromSecret("index.docker.io", &testSecret)
+	if err != nil {
+		t.Fatalf("resolveCredentialFromSecret failed to get credential with err %v", err)
+	}
+
+	if authConfig.Username != "joejoe" || authConfig.Password != "hello" {
+		t.Fatalf("resolveCredentialFromSecret returned incorrect credentials (username: %s, password: %s)", authConfig.Username, authConfig.Password)
+	}
+}
+
+// Checks an error is returned for non-existent registry credential
+func TestProvide_K8SecretNonExistentRegistry_ReturnsExpected(t *testing.T) {
+	var testSecret core.Secret
+	js := `{
+		"index.docker.io": {
+			"auth": "am9lam9lOmhlbGxv"
+		}
+	}`
+	testSecret.Data = make(map[string][]byte)
+	testSecret.Data[core.DockerConfigKey] = []byte(js)
+	testSecret.Type = core.SecretTypeDockercfg
+
+	var k8secretprovider k8SecretAuthProvider
+
+	_, err := k8secretprovider.resolveCredentialFromSecret("nonexistent.ghcr.io", &testSecret)
+	if err != ErrorNoMatchingCredential {
+		t.Fatalf("resolveCredentialFromSecret should have failed to get credential with err %v but returned err %v", ErrorNoMatchingCredential, err)
+	}
+}