Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support for dynamically fetching credentials from external command #149

Merged
Merged
17 changes: 16 additions & 1 deletion docs/local-run.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,21 @@ data:
token: "..." # working jwt token
```

2.3. (Alternatively) Create a secret containing an exec config to dynamically fetch the cluster credential from an external command:

```yaml
apiVersion: v1
kind: Secret
metadata:
name: managed1
labels:
cluster.core.oam.dev/cluster-credential-type: Dynamic
type: Opaque # <--- Has to be opaque
data:
endpoint: "..." # ditto
exec: "..." # an exec config in JSON format; see ExecConfig (https://github.com/kubernetes/kubernetes/blob/2016fab3085562b4132e6d3774b6ded5ba9939fd/staging/src/k8s.io/client-go/tools/clientcmd/api/types.go#L206, https://kubernetes.io/docs/reference/access-authn-authz/authentication/#configuration)
```

3. Proxy to cluster `managed1`'s `/healthz` endpoint

```shell
Expand Down Expand Up @@ -195,4 +210,4 @@ KUBECONFIG=/tmp/hub-managed1.kubeconfig kubectl get ns

```shell
$ kind delete cluster --name tmp
```
```
4 changes: 4 additions & 0 deletions pkg/apis/cluster/v1alpha1/clustergateway_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ const (
// CredentialTypeX509Certificate means the cluster is accessible via
// X509 certificate and key.
CredentialTypeX509Certificate CredentialType = "X509Certificate"
// CredentialTypeDynamic means that a credential will be issued before
// accessing the cluster. The generated credential can be either a service
// account token or X509 certificate and key.
CredentialTypeDynamic CredentialType = "Dynamic"
)

type ClusterEndpointType string
Expand Down
58 changes: 54 additions & 4 deletions pkg/apis/cluster/v1alpha1/clustergateway_types_secret.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,21 @@

import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"

"k8s.io/apimachinery/pkg/util/yaml"
"k8s.io/apiserver/pkg/registry/rest"
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
"k8s.io/utils/pointer"

"github.com/oam-dev/cluster-gateway/pkg/common"
"github.com/oam-dev/cluster-gateway/pkg/config"
"github.com/oam-dev/cluster-gateway/pkg/featuregates"
"github.com/oam-dev/cluster-gateway/pkg/options"
"github.com/oam-dev/cluster-gateway/pkg/util/exec"
"github.com/oam-dev/cluster-gateway/pkg/util/singleton"

"github.com/pkg/errors"
Expand All @@ -22,7 +26,6 @@
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/registry/rest"
utilfeature "k8s.io/apiserver/pkg/util/feature"
"k8s.io/klog/v2"
clusterv1 "open-cluster-management.io/api/cluster/v1"
Expand Down Expand Up @@ -176,11 +179,11 @@
func convert(caData []byte, apiServerEndpoint string, insecure bool, secret *v1.Secret) (*ClusterGateway, error) {
c := &ClusterGateway{
ObjectMeta: metav1.ObjectMeta{
Name: secret.Name,
Name: secret.Name,
CreationTimestamp: secret.CreationTimestamp,
},
Spec: ClusterGatewaySpec{
Provider: "",
Access: ClusterAccess{},
Access: ClusterAccess{},
},
}

Expand Down Expand Up @@ -242,11 +245,21 @@
PrivateKey: secret.Data[v1.TLSPrivateKeyKey],
},
}

case CredentialTypeServiceAccountToken:
c.Spec.Access.Credential = &ClusterAccessCredential{
Type: CredentialTypeServiceAccountToken,
ServiceAccountToken: string(secret.Data[v1.ServiceAccountTokenKey]),
}

case CredentialTypeDynamic:
credential, err := buildCredentialFromExecConfig(secret)
if err != nil {
return nil, fmt.Errorf("failed to issue credential from external command: %s", err)
}

c.Spec.Access.Credential = credential

default:
return nil, fmt.Errorf("unrecognized secret credential type %v", credentialType)
}
Expand Down Expand Up @@ -278,3 +291,40 @@

return c, nil
}

func buildCredentialFromExecConfig(secret *v1.Secret) (*ClusterAccessCredential, error) {
execConfigRaw := secret.Data["exec"]
if len(execConfigRaw) == 0 {
return nil, errors.New("missing secret data key: exec")
}

var ec clientcmdapi.ExecConfig
if err := json.Unmarshal(execConfigRaw, &ec); err != nil {
return nil, fmt.Errorf("failed to decode exec config JSON from secret data: %v", err)
}

cred, err := exec.IssueClusterCredential(secret.Name, &ec)
if err != nil {
return nil, err
}

Check warning on line 309 in pkg/apis/cluster/v1alpha1/clustergateway_types_secret.go

View check run for this annotation

Codecov / codecov/patch

pkg/apis/cluster/v1alpha1/clustergateway_types_secret.go#L308-L309

Added lines #L308 - L309 were not covered by tests

if token := cred.Status.Token; len(token) > 0 {
return &ClusterAccessCredential{
Type: CredentialTypeDynamic,
ServiceAccountToken: token,
}, nil
}

if cert, key := cred.Status.ClientCertificateData, cred.Status.ClientKeyData; len(cert) > 0 && len(key) > 0 {
return &ClusterAccessCredential{
Type: CredentialTypeDynamic,
X509: &X509{
Certificate: []byte(cert),
PrivateKey: []byte(key),
},
}, nil

}

return nil, fmt.Errorf("no credential type available")

Check warning on line 329 in pkg/apis/cluster/v1alpha1/clustergateway_types_secret.go

View check run for this annotation

Codecov / codecov/patch

pkg/apis/cluster/v1alpha1/clustergateway_types_secret.go#L329

Added line #L329 was not covered by tests
}
201 changes: 194 additions & 7 deletions pkg/apis/cluster/v1alpha1/clustergateway_types_secret_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,29 @@ import (
)

var (
testNamespace = "foo"
testName = "bar"
testCAData = "caData"
testCertData = "certData"
testKeyData = "keyData"
testToken = "token"
testEndpoint = "https://localhost:443"
testNamespace = "foo"
testName = "bar"
testCAData = "caData"
testCertData = "certData"
testKeyData = "keyData"
testToken = "token"
testEndpoint = "https://localhost:443"
testExecConfigForToken = `{
"apiVersion": "client.authentication.k8s.io/v1beta1",
"kind": "ExecConfig",
"command": "echo",
"args": [
"{\"apiVersion\": \"client.authentication.k8s.io/v1beta1\", \"kind\": \"ExecCredential\", \"status\": {\"token\": \"token\"}}"
]
}`
testExecConfigForX509 = `{
"apiVersion": "client.authentication.k8s.io/v1beta1",
"kind": "ExecConfig",
"command": "echo",
"args": [
"{\"apiVersion\": \"client.authentication.k8s.io/v1beta1\", \"kind\": \"ExecCredential\", \"status\": {\"clientCertificateData\": \"certData\", \"clientKeyData\": \"keyData\"}}"
]
}`
)

func TestConvertSecretToGateway(t *testing.T) {
Expand Down Expand Up @@ -260,6 +276,101 @@ func TestConvertSecretToGateway(t *testing.T) {
},
},
},
{
name: "dynamic service account token issued from external command",
inputSecret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: testName,
Namespace: testNamespace,
Labels: map[string]string{
common.LabelKeyClusterCredentialType: string(CredentialTypeDynamic),
},
},
Data: map[string][]byte{
"endpoint": []byte(testEndpoint),
"ca.crt": []byte(testCAData),
"exec": []byte(testExecConfigForToken),
},
},
expected: &ClusterGateway{
ObjectMeta: metav1.ObjectMeta{
Name: testName,
},
Spec: ClusterGatewaySpec{
Access: ClusterAccess{
Credential: &ClusterAccessCredential{
Type: CredentialTypeDynamic,
ServiceAccountToken: testToken,
},
Endpoint: &ClusterEndpoint{
Type: ClusterEndpointTypeConst,
Const: &ClusterEndpointConst{
CABundle: []byte(testCAData),
Address: testEndpoint,
},
},
},
},
},
},
{
name: "dynamic x509 cert-key pair issued from external command",
inputSecret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: testName,
Namespace: testNamespace,
Labels: map[string]string{
common.LabelKeyClusterCredentialType: string(CredentialTypeDynamic),
},
},
Data: map[string][]byte{
"endpoint": []byte(testEndpoint),
"ca.crt": []byte(testCAData),
"exec": []byte(testExecConfigForX509),
},
},
expected: &ClusterGateway{
ObjectMeta: metav1.ObjectMeta{
Name: testName,
},
Spec: ClusterGatewaySpec{
Access: ClusterAccess{
Credential: &ClusterAccessCredential{
Type: CredentialTypeDynamic,
X509: &X509{
Certificate: []byte(testCertData),
PrivateKey: []byte(testKeyData),
},
},
Endpoint: &ClusterEndpoint{
Type: ClusterEndpointTypeConst,
Const: &ClusterEndpointConst{
CABundle: []byte(testCAData),
Address: testEndpoint,
},
},
},
},
},
},
{
name: "failed to fetch cluster credential from dynamic auth mode",
inputSecret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: testName,
Namespace: testNamespace,
Labels: map[string]string{
common.LabelKeyClusterCredentialType: string(CredentialTypeDynamic),
},
},
Data: map[string][]byte{
"endpoint": []byte(testEndpoint),
"ca.crt": []byte(testCAData),
"exec": []byte("invalid exec config format"),
},
},
expectedFailure: true,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
Expand Down Expand Up @@ -524,3 +635,79 @@ func TestListHybridClusterGateway(t *testing.T) {
}
assert.Equal(t, expectedNames, actualNames)
}

func TestBuildCredentialFromExecConfig(t *testing.T) {
cases := []struct {
name string
secret func(s *corev1.Secret) *corev1.Secret
cluster func(ce *ClusterEndpoint) *ClusterEndpoint
expectedError string
expected *ClusterAccessCredential
}{
{
name: "missing exec config",
expectedError: "missing secret data key: exec",
},

{
name: "invalid exec config format",
secret: func(s *corev1.Secret) *corev1.Secret {
s.Data["exec"] = []byte("some invalid exec config")
return s
},
expectedError: "failed to decode exec config JSON from secret data: invalid character 's' looking for beginning of value",
},

{
name: "returns successfully a service account token",
secret: func(s *corev1.Secret) *corev1.Secret {
s.Data["exec"] = []byte(`{"apiVersion": "client.authentication.k8s.io/v1", "command": "echo", "args": ["{\"apiVersion\": \"client.authentication.k8s.io/v1\", \"status\": {\"token\": \"token\"}}"]}`)
return s
},
expected: &ClusterAccessCredential{
Type: CredentialTypeDynamic,
ServiceAccountToken: testToken,
},
},

{
name: "returns successfully a X509 client certificate",
secret: func(s *corev1.Secret) *corev1.Secret {
s.Data["exec"] = []byte(`{"apiVersion": "client.authentication.k8s.io/v1", "command": "echo", "args": ["{\"apiVersion\": \"client.authentication.k8s.io/v1\", \"status\": {\"clientCertificateData\": \"certData\", \"clientKeyData\": \"keyData\"}}"]}`)
return s
},
expected: &ClusterAccessCredential{
Type: CredentialTypeDynamic,
X509: &X509{
Certificate: []byte(testCertData),
PrivateKey: []byte(testKeyData),
},
},
},
}

for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: testName,
Namespace: testNamespace,
},
Data: map[string][]byte{},
}
if tt.secret != nil {
secret = tt.secret(secret)
}

got, err := buildCredentialFromExecConfig(secret)
if tt.expectedError != "" {
assert.Error(t, err)
assert.EqualError(t, err, tt.expectedError)
return
}

assert.NoError(t, err)
assert.Equal(t, tt.expected, got)
})
}
}
11 changes: 11 additions & 0 deletions pkg/apis/cluster/v1alpha1/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,19 @@ func NewConfigFromCluster(ctx context.Context, c *ClusterGateway) (*restclient.C
}
// setting up credentials
switch c.Spec.Access.Credential.Type {
case CredentialTypeDynamic:
if token := c.Spec.Access.Credential.ServiceAccountToken; token != "" {
cfg.BearerToken = token
}

if c.Spec.Access.Credential.X509 != nil && len(c.Spec.Access.Credential.X509.Certificate) > 0 && len(c.Spec.Access.Credential.X509.PrivateKey) > 0 {
cfg.CertData = c.Spec.Access.Credential.X509.Certificate
cfg.KeyData = c.Spec.Access.Credential.X509.PrivateKey
}

case CredentialTypeServiceAccountToken:
cfg.BearerToken = c.Spec.Access.Credential.ServiceAccountToken

case CredentialTypeX509Certificate:
cfg.CertData = c.Spec.Access.Credential.X509.Certificate
cfg.KeyData = c.Spec.Access.Credential.X509.PrivateKey
Expand Down
Loading
Loading