diff --git a/pkg/clusteraccess/access.go b/pkg/clusteraccess/access.go index be6d96f..2d62f07 100644 --- a/pkg/clusteraccess/access.go +++ b/pkg/clusteraccess/access.go @@ -5,6 +5,8 @@ import ( "fmt" "time" + "sigs.k8s.io/yaml" + authenticationv1 "k8s.io/api/authentication/v1" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" @@ -341,3 +343,138 @@ func ComputeTokenRenewalTimeWithRatio(creationTime, expirationTime time.Time, ra renewalAt := creationTime.Add(renewalAfter) return renewalAt } + +// oidcTrustConfig represents the configuration for an OIDC trust relationship. +// It includes the host of the Kubernetes API server, CA data for TLS verification, +// and the audience for the OIDC tokens. +type oidcTrustConfig struct { + // Host is the URL of the Kubernetes API server. + Host string `json:"host,omitempty"` + // CAData is the base64-encoded CA certificate data used to verify the server's TLS certificate. + CAData []byte `json:"caData,omitempty"` +} + +// WriteOIDCConfigFromRESTConfig converts a RESTConfig to an OIDC trust configuration format. +// When creating a Kubernetes deployment, this configuration is used to set up the trust relationship to +// the target cluster. +// Example: +// +// spec: +// +// template: +// spec: +// volumes: +// - name: oidc-trust-config +// projected: +// sources: +// - secret: +// name: oidc-trust-config +// items: +// - key: host +// path: cluster/host +// - key: caData +// path: cluster/ca.crt +// - serviceAccountToken: +// audience: target-cluster +// path: cluster/token +// expirationSeconds: 3600 +// +// volumeMounts: +// - name: oidc-trust-config +// mountPath: /var/run/secrets/oidc-trust-config +// readOnly: true +func WriteOIDCConfigFromRESTConfig(restConfig *rest.Config) ([]byte, error) { + oidcConfig := &oidcTrustConfig{ + Host: restConfig.Host, + CAData: restConfig.CAData, + } + + configMarshaled, err := yaml.Marshal(oidcConfig) + if err != nil { + return nil, fmt.Errorf("failed to write OIDC trust config: %w", err) + } + + return configMarshaled, nil +} + +// WriteKubeconfigFromRESTConfig converts the RESTConfig to a kubeconfig format. +// Supported authentication methods are Bearer Token, Username/Password and Client Certificate. +func WriteKubeconfigFromRESTConfig(restConfig *rest.Config) ([]byte, error) { + var authInfo *clientcmdapi.AuthInfo + + id := "cluster" + + type authType string + const ( + authTypeBearerToken authType = "BearerToken" + authTypeBasicAuth authType = "BasicAuth" + authTypeClientCert authType = "ClientCert" + ) + availableAuthTypes := make(map[authType]interface{}) + if restConfig.BearerToken != "" { + availableAuthTypes[authTypeBearerToken] = nil + } + + if restConfig.Username != "" && restConfig.Password != "" { + availableAuthTypes[authTypeBasicAuth] = nil + } + + if restConfig.CertData != nil && restConfig.KeyData != nil { + availableAuthTypes[authTypeClientCert] = nil + } + + if len(availableAuthTypes) == 0 { + return nil, fmt.Errorf("cannot write to kubeconfig when RESTConfig does not contain any supported authentication information") + } + + if _, ok := availableAuthTypes[authTypeBearerToken]; ok { + authInfo = &clientcmdapi.AuthInfo{ + Token: restConfig.BearerToken, + } + } + + if _, ok := availableAuthTypes[authTypeBasicAuth]; ok { + authInfo = &clientcmdapi.AuthInfo{ + Username: restConfig.Username, + Password: restConfig.Password, + } + } + + if _, ok := availableAuthTypes[authTypeClientCert]; ok { + authInfo = &clientcmdapi.AuthInfo{ + ClientCertificateData: restConfig.CertData, + ClientKeyData: restConfig.KeyData, + } + } + + server := restConfig.Host + if restConfig.APIPath != "" { + server = fmt.Sprint(server, "/", restConfig.APIPath) + } + + kubeConfig := clientcmdapi.Config{ + CurrentContext: id, + Contexts: map[string]*clientcmdapi.Context{ + id: { + AuthInfo: id, + Cluster: id, + }, + }, + Clusters: map[string]*clientcmdapi.Cluster{ + id: { + Server: server, + CertificateAuthorityData: restConfig.CAData, + }, + }, + AuthInfos: map[string]*clientcmdapi.AuthInfo{ + id: authInfo, + }, + } + + configMarshaled, err := clientcmd.Write(kubeConfig) + if err != nil { + return nil, fmt.Errorf("failed to write RESTConfig to kubeconfig: %w", err) + } + + return configMarshaled, nil +} diff --git a/pkg/clusteraccess/access_test.go b/pkg/clusteraccess/access_test.go index b79558f..286b5a8 100644 --- a/pkg/clusteraccess/access_test.go +++ b/pkg/clusteraccess/access_test.go @@ -1,9 +1,15 @@ package clusteraccess_test import ( + "fmt" + "os" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/yaml" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" @@ -388,4 +394,72 @@ var _ = Describe("ClusterAccess", func() { }) + Context("Marshal RESTConfig", func() { + readRESTConfigFromKubeconfig := func(kubeconfig string) *rest.Config { + data, err := os.ReadFile(fmt.Sprint("./testdata/kubeconfig/", kubeconfig)) + Expect(err).ToNot(HaveOccurred(), "failed to read kubeconfig file") + + config, err := clientcmd.RESTConfigFromKubeConfig(data) + Expect(err).ToNot(HaveOccurred(), "failed to parse kubeconfig file") + return config + } + + It("should create an OIDC config", func() { + restConfig := readRESTConfigFromKubeconfig("kubeconfig-token.yaml") + + oidcConfigRaw, err := clusteraccess.WriteOIDCConfigFromRESTConfig(restConfig) + Expect(err).ToNot(HaveOccurred()) + Expect(oidcConfigRaw).ToNot(BeEmpty()) + + var oidcConfig map[string]string + Expect(yaml.Unmarshal(oidcConfigRaw, &oidcConfig)).ToNot(HaveOccurred()) + Expect(oidcConfig).To(HaveKeyWithValue("host", "https://test-server")) + Expect(oidcConfig) + }) + + It("should create a kubeconfig with token", func() { + restConfig := readRESTConfigFromKubeconfig("kubeconfig-token.yaml") + + kubeconfigRaw, err := clusteraccess.WriteKubeconfigFromRESTConfig(restConfig) + Expect(err).ToNot(HaveOccurred()) + Expect(kubeconfigRaw).ToNot(BeEmpty()) + + config, err := clientcmd.RESTConfigFromKubeConfig(kubeconfigRaw) + Expect(err).ToNot(HaveOccurred()) + Expect(config.Host).To(Equal("https://test-server")) + Expect(config.TLSClientConfig.CAData).ToNot(BeEmpty()) + Expect(config.BearerToken).To(Equal("dGVzdC10b2tlbg==")) + }) + + It("should create a kubeconfig with basic auth", func() { + restConfig := readRESTConfigFromKubeconfig("kubeconfig-basicauth.yaml") + + kubeconfigRaw, err := clusteraccess.WriteKubeconfigFromRESTConfig(restConfig) + Expect(err).ToNot(HaveOccurred()) + Expect(kubeconfigRaw).ToNot(BeEmpty()) + + config, err := clientcmd.RESTConfigFromKubeConfig(kubeconfigRaw) + Expect(err).ToNot(HaveOccurred()) + Expect(config.Host).To(Equal("https://test-server")) + Expect(config.TLSClientConfig.CAData).ToNot(BeEmpty()) + Expect(config.Username).To(Equal("foo")) + Expect(config.Password).To(Equal("bar")) + }) + + It("should create a kubeconfig with client tls", func() { + restConfig := readRESTConfigFromKubeconfig("kubeconfig-tls.yaml") + + kubeconfigRaw, err := clusteraccess.WriteKubeconfigFromRESTConfig(restConfig) + Expect(err).ToNot(HaveOccurred()) + Expect(kubeconfigRaw).ToNot(BeEmpty()) + + config, err := clientcmd.RESTConfigFromKubeConfig(kubeconfigRaw) + Expect(err).ToNot(HaveOccurred()) + Expect(config.Host).To(Equal("https://test-server")) + Expect(config.TLSClientConfig.CAData).ToNot(BeEmpty()) + Expect(config.TLSClientConfig.CertData).ToNot(BeEmpty()) + Expect(config.TLSClientConfig.KeyData).ToNot(BeEmpty()) + }) + }) + }) diff --git a/pkg/clusteraccess/testdata/kubeconfig/kubeconfig-basicauth.yaml b/pkg/clusteraccess/testdata/kubeconfig/kubeconfig-basicauth.yaml new file mode 100644 index 0000000..6717bb7 --- /dev/null +++ b/pkg/clusteraccess/testdata/kubeconfig/kubeconfig-basicauth.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: Config +clusters: +- name: test-cluster + cluster: + server: https://test-server + certificate-authority-data: dGVzdC1jYS1kYXRh + +contexts: +- name: test-context + context: + cluster: test-cluster + user: test-auth + +current-context: test-context + +users: +- name: test-auth + user: + username: foo + password: bar diff --git a/pkg/clusteraccess/testdata/kubeconfig/kubeconfig-tls.yaml b/pkg/clusteraccess/testdata/kubeconfig/kubeconfig-tls.yaml new file mode 100644 index 0000000..15ebe8e --- /dev/null +++ b/pkg/clusteraccess/testdata/kubeconfig/kubeconfig-tls.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: Config +clusters: +- name: test-cluster + cluster: + server: https://test-server + certificate-authority-data: dGVzdC1jYS1kYXRh + +contexts: +- name: test-context + context: + cluster: test-cluster + user: test-auth + +current-context: test-context + +users: +- name: test-auth + user: + client-certificate-data: dGVzdC1jYS1jZXJ0aWZpY2F0ZQ== + client-key-data: dGVzdC1jYS1rZXk= diff --git a/pkg/clusteraccess/testdata/kubeconfig/kubeconfig-token.yaml b/pkg/clusteraccess/testdata/kubeconfig/kubeconfig-token.yaml new file mode 100644 index 0000000..cea8892 --- /dev/null +++ b/pkg/clusteraccess/testdata/kubeconfig/kubeconfig-token.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: Config +clusters: +- name: test-cluster + cluster: + server: https://test-server + certificate-authority-data: dGVzdC1jYS1kYXRh + +contexts: +- name: test-context + context: + cluster: test-cluster + user: test-auth + +current-context: test-context + +users: +- name: test-auth + user: + token: dGVzdC10b2tlbg== diff --git a/pkg/clusters/cluster.go b/pkg/clusters/cluster.go index 67e7991..91a8d65 100644 --- a/pkg/clusters/cluster.go +++ b/pkg/clusters/cluster.go @@ -3,6 +3,8 @@ package clusters import ( "fmt" + "github.com/openmcp-project/controller-utils/pkg/clusteraccess" + flag "github.com/spf13/pflag" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/rest" @@ -235,3 +237,25 @@ func (c *Cluster) APIServerEndpoint() string { } return c.restCfg.Host } + +///////////////// +// Serializing // +///////////////// + +// WriteKubeconfig writes the cluster's kubeconfig to a byte slice. +// see clusteraccess.WriteKubeconfigFromRESTConfig for details. +func (c *Cluster) WriteKubeconfig() ([]byte, error) { + if c.restCfg == nil { + return nil, fmt.Errorf("cannot write kubeconfig for cluster when REST config is not set") + } + return clusteraccess.WriteKubeconfigFromRESTConfig(c.restCfg) +} + +// WriteOIDCConfig writes the cluster's OIDC config to a byte slice. +// see clusteraccess.WriteOIDCConfigFromRESTConfig for details. +func (c *Cluster) WriteOIDCConfig() ([]byte, error) { + if c.restCfg == nil { + return nil, fmt.Errorf("cannot write OIDC config for cluster when REST config is not set") + } + return clusteraccess.WriteOIDCConfigFromRESTConfig(c.restCfg) +}