Skip to content

Commit

Permalink
impr: finish implementation for selector definition in roles
Browse files Browse the repository at this point in the history
Implements: hashicorp#155
  • Loading branch information
f4z3r committed Sep 4, 2023
1 parent 1814f45 commit d10efca
Show file tree
Hide file tree
Showing 11 changed files with 273 additions and 36 deletions.
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
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": os.Getenv("KUBERNETES_JWT"),
})
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

97 changes: 94 additions & 3 deletions namespace_validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,22 @@ 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, map[string]string) (bool, error)
ValidateLabels(context.Context, *http.Client, string, metav1.LabelSelector) (bool, error)
}

type namespaceValidatorFactory func(*kubeConfig) namespaceValidator
Expand All @@ -23,6 +33,87 @@ func namespaceValidatorAPIFactory(config *kubeConfig) namespaceValidator {
}
}

func (t *namespaceValidatorAPI) ValidateLabels(ctx context.Context, client *http.Client, name string, labels map[string]string) (bool, error) {
return true, nil
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

0 comments on commit d10efca

Please sign in to comment.