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

improvement: allow namespace selection in kubernetes authentication to go over label selection #182

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ vault-image:
setup-integration-test: teardown-integration-test vault-image
kind --name $(KIND_CLUSTER_NAME) load docker-image hashicorp/vault:dev
kubectl --context="kind-$(KIND_CLUSTER_NAME)" create namespace test
kubectl --context="kind-$(KIND_CLUSTER_NAME)" label namespaces test target=integration-test other=label
helm upgrade --install vault vault --repo https://helm.releases.hashicorp.com --version=0.25.0 \
--kube-context="kind-$(KIND_CLUSTER_NAME)" \
--wait --timeout=5m \
Expand All @@ -63,6 +64,7 @@ setup-integration-test: teardown-integration-test vault-image
--set server.extraArgs="-dev-plugin-dir=/vault/plugin_directory"
kubectl --context="kind-$(KIND_CLUSTER_NAME)" apply --namespace=test -f integrationtest/vault/tokenReviewerServiceAccount.yaml
kubectl --context="kind-$(KIND_CLUSTER_NAME)" apply -f integrationtest/vault/tokenReviewerBinding.yaml
kubectl --context="kind-$(KIND_CLUSTER_NAME)" apply -f integrationtest/vault/namespaceControllerBinding.yaml
kubectl --context="kind-$(KIND_CLUSTER_NAME)" wait --namespace=test --for=condition=Ready --timeout=5m pod -l app.kubernetes.io/name=vault

.PHONY: teardown-integration-test
Expand Down
9 changes: 8 additions & 1 deletion backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ type kubeAuthBackend struct {
// review. Mocks should only be used in tests.
reviewFactory tokenReviewFactory

// nsValidatorFactory is used to configure the strategy for validating
// namespace properties (currently labels). Currently, the only options
// are using the kubernetes API or mocking the validation. Mocks should
// only be used in tests.
nsValidatorFactory namespaceValidatorFactory

// localSATokenReader caches the service account token in memory.
// It periodically reloads the token to support token rotation/renewal.
// Local token is used when running in a pod with following configuration
Expand Down Expand Up @@ -133,7 +139,8 @@ func Backend() *kubeAuthBackend {
// Set the default TLSConfig
tlsConfig: getDefaultTLSConfig(),
// Set the review factory to default to calling into the kubernetes API.
reviewFactory: tokenReviewAPIFactory,
reviewFactory: tokenReviewAPIFactory,
nsValidatorFactory: namespaceValidatorAPIFactory,
}

b.Backend = &framework.Backend{
Expand Down
5 changes: 1 addition & 4 deletions backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ func Test_kubeAuthBackend_updateTLSConfig(t *testing.T) {
for idx, config := range tt.configs {
t.Run(fmt.Sprintf("config-%d", idx), func(t *testing.T) {
if config.localCACert != "" {
if err := os.WriteFile(localFile, []byte(config.localCACert), 0600); err != nil {
if err := os.WriteFile(localFile, []byte(config.localCACert), 0o600); err != nil {
t.Fatalf("failed to write local file %q", localFile)
}
t.Cleanup(func() {
Expand Down Expand Up @@ -324,7 +324,6 @@ func Test_kubeAuthBackend_initialize(t *testing.T) {
err := b.initialize(ctx, tt.req)
if tt.wantErr && err == nil {
t.Errorf("initialize() error = %v, wantErr %v", err, tt.wantErr)

}

if !reflect.DeepEqual(err, tt.expectErr) {
Expand Down Expand Up @@ -442,7 +441,6 @@ func Test_kubeAuthBackend_runTLSConfigUpdater(t *testing.T) {
err := b.runTLSConfigUpdater(ctx, tt.storage, tt.horizon)
if tt.wantErr && err == nil {
t.Errorf("runTLSConfigUpdater() error = %v, wantErr %v", err, tt.wantErr)

}

if !reflect.DeepEqual(err, tt.expectErr) {
Expand Down Expand Up @@ -506,7 +504,6 @@ func assertTLSConfigEquals(t *testing.T, actual, expected *tls.Config) {
t.Errorf("updateTLSConfig() actual MinVersion = %v, expected MinVersion %v",
actual.MinVersion, expected.MinVersion)
}

}

func assertValidTransport(t *testing.T, b *kubeAuthBackend, expected *tls.Config) {
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,5 @@ require (
k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 // indirect
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
)
1 change: 1 addition & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -332,3 +332,4 @@ sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h6
sigs.k8s.io/structured-merge-diff/v4 v4.2.3 h1:PRbqxJClWWYMNV1dhaG4NsibJbArud9kFxnAMREiWFE=
sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E=
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
25 changes: 25 additions & 0 deletions integrationtest/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

const (
matchLabelsKeyValue = `{
"matchLabels": {
"target": "integration-test"
}
}`
)

// Set the environment variable INTEGRATION_TESTS to any non-empty value to run
// the tests in this package. The test assumes it has available:
// - A Kubernetes cluster in which:
Expand Down Expand Up @@ -149,6 +157,23 @@ func TestSuccessWithTokenReviewerJwt(t *testing.T) {
}
}

func TestSuccessWithNamespaceLabels(t *testing.T) {
roleConfigOverride := map[string]interface{}{
"bound_service_account_names": "vault",
"bound_service_account_namespace_selector": matchLabelsKeyValue,
}
client, cleanup := setupKubernetesAuth(t, "vault", nil, roleConfigOverride)
defer cleanup()

_, err := client.Logical().Write("auth/kubernetes/login", map[string]interface{}{
"role": "test-role",
"jwt": createToken(t, "vault", nil),
})
if err != nil {
t.Fatalf("Expected successful login but got: %v", err)
}
}

func TestFailWithBadTokenReviewerJwt(t *testing.T) {
client, cleanup := setupKubernetesAuth(t, "vault", map[string]interface{}{
"kubernetes_host": "https://kubernetes.default.svc.cluster.local",
Expand Down
26 changes: 26 additions & 0 deletions integrationtest/vault/namespaceControllerBinding.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: test-namespacelister-account-binding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: system:controller:namespace-controller
subjects:
- kind: ServiceAccount
name: test-token-reviewer-account
namespace: test
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: test-namespacelister-account-binding-vault
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: system:controller:namespace-controller
subjects:
- kind: ServiceAccount
name: vault
namespace: test

119 changes: 119 additions & 0 deletions namespace_validator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package kubeauth

import (
"context"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"strings"

v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
k8s_yaml "k8s.io/apimachinery/pkg/util/yaml"
)

// This exists so we can use a mock namespace validation when running tests
type namespaceValidator interface {
ValidateLabels(context.Context, *http.Client, string, metav1.LabelSelector) (bool, error)
}

type namespaceValidatorFactory func(*kubeConfig) namespaceValidator

// This is the real implementation that calls the kubernetes API
type namespaceValidatorAPI struct {
config *kubeConfig
}

func namespaceValidatorAPIFactory(config *kubeConfig) namespaceValidator {
return &namespaceValidatorAPI{
config: config,
}
}

func (v *namespaceValidatorAPI) ValidateLabels(ctx context.Context, client *http.Client, namespace string, selector metav1.LabelSelector) (bool, error) {
nsLabels, err := v.getNamespaceLabels(ctx, client, namespace)
if err != nil {
return false, err
}

labelSelector, err := metav1.LabelSelectorAsSelector(&selector)
if err != nil {
return false, err
}
return labelSelector.Matches(labels.Set(nsLabels)), nil
}

func makeLabelSelector(selector string) (metav1.LabelSelector, error) {
labelSelector := metav1.LabelSelector{}
decoder := k8s_yaml.NewYAMLOrJSONDecoder(strings.NewReader(selector), len(selector))
err := decoder.Decode(&labelSelector)
if err != nil {
return labelSelector, err
}
return labelSelector, nil
}

func (v *namespaceValidatorAPI) getNamespaceLabels(ctx context.Context, client *http.Client, namespace string) (map[string]string, error) {
url := fmt.Sprintf("%s/api/v1/namespaces/%s", strings.TrimSuffix(v.config.Host, "/"), namespace)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}

// If we have a configured TokenReviewer JWT use it as the bearer, otherwise
// try to use the passed in JWT.
if v.config.TokenReviewerJWT == "" {
return nil, errors.New("namespace lookup failed: TokenReviewer JWT needs to be configured to use namespace selectors")
}
bearer := fmt.Sprintf("Bearer %s", v.config.TokenReviewerJWT)
bearer = strings.TrimSpace(bearer)

// Set the JWT as the Bearer token
req.Header.Set("Authorization", bearer)

// Set the MIME type headers
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")

resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("failed to get namespace (code %d): %s", resp.StatusCode, body)
}
ns := v1.Namespace{}

err = json.Unmarshal(body, &ns)
if err != nil {
return nil, err
}
return ns.Labels, nil
}

type mockNamespaceValidator struct {
labels map[string]string
}

func mockNamespaceValidatorFactory(labels map[string]string) namespaceValidatorFactory {
return func(config *kubeConfig) namespaceValidator {
return &mockNamespaceValidator{
labels: labels,
}
}
}

func (v *mockNamespaceValidator) ValidateLabels(ctx context.Context, client *http.Client, namespace string, selector metav1.LabelSelector) (bool, error) {
labelSelector, err := metav1.LabelSelectorAsSelector(&selector)
if err != nil {
return false, err
}
return labelSelector.Matches(labels.Set(v.labels)), nil
}
40 changes: 30 additions & 10 deletions path_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,13 @@ func (b *kubeAuthBackend) pathLogin(ctx context.Context, req *logical.Request, d
return nil, errors.New("could not load backend configuration")
}

serviceAccount, err := b.parseAndValidateJWT(jwtStr, role, config)
client, err := b.getHTTPClient()
if err != nil {
b.Logger().Error(`Failed to get the HTTP client`, "err", err)
return nil, logical.ErrUnrecoverable
}

serviceAccount, err := b.parseAndValidateJWT(ctx, client, jwtStr, role, config)
if err != nil {
if err == jose.ErrCryptoFailure || strings.Contains(err.Error(), "verifying token signature") {
b.Logger().Debug(`login unauthorized`, "err", err)
Expand All @@ -138,12 +144,6 @@ func (b *kubeAuthBackend) pathLogin(ctx context.Context, req *logical.Request, d
return nil, err
}

client, err := b.getHTTPClient()
if err != nil {
b.Logger().Error(`Failed to get the HTTP client`, "err", err)
return nil, logical.ErrUnrecoverable
}

// look up the JWT token in the kubernetes API
err = serviceAccount.lookup(ctx, client, jwtStr, role.Audience, b.reviewFactory(config))

Expand Down Expand Up @@ -247,7 +247,13 @@ func (b *kubeAuthBackend) aliasLookahead(ctx context.Context, req *logical.Reque
}
// validation of the JWT against the provided role ensures alias look ahead requests
// are authentic.
sa, err := b.parseAndValidateJWT(jwtStr, role, config)
client, err := b.getHTTPClient()
if err != nil {
b.Logger().Error(`Failed to get the HTTP client`, "err", err)
return nil, logical.ErrUnrecoverable
}

sa, err := b.parseAndValidateJWT(ctx, client, jwtStr, role, config)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -282,7 +288,7 @@ func (keySet DontVerifySignature) VerifySignature(_ context.Context, token strin
}

// parseAndValidateJWT is used to parse, validate and lookup the JWT token.
func (b *kubeAuthBackend) parseAndValidateJWT(jwtStr string, role *roleStorageEntry, config *kubeConfig) (*serviceAccount, error) {
func (b *kubeAuthBackend) parseAndValidateJWT(ctx context.Context, client *http.Client, jwtStr string, role *roleStorageEntry, config *kubeConfig) (*serviceAccount, error) {
expected := capjwt.Expected{
SigningAlgorithms: supportedJwtAlgs,
}
Expand Down Expand Up @@ -333,7 +339,21 @@ func (b *kubeAuthBackend) parseAndValidateJWT(jwtStr string, role *roleStorageEn
}

// verify the namespace is allowed
if len(role.ServiceAccountNamespaces) > 1 || role.ServiceAccountNamespaces[0] != "*" {
valid := false
if role.ServiceAccountNamespaceSelector != "" {
labelSelector, err := makeLabelSelector(role.ServiceAccountNamespaceSelector)
if err != nil {
return nil, err
}
valid, err = b.nsValidatorFactory(config).ValidateLabels(ctx, client, sa.namespace(), labelSelector)
if err != nil {
return nil, err
}
if !valid && len(role.ServiceAccountNamespaces) == 0 {
return nil, logical.CodedError(http.StatusForbidden, "namespace not authorized")
}
}
if !valid && (len(role.ServiceAccountNamespaces) > 1 || role.ServiceAccountNamespaces[0] != "*") {
if !strutil.StrListContainsGlob(role.ServiceAccountNamespaces, sa.namespace()) {
return nil, logical.CodedError(http.StatusForbidden, "namespace not authorized")
}
Expand Down
Loading