├── .go-version ├── .gitignore ├── integrationtest ├── vault │ ├── tokenReviewerServiceAccount.yaml │ ├── Dockerfile │ ├── tokenReviewerBinding.yaml │ └── namespaceControllerBinding.yaml ├── k8s │ ├── client.go │ └── portforward.go ├── go.mod └── integration_test.go ├── CODEOWNERS ├── .github ├── workflows │ ├── backport.yaml │ ├── bulk-dep-upgrades.yaml │ ├── jira.yaml │ └── tests.yaml ├── dependabot.yaml └── PULL_REQUEST_TEMPLATE.md ├── scripts └── gofmtcheck.sh ├── helpers.go ├── cmd └── vault-plugin-auth-kubernetes │ └── main.go ├── caching_file_reader_test.go ├── caching_file_reader.go ├── Makefile ├── service_account_getter.go ├── namespace_validator.go ├── README.md ├── token_review.go ├── go.mod ├── common_test.go ├── CHANGELOG.md ├── path_config.go ├── backend_test.go ├── backend.go ├── path_role.go ├── LICENSE ├── path_login.go ├── path_config_test.go └── path_role_test.go /.go-version: -------------------------------------------------------------------------------- 1 | 1.25.3 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pkg/* 2 | bin/* 3 | .idea/* 4 | vault-plugin-auth-kubernetes 5 | go.work 6 | scratch/ 7 | -------------------------------------------------------------------------------- /integrationtest/vault/tokenReviewerServiceAccount.yaml: -------------------------------------------------------------------------------- 1 | # Copyright (c) HashiCorp, Inc. 2 | # SPDX-License-Identifier: MPL-2.0 3 | 4 | apiVersion: v1 5 | kind: ServiceAccount 6 | metadata: 7 | name: test-token-reviewer-account 8 | namespace: test 9 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Each line is a file pattern followed by one or more owners. Being an owner 2 | # means those groups or individuals will be added as reviewers to PRs affecting 3 | # those areas of the code. 4 | # 5 | # More on CODEOWNERS files: https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners 6 | 7 | * @hashicorp/vault-ecosystem 8 | -------------------------------------------------------------------------------- /integrationtest/vault/Dockerfile: -------------------------------------------------------------------------------- 1 | # Copyright (c) HashiCorp, Inc. 2 | # SPDX-License-Identifier: MPL-2.0 3 | 4 | FROM docker.mirror.hashicorp.services/hashicorp/vault:1.19.0 5 | 6 | # Don't use `kubernetes` as plugin name to ensure we don't silently fall back to 7 | # the built-in kubernetes auth plugin if something goes wrong. 8 | COPY --chown=vault:vault vault-plugin-auth-kubernetes /vault/plugin_directory/kubernetes-dev 9 | -------------------------------------------------------------------------------- /.github/workflows/backport.yaml: -------------------------------------------------------------------------------- 1 | name: Backport Assistant 2 | on: 3 | pull_request_target: 4 | types: 5 | - closed 6 | - labeled 7 | permissions: write-all 8 | jobs: 9 | backport: 10 | # using `main` as the ref will keep your workflow up-to-date 11 | uses: hashicorp/vault-workflows-common/.github/workflows/backport.yaml@main 12 | secrets: 13 | VAULT_ECO_GITHUB_TOKEN: ${{ secrets.VAULT_ECO_GITHUB_TOKEN }} -------------------------------------------------------------------------------- /integrationtest/vault/tokenReviewerBinding.yaml: -------------------------------------------------------------------------------- 1 | # Copyright (c) HashiCorp, Inc. 2 | # SPDX-License-Identifier: MPL-2.0 3 | 4 | apiVersion: rbac.authorization.k8s.io/v1 5 | kind: ClusterRoleBinding 6 | metadata: 7 | name: test-token-reviewer-account-binding 8 | roleRef: 9 | apiGroup: rbac.authorization.k8s.io 10 | kind: ClusterRole 11 | name: system:auth-delegator 12 | subjects: 13 | - kind: ServiceAccount 14 | name: test-token-reviewer-account 15 | namespace: test -------------------------------------------------------------------------------- /scripts/gofmtcheck.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Copyright (c) HashiCorp, Inc. 3 | # SPDX-License-Identifier: MPL-2.0 4 | 5 | 6 | echo "==> Checking that code complies with gofmt requirements..." 7 | 8 | gofmt_files=$(gofmt -l `find . -name '*.go'`) 9 | if [[ -n ${gofmt_files} ]]; then 10 | echo 'gofmt needs running on the following files:' 11 | echo "${gofmt_files}" 12 | echo "You can use the command: \`make fmt\` to reformat code." 13 | exit 1 14 | fi 15 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | # Copyright (c) HashiCorp, Inc. 2 | # SPDX-License-Identifier: MPL-2.0 3 | 4 | --- 5 | version: 2 6 | 7 | updates: 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | labels: ["dependencies"] 13 | groups: 14 | github-actions-breaking: 15 | update-types: 16 | - major 17 | github-actions-backward-compatible: 18 | update-types: 19 | - minor 20 | - patch 21 | -------------------------------------------------------------------------------- /helpers.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package kubeauth 5 | 6 | import ( 7 | "net/http" 8 | "strings" 9 | ) 10 | 11 | func setRequestHeader(req *http.Request, bearer string) { 12 | bearer = strings.TrimSpace(bearer) 13 | 14 | // Set the JWT as the Bearer token 15 | req.Header.Set("Authorization", bearer) 16 | 17 | // Set the MIME type headers 18 | req.Header.Set("Content-Type", "application/json") 19 | req.Header.Set("Accept", "application/json") 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/bulk-dep-upgrades.yaml: -------------------------------------------------------------------------------- 1 | name: Upgrade dependencies 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | # Runs 12:00AM on the first of every month 6 | - cron: '0 0 1 * *' 7 | jobs: 8 | upgrade: 9 | # using `main` as the ref will keep your workflow up-to-date 10 | uses: hashicorp/vault-workflows-common/.github/workflows/bulk-dependency-updates.yaml@main 11 | secrets: 12 | VAULT_ECO_GITHUB_TOKEN: ${{ secrets.VAULT_ECO_GITHUB_TOKEN }} 13 | with: 14 | reviewer-team: hashicorp/vault-ecosystem 15 | repository: ${{ github.repository }} 16 | run-id: ${{ github.run_id }} 17 | -------------------------------------------------------------------------------- /.github/workflows/jira.yaml: -------------------------------------------------------------------------------- 1 | name: Jira Sync 2 | on: 3 | issues: 4 | types: [opened, closed, deleted, reopened] 5 | pull_request_target: 6 | types: [opened, closed, reopened] 7 | issue_comment: # Also triggers when commenting on a PR from the conversation view 8 | types: [created] 9 | jobs: 10 | sync: 11 | uses: hashicorp/vault-workflows-common/.github/workflows/jira.yaml@main 12 | secrets: 13 | JIRA_SYNC_BASE_URL: ${{ secrets.JIRA_SYNC_BASE_URL }} 14 | JIRA_SYNC_USER_EMAIL: ${{ secrets.JIRA_SYNC_USER_EMAIL }} 15 | JIRA_SYNC_API_TOKEN: ${{ secrets.JIRA_SYNC_API_TOKEN }} 16 | with: 17 | teams-array: '["vault-eco-infra"]' 18 | -------------------------------------------------------------------------------- /integrationtest/vault/namespaceControllerBinding.yaml: -------------------------------------------------------------------------------- 1 | # Copyright (c) HashiCorp, Inc. 2 | # SPDX-License-Identifier: MPL-2.0 3 | 4 | apiVersion: rbac.authorization.k8s.io/v1 5 | kind: ClusterRoleBinding 6 | metadata: 7 | name: test-namespacelister-account-binding 8 | roleRef: 9 | apiGroup: rbac.authorization.k8s.io 10 | kind: ClusterRole 11 | name: system:controller:namespace-controller 12 | subjects: 13 | - kind: ServiceAccount 14 | name: test-token-reviewer-account 15 | namespace: test 16 | --- 17 | apiVersion: rbac.authorization.k8s.io/v1 18 | kind: ClusterRoleBinding 19 | metadata: 20 | name: test-namespacelister-account-binding-vault 21 | roleRef: 22 | apiGroup: rbac.authorization.k8s.io 23 | kind: ClusterRole 24 | name: system:controller:namespace-controller 25 | subjects: 26 | - kind: ServiceAccount 27 | name: vault 28 | namespace: test 29 | 30 | -------------------------------------------------------------------------------- /integrationtest/k8s/client.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package k8s 5 | 6 | import ( 7 | "fmt" 8 | 9 | "k8s.io/client-go/kubernetes" 10 | "k8s.io/client-go/rest" 11 | "k8s.io/client-go/tools/clientcmd" 12 | ) 13 | 14 | func ClientFromKubeConfig(kubeContext string) (*kubernetes.Clientset, error) { 15 | config, err := kubeConfig(kubeContext) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | k8sClient, err := kubernetes.NewForConfig(config) 21 | if err != nil { 22 | return nil, fmt.Errorf("failed to create client: %w", err) 23 | } 24 | 25 | return k8sClient, nil 26 | } 27 | 28 | func kubeConfig(kubeContext string) (*rest.Config, error) { 29 | config, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( 30 | clientcmd.NewDefaultClientConfigLoadingRules(), 31 | &clientcmd.ConfigOverrides{ 32 | CurrentContext: kubeContext, 33 | }, 34 | ).ClientConfig() 35 | if err != nil { 36 | return nil, fmt.Errorf("failed to setup config: %w", err) 37 | } 38 | 39 | return config, nil 40 | } 41 | -------------------------------------------------------------------------------- /cmd/vault-plugin-auth-kubernetes/main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "os" 8 | 9 | log "github.com/hashicorp/go-hclog" 10 | 11 | kubeauth "github.com/hashicorp/vault-plugin-auth-kubernetes" 12 | "github.com/hashicorp/vault/api" 13 | "github.com/hashicorp/vault/sdk/plugin" 14 | ) 15 | 16 | func main() { 17 | apiClientMeta := &api.PluginAPIClientMeta{} 18 | 19 | flags := apiClientMeta.FlagSet() 20 | if err := flags.Parse(os.Args[1:]); err != nil { 21 | fatal(err) 22 | } 23 | 24 | tlsConfig := apiClientMeta.GetTLSConfig() 25 | tlsProviderFunc := api.VaultPluginTLSProvider(tlsConfig) 26 | 27 | err := plugin.ServeMultiplex(&plugin.ServeOpts{ 28 | BackendFactoryFunc: kubeauth.Factory, 29 | // set the TLSProviderFunc so that the plugin maintains backwards 30 | // compatibility with Vault versions that don’t support plugin AutoMTLS 31 | TLSProviderFunc: tlsProviderFunc, 32 | }) 33 | if err != nil { 34 | fatal(err) 35 | } 36 | } 37 | 38 | func fatal(err error) { 39 | log.L().Error("plugin shutting down", "error", err) 40 | os.Exit(1) 41 | } 42 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | A high level description of the contribution, including: 3 | Who the change affects or is for (stakeholders)? 4 | What is the change? 5 | Why is the change needed? 6 | How does this change affect the user experience (if at all)? 7 | 8 | # Design of Change 9 | How was this change implemented? 10 | 11 | # Related Issues/Pull Requests 12 | [ ] [Issue #1234](https://github.com/hashicorp/vault/issues/1234) 13 | [ ] [PR #1234](https://github.com/hashicorp/vault/pr/1234) 14 | 15 | # Contributor Checklist 16 | [ ] Add relevant docs to upstream Vault repository, or sufficient reasoning why docs won’t be added yet 17 | [My Docs PR Link](link) 18 | [Example](https://github.com/hashicorp/vault/commit/2715f5cec982aabc7b7a6ae878c547f6f475bba6) 19 | [ ] Add output for any tests not ran in CI to the PR description (eg, acceptance tests) 20 | [ ] Backwards compatible 21 | 22 | ## PCI review checklist 23 | 24 | 25 | 26 | - [ ] I have documented a clear reason for, and description of, the change I am making. 27 | 28 | - [ ] If applicable, I've documented a plan to revert these changes if they require more than reverting the pull request. 29 | 30 | - [ ] If applicable, I've documented the impact of any changes to security controls. 31 | 32 | Examples of changes to security controls include using new access control methods, adding or removing logging pipelines, etc. 33 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push, workflow_dispatch] 4 | 5 | jobs: 6 | fmtcheck: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 10 | - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 11 | with: 12 | go-version-file: .go-version 13 | - run: make fmtcheck 14 | 15 | test: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 19 | - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 20 | with: 21 | go-version-file: .go-version 22 | - run: make test 23 | 24 | integrationTest: 25 | runs-on: ubuntu-latest 26 | needs: [fmtcheck, test] 27 | strategy: 28 | fail-fast: false 29 | matrix: 30 | kind-k8s-version: [1.29.14, 1.30.13, 1.31.12, 1.32.8, 1.33.4] 31 | steps: 32 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 33 | - name: Create K8s Kind Cluster 34 | uses: helm/kind-action@92086f6be054225fa813e0a4b13787fc9088faab # v1.13.0 35 | with: 36 | version: v0.27.0 37 | cluster_name: vault-plugin-auth-kubernetes 38 | node_image: kindest/node:v${{ matrix.kind-k8s-version }} 39 | - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 40 | with: 41 | go-version-file: .go-version 42 | - run: make setup-integration-test 43 | - env: 44 | INTEGRATION_TESTS: true 45 | run: make integration-test 46 | -------------------------------------------------------------------------------- /caching_file_reader_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package kubeauth 5 | 6 | import ( 7 | "io/ioutil" 8 | "os" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | func TestCachingFileReader(t *testing.T) { 14 | content1 := "before" 15 | content2 := "after" 16 | 17 | // Create temporary file. 18 | f, err := ioutil.TempFile("", "testfile") 19 | if err != nil { 20 | t.Error(err) 21 | } 22 | f.Close() 23 | defer os.Remove(f.Name()) 24 | 25 | currentTime := time.Now() 26 | 27 | r := newCachingFileReader(f.Name(), 1*time.Minute, 28 | func() time.Time { 29 | return currentTime 30 | }) 31 | 32 | // Write initial content to file and check that we can read it. 33 | err = ioutil.WriteFile(f.Name(), []byte(content1), 0o644) 34 | if err != nil { 35 | t.Error(err) 36 | } 37 | got, err := r.ReadFile() 38 | if err != nil { 39 | t.Error(err) 40 | } 41 | if got != content1 { 42 | t.Errorf("got '%s', expected '%s'", got, content1) 43 | } 44 | 45 | // Write new content to the file. 46 | err = ioutil.WriteFile(f.Name(), []byte(content2), 0o644) 47 | if err != nil { 48 | t.Error(err) 49 | } 50 | 51 | // Advance simulated time, but not enough for cache to expire. 52 | currentTime = currentTime.Add(30 * time.Second) 53 | 54 | // Read again and check we still got the old cached content. 55 | got, err = r.ReadFile() 56 | if err != nil { 57 | t.Error(err) 58 | } 59 | if got != content1 { 60 | t.Errorf("got '%s', expected '%s'", got, content1) 61 | } 62 | 63 | // Advance simulated time for cache to expire. 64 | currentTime = currentTime.Add(30 * time.Second) 65 | 66 | // Read again and check that we got the new content. 67 | got, err = r.ReadFile() 68 | if err != nil { 69 | t.Error(err) 70 | } 71 | if got != content2 { 72 | t.Errorf("got '%s', expected '%s'", got, content2) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /caching_file_reader.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package kubeauth 5 | 6 | import ( 7 | "os" 8 | "sync" 9 | "time" 10 | ) 11 | 12 | // cachingFileReader reads a file and keeps an in-memory copy of it, until the 13 | // copy is considered stale. Next ReadFile() after expiry will re-read the file from disk. 14 | type cachingFileReader struct { 15 | // path is the file path to the cached file. 16 | path string 17 | 18 | // ttl is the time-to-live duration when cached file is considered stale 19 | ttl time.Duration 20 | 21 | // cache is the buffer holding the in-memory copy of the file. 22 | cache cachedFile 23 | 24 | l sync.RWMutex 25 | 26 | // currentTime is a function that returns the current local time. 27 | // Normally set to time.Now but it can be overwritten by test cases to manipulate time. 28 | currentTime func() time.Time 29 | } 30 | 31 | type cachedFile struct { 32 | // buf is the buffer holding the in-memory copy of the file. 33 | buf string 34 | 35 | // expiry is the time when the cached copy is considered stale and must be re-read. 36 | expiry time.Time 37 | } 38 | 39 | func newCachingFileReader(path string, ttl time.Duration, currentTime func() time.Time) *cachingFileReader { 40 | return &cachingFileReader{ 41 | path: path, 42 | ttl: ttl, 43 | currentTime: currentTime, 44 | } 45 | } 46 | 47 | func (r *cachingFileReader) ReadFile() (string, error) { 48 | // Fast path requiring read lock only: file is already in memory and not stale. 49 | r.l.RLock() 50 | now := r.currentTime() 51 | cache := r.cache 52 | r.l.RUnlock() 53 | if now.Before(cache.expiry) { 54 | return cache.buf, nil 55 | } 56 | 57 | // Slow path: read the file from disk. 58 | r.l.Lock() 59 | defer r.l.Unlock() 60 | 61 | buf, err := os.ReadFile(r.path) 62 | if err != nil { 63 | return "", err 64 | } 65 | r.cache = cachedFile{ 66 | buf: string(buf), 67 | expiry: now.Add(r.ttl), 68 | } 69 | 70 | return r.cache.buf, nil 71 | } 72 | -------------------------------------------------------------------------------- /integrationtest/k8s/portforward.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package k8s 5 | 6 | import ( 7 | "fmt" 8 | "io/ioutil" 9 | "net/http" 10 | "os" 11 | 12 | "k8s.io/client-go/kubernetes" 13 | "k8s.io/client-go/tools/portforward" 14 | "k8s.io/client-go/transport/spdy" 15 | ) 16 | 17 | func SetupPortForwarding(kubeContext, namespace, pod string) (localPort int, close func(), err error) { 18 | config, err := kubeConfig(kubeContext) 19 | if err != nil { 20 | return 0, nil, err 21 | } 22 | 23 | k8sClient, err := kubernetes.NewForConfig(config) 24 | if err != nil { 25 | return 0, nil, fmt.Errorf("failed to create client: %w", err) 26 | } 27 | 28 | url := k8sClient.CoreV1().RESTClient().Post().Resource("pods").Namespace(namespace).Name(pod).SubResource("portforward").URL() 29 | transport, upgrader, err := spdy.RoundTripperFor(config) 30 | if err != nil { 31 | return 0, nil, fmt.Errorf("failed to create transport: %w", err) 32 | } 33 | 34 | dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, "POST", url) 35 | stopChan := make(chan struct{}) 36 | readyChan := make(chan struct{}) 37 | 38 | // Listen on random available local port, forwarding to 8200 in the Vault container. 39 | forwarder, err := portforward.New(dialer, []string{"0:8200"}, stopChan, readyChan, ioutil.Discard, os.Stderr) 40 | if err != nil { 41 | return 0, nil, err 42 | } 43 | 44 | errChan := make(chan error) 45 | go func() { 46 | if err := forwarder.ForwardPorts(); err != nil { 47 | errChan <- err 48 | } 49 | }() 50 | 51 | select { 52 | case err = <-errChan: 53 | return 0, nil, fmt.Errorf("failed to start forwarding: %w", err) 54 | case <-readyChan: 55 | break 56 | } 57 | 58 | if ports, err := forwarder.GetPorts(); err != nil { 59 | return 0, nil, fmt.Errorf("failed to get forwarded ports: %w", err) 60 | } else { 61 | localPort = int(ports[0].Local) 62 | } 63 | 64 | return localPort, func() { 65 | stopChan <- struct{}{} 66 | forwarder.Close() 67 | }, nil 68 | } 69 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TESTARGS ?= '-test.v' 2 | # kind cluster name 3 | KIND_CLUSTER_NAME ?= vault-plugin-auth-kubernetes 4 | 5 | # kind k8s version 6 | KIND_K8S_VERSION ?= v1.33.4 7 | 8 | .PHONY: default 9 | default: dev 10 | 11 | .PHONY: dev 12 | dev: 13 | CGO_ENABLED=0 go build -o bin/vault-plugin-auth-kubernetes cmd/vault-plugin-auth-kubernetes/main.go 14 | 15 | .PHONY: test 16 | test: fmtcheck 17 | CGO_ENABLED=0 go test $(TESTARGS) -timeout=20m ./... 18 | 19 | .PHONY: integration-test 20 | integration-test: 21 | cd integrationtest && INTEGRATION_TESTS=true CGO_ENABLED=0 KUBE_CONTEXT="kind-$(KIND_CLUSTER_NAME)" go test $(TESTARGS) -count=1 -timeout=20m ./... 22 | 23 | .PHONY: fmtcheck 24 | fmtcheck: 25 | @sh -c "'$(CURDIR)/scripts/gofmtcheck.sh'" 26 | 27 | .PHONY: fmt 28 | fmt: 29 | gofumpt -w . 30 | 31 | .PHONY: setup-kind 32 | # create a kind cluster for running the integration tests locally 33 | setup-kind: 34 | kind get clusters | grep --silent "^$(KIND_CLUSTER_NAME)$$" || \ 35 | kind create cluster \ 36 | --image kindest/node:$(KIND_K8S_VERSION) \ 37 | --name $(KIND_CLUSTER_NAME) 38 | 39 | .PHONY: delete-kind 40 | # delete the kind cluster 41 | delete-kind: 42 | kind delete cluster --name $(KIND_CLUSTER_NAME) || true 43 | 44 | .PHONY: vault-image 45 | vault-image: 46 | GOOS=linux make dev 47 | docker build -f integrationtest/vault/Dockerfile bin/ --tag=hashicorp/vault:dev 48 | 49 | # Create Vault inside the cluster with a locally-built version of kubernetes auth. 50 | .PHONY: setup-integration-test 51 | setup-integration-test: teardown-integration-test vault-image 52 | kind --name $(KIND_CLUSTER_NAME) load docker-image hashicorp/vault:dev 53 | kubectl --context="kind-$(KIND_CLUSTER_NAME)" create namespace test 54 | kubectl --context="kind-$(KIND_CLUSTER_NAME)" label namespaces test target=integration-test other=label 55 | helm upgrade --install vault vault --repo https://helm.releases.hashicorp.com --version=0.30.1 \ 56 | --kube-context="kind-$(KIND_CLUSTER_NAME)" \ 57 | --wait --timeout=5m \ 58 | --namespace=test \ 59 | --set server.dev.enabled=true \ 60 | --set server.image.tag=dev \ 61 | --set server.image.pullPolicy=Never \ 62 | --set server.logLevel=trace \ 63 | --set injector.enabled=false \ 64 | --set server.extraArgs="-dev-plugin-dir=/vault/plugin_directory" 65 | kubectl --context="kind-$(KIND_CLUSTER_NAME)" apply --namespace=test -f integrationtest/vault/tokenReviewerServiceAccount.yaml 66 | kubectl --context="kind-$(KIND_CLUSTER_NAME)" apply -f integrationtest/vault/tokenReviewerBinding.yaml 67 | kubectl --context="kind-$(KIND_CLUSTER_NAME)" apply -f integrationtest/vault/namespaceControllerBinding.yaml 68 | kubectl --context="kind-$(KIND_CLUSTER_NAME)" wait --namespace=test --for=condition=Ready --timeout=5m pod -l app.kubernetes.io/name=vault 69 | 70 | .PHONY: teardown-integration-test 71 | teardown-integration-test: 72 | helm uninstall vault --namespace=test || true 73 | kubectl --context="kind-$(KIND_CLUSTER_NAME)" delete --ignore-not-found namespace test 74 | -------------------------------------------------------------------------------- /integrationtest/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hashicorp/vault-plugin-auth-kubernetes/integrationtest 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.23.7 6 | 7 | require ( 8 | github.com/hashicorp/vault/api v1.9.2 9 | k8s.io/api v0.27.3 10 | k8s.io/apimachinery v0.27.3 11 | k8s.io/client-go v0.27.3 12 | ) 13 | 14 | require ( 15 | github.com/cenkalti/backoff/v3 v3.0.0 // indirect 16 | github.com/davecgh/go-spew v1.1.1 // indirect 17 | github.com/emicklei/go-restful/v3 v3.9.0 // indirect 18 | github.com/go-jose/go-jose/v3 v3.0.4 // indirect 19 | github.com/go-logr/logr v1.2.3 // indirect 20 | github.com/go-openapi/jsonpointer v0.19.6 // indirect 21 | github.com/go-openapi/jsonreference v0.20.1 // indirect 22 | github.com/go-openapi/swag v0.22.3 // indirect 23 | github.com/gogo/protobuf v1.3.2 // indirect 24 | github.com/golang/protobuf v1.5.3 // indirect 25 | github.com/google/gnostic v0.5.7-v3refs // indirect 26 | github.com/google/go-cmp v0.5.9 // indirect 27 | github.com/google/gofuzz v1.1.0 // indirect 28 | github.com/google/uuid v1.3.0 // indirect 29 | github.com/hashicorp/errwrap v1.1.0 // indirect 30 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 31 | github.com/hashicorp/go-multierror v1.1.1 // indirect 32 | github.com/hashicorp/go-retryablehttp v0.7.7 // indirect 33 | github.com/hashicorp/go-rootcerts v1.0.2 // indirect 34 | github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 // indirect 35 | github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect 36 | github.com/hashicorp/go-sockaddr v1.0.2 // indirect 37 | github.com/hashicorp/hcl v1.0.0 // indirect 38 | github.com/imdario/mergo v0.3.6 // indirect 39 | github.com/josharian/intern v1.0.0 // indirect 40 | github.com/json-iterator/go v1.1.12 // indirect 41 | github.com/mailru/easyjson v0.7.7 // indirect 42 | github.com/mitchellh/go-homedir v1.1.0 // indirect 43 | github.com/mitchellh/mapstructure v1.5.0 // indirect 44 | github.com/moby/spdystream v0.2.0 // indirect 45 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 46 | github.com/modern-go/reflect2 v1.0.2 // indirect 47 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 48 | github.com/ryanuber/go-glob v1.0.0 // indirect 49 | github.com/spf13/pflag v1.0.5 // indirect 50 | golang.org/x/crypto v0.36.0 // indirect 51 | golang.org/x/net v0.38.0 // indirect 52 | golang.org/x/oauth2 v0.28.0 // indirect 53 | golang.org/x/sys v0.31.0 // indirect 54 | golang.org/x/term v0.30.0 // indirect 55 | golang.org/x/text v0.23.0 // indirect 56 | golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 // indirect 57 | google.golang.org/protobuf v1.33.0 // indirect 58 | gopkg.in/inf.v0 v0.9.1 // indirect 59 | gopkg.in/yaml.v2 v2.4.0 // indirect 60 | gopkg.in/yaml.v3 v3.0.1 // indirect 61 | k8s.io/klog/v2 v2.90.1 // indirect 62 | k8s.io/kube-openapi v0.0.0-20230501164219-8b0f38b5fd1f // indirect 63 | k8s.io/utils v0.0.0-20230209194617-a36077c30491 // indirect 64 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect 65 | sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect 66 | sigs.k8s.io/yaml v1.3.0 // indirect 67 | ) 68 | -------------------------------------------------------------------------------- /service_account_getter.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package kubeauth 5 | 6 | import ( 7 | "context" 8 | "encoding/json" 9 | "errors" 10 | "fmt" 11 | "io" 12 | "net/http" 13 | "strings" 14 | 15 | v1 "k8s.io/api/core/v1" 16 | kubeerrors "k8s.io/apimachinery/pkg/api/errors" 17 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 18 | "k8s.io/apimachinery/pkg/runtime" 19 | ) 20 | 21 | const annotationKeyPrefix = "vault.hashicorp.com/alias-metadata-" 22 | 23 | var errAliasMetadataReservedKeysFound = errors.New("entity alias metadata keys for only internal use found" + 24 | " from the client token's associated service account annotations") 25 | 26 | // serviceAccountGetter defines a namespace validator interface 27 | type serviceAccountGetter interface { 28 | annotations(context.Context, *http.Client, string, string, string) (map[string]string, error) 29 | } 30 | 31 | type serviceAccountGetterFactory func(*kubeConfig) serviceAccountGetter 32 | 33 | // serviceAccountGetterWrapper implements the serviceAccountGetter interface 34 | type serviceAccountGetterWrapper struct { 35 | config *kubeConfig 36 | } 37 | 38 | func newServiceAccountGetterWrapper(config *kubeConfig) serviceAccountGetter { 39 | return &serviceAccountGetterWrapper{ 40 | config: config, 41 | } 42 | } 43 | 44 | func (w *serviceAccountGetterWrapper) annotations(ctx context.Context, client *http.Client, jwtStr, namespace, serviceAccount string) (map[string]string, error) { 45 | url := fmt.Sprintf("%s/api/v1/namespaces/%s/serviceaccounts/%s", 46 | strings.TrimSuffix(w.config.Host, "/"), namespace, serviceAccount) 47 | req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | // If we have a configured TokenReviewer JWT use it as the bearer, otherwise 53 | // try to use the passed in JWT. 54 | bearer := fmt.Sprintf("Bearer %s", jwtStr) 55 | if len(w.config.TokenReviewerJWT) > 0 { 56 | bearer = fmt.Sprintf("Bearer %s", w.config.TokenReviewerJWT) 57 | } 58 | setRequestHeader(req, bearer) 59 | 60 | resp, err := client.Do(req) 61 | if err != nil { 62 | return nil, err 63 | } 64 | body, err := io.ReadAll(resp.Body) 65 | if err != nil { 66 | return nil, err 67 | } 68 | if resp.StatusCode != http.StatusOK { 69 | var errStatus metav1.Status 70 | if err = json.Unmarshal(body, &errStatus); err != nil { 71 | return nil, fmt.Errorf("failed to parse error status on service account retrieval failure err=%s", err) 72 | } 73 | 74 | if errStatus.Status != metav1.StatusSuccess { 75 | return nil, fmt.Errorf("failed to get service account (code %d status %s)", 76 | resp.StatusCode, kubeerrors.FromObject(runtime.Object(&errStatus))) 77 | } 78 | } 79 | var sa v1.ServiceAccount 80 | err = json.Unmarshal(body, &sa) 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | var found bool 86 | var foundKeys []string 87 | annotations := map[string]string{} 88 | for k, v := range sa.Annotations { 89 | if strings.HasPrefix(k, annotationKeyPrefix) { 90 | newK := strings.TrimPrefix(k, annotationKeyPrefix) 91 | if _, ok := reservedAliasMetadataKeys[newK]; ok { 92 | foundKeys = append(foundKeys, newK) 93 | found = true 94 | } else { 95 | annotations[newK] = v 96 | } 97 | } 98 | } 99 | 100 | if found { 101 | errContext := fmt.Sprintf("keys=%+q", foundKeys) 102 | return nil, fmt.Errorf("%w: %s", errAliasMetadataReservedKeysFound, errContext) 103 | } 104 | 105 | return annotations, nil 106 | } 107 | 108 | type mockServiceAccountGetter struct { 109 | meta metav1.ObjectMeta 110 | } 111 | 112 | func mockServiceAccountGetterFactory(meta metav1.ObjectMeta) serviceAccountGetterFactory { 113 | return func(config *kubeConfig) serviceAccountGetter { 114 | return &mockServiceAccountGetter{ 115 | meta: meta, 116 | } 117 | } 118 | } 119 | 120 | func (v *mockServiceAccountGetter) annotations(context.Context, *http.Client, string, string, string) (map[string]string, error) { 121 | annotations := map[string]string{} 122 | for k, v := range v.meta.Annotations { 123 | if strings.HasPrefix(k, annotationKeyPrefix) { 124 | newK := strings.TrimPrefix(k, annotationKeyPrefix) 125 | annotations[newK] = v 126 | } 127 | } 128 | return annotations, nil 129 | } 130 | -------------------------------------------------------------------------------- /namespace_validator.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package kubeauth 5 | 6 | import ( 7 | "context" 8 | "encoding/json" 9 | "errors" 10 | "fmt" 11 | "io" 12 | "net/http" 13 | "strings" 14 | 15 | v1 "k8s.io/api/core/v1" 16 | kubeerrors "k8s.io/apimachinery/pkg/api/errors" 17 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 18 | "k8s.io/apimachinery/pkg/labels" 19 | "k8s.io/apimachinery/pkg/runtime" 20 | k8s_yaml "k8s.io/apimachinery/pkg/util/yaml" 21 | ) 22 | 23 | // namespaceValidator defines a namespace validator interface 24 | type namespaceValidator interface { 25 | validateLabels(context.Context, *http.Client, string, string) (bool, error) 26 | } 27 | 28 | type namespaceValidatorFactory func(*kubeConfig) namespaceValidator 29 | 30 | // namespaceValidatorWrapper implements the namespaceValidator interface 31 | type namespaceValidatorWrapper struct { 32 | config *kubeConfig 33 | } 34 | 35 | func newNsValidatorWrapper(config *kubeConfig) namespaceValidator { 36 | return &namespaceValidatorWrapper{ 37 | config: config, 38 | } 39 | } 40 | 41 | func (v *namespaceValidatorWrapper) validateLabels(ctx context.Context, client *http.Client, namespace string, namespaceSelector string) (bool, error) { 42 | labelSelector, err := makeNsLabelSelector(namespaceSelector) 43 | if err != nil { 44 | return false, err 45 | } 46 | 47 | selector, err := metav1.LabelSelectorAsSelector(labelSelector) 48 | if err != nil { 49 | return false, err 50 | } 51 | 52 | nsLabels, err := v.getNamespaceLabels(ctx, client, namespace) 53 | if err != nil { 54 | return false, err 55 | } 56 | 57 | return selector.Matches(labels.Set(nsLabels)), nil 58 | } 59 | 60 | func (v *namespaceValidatorWrapper) getNamespaceLabels(ctx context.Context, client *http.Client, namespace string) (map[string]string, error) { 61 | url := fmt.Sprintf("%s/api/v1/namespaces/%s", strings.TrimSuffix(v.config.Host, "/"), namespace) 62 | req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | // Use the configured TokenReviewer JWT as the bearer 68 | if v.config.TokenReviewerJWT == "" { 69 | return nil, errors.New("namespace lookup failed: TokenReviewer JWT needs to be configured to use namespace selectors") 70 | } 71 | setRequestHeader(req, fmt.Sprintf("Bearer %s", v.config.TokenReviewerJWT)) 72 | 73 | resp, err := client.Do(req) 74 | if err != nil { 75 | return nil, err 76 | } 77 | body, err := io.ReadAll(resp.Body) 78 | if err != nil { 79 | return nil, err 80 | } 81 | if resp.StatusCode != http.StatusOK { 82 | var errStatus metav1.Status 83 | if err = json.Unmarshal(body, &errStatus); err != nil { 84 | return nil, fmt.Errorf("failed to parse error status on namespace retrieval failure err=%s", err) 85 | } 86 | 87 | if errStatus.Status != metav1.StatusSuccess { 88 | return nil, fmt.Errorf("failed to get namespace (code %d status %s)", 89 | resp.StatusCode, kubeerrors.FromObject(runtime.Object(&errStatus))) 90 | } 91 | } 92 | var ns v1.Namespace 93 | err = json.Unmarshal(body, &ns) 94 | if err != nil { 95 | return nil, err 96 | } 97 | return ns.Labels, nil 98 | } 99 | 100 | func makeLabelSelector(selector string) (*metav1.LabelSelector, error) { 101 | labelSelector := metav1.LabelSelector{} 102 | decoder := k8s_yaml.NewYAMLOrJSONDecoder(strings.NewReader(selector), len(selector)) 103 | err := decoder.Decode(&labelSelector) 104 | if err != nil { 105 | return nil, err 106 | } 107 | 108 | return &labelSelector, nil 109 | } 110 | 111 | func makeNsLabelSelector(namespaceSelector string) (*metav1.LabelSelector, error) { 112 | if namespaceSelector == "" { 113 | return nil, fmt.Errorf("namespace selector is empty") 114 | } 115 | 116 | labelSelector, err := makeLabelSelector(namespaceSelector) 117 | if err != nil { 118 | return nil, err 119 | } 120 | 121 | if labelSelector.MatchExpressions != nil { 122 | return nil, fmt.Errorf("label selector match expressions are not supported") 123 | } 124 | 125 | return labelSelector, nil 126 | } 127 | 128 | type mockNamespaceValidator struct { 129 | labels map[string]string 130 | } 131 | 132 | func mockNamespaceValidateFactory(labels map[string]string) namespaceValidatorFactory { 133 | return func(config *kubeConfig) namespaceValidator { 134 | return &mockNamespaceValidator{ 135 | labels: labels, 136 | } 137 | } 138 | } 139 | 140 | func (v *mockNamespaceValidator) validateLabels(ctx context.Context, client *http.Client, namespace string, namespaceSelector string) (bool, error) { 141 | labelSelector, err := makeNsLabelSelector(namespaceSelector) 142 | if err != nil { 143 | return false, err 144 | } 145 | selector, err := metav1.LabelSelectorAsSelector(labelSelector) 146 | if err != nil { 147 | return false, err 148 | } 149 | return selector.Matches(labels.Set(v.labels)), nil 150 | } 151 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vault Plugin: Kubernetes Auth Backend 2 | 3 | This is a standalone backend plugin for use with [Hashicorp Vault](https://www.github.com/hashicorp/vault). 4 | This plugin allows for Kubernetes Service Accounts to authenticate with Vault. 5 | 6 | **Please note**: We take Vault's security and our users' trust very seriously. If you believe you have found a security issue in Vault, _please responsibly disclose_ by contacting us at [security@hashicorp.com](mailto:security@hashicorp.com). 7 | 8 | ## Quick Links 9 | 10 | - Vault Website: [https://www.vaultproject.io] 11 | - Kubernetes Auth Docs: [https://www.vaultproject.io/docs/auth/kubernetes.html] 12 | - Main Project Github: [https://www.github.com/hashicorp/vault] 13 | 14 | ## Getting Started 15 | 16 | This is a [Vault plugin](https://www.vaultproject.io/docs/plugins/plugin-architecture#plugin-catalogs) 17 | and is meant to work with Vault. This guide assumes you have already installed Vault 18 | and have a basic understanding of how Vault works. 19 | 20 | Otherwise, first read this guide on how to [get started with Vault](https://www.vaultproject.io/intro/getting-started/install.html). 21 | 22 | To learn specifically about how plugins work, see documentation on [Vault plugins](https://www.vaultproject.io/docs/plugins/plugin-architecture#plugin-catalog). 23 | 24 | ## Security Model 25 | 26 | The current authentication model requires providing Vault with a Service Account token, which can be used to make authenticated calls to Kubernetes. This token should not typically be shared, but in order for Kubernetes to be treated as a trusted third party, Vault must validate something that Kubernetes has cryptographically signed and that conveys the identity of the token holder. 27 | 28 | We expect Kubernetes to support less sensitive mechanisms in the future, and the Vault integration will be updated to use those mechanisms when available. 29 | 30 | ## Usage 31 | 32 | Please see [documentation for the plugin](https://www.vaultproject.io/docs/auth/kubernetes) 33 | on the Vault website. 34 | 35 | This plugin is currently built into Vault and by default is accessed 36 | at `auth/kubernetes`. To enable this in a running Vault server: 37 | 38 | ```sh 39 | $ vault auth enable kubernetes 40 | Successfully enabled 'kubernetes' at 'kubernetes'! 41 | ``` 42 | 43 | To see all the supported paths, see the [Kubernetes auth API docs](https://www.vaultproject.io/api-docs/auth/kubernetes). 44 | 45 | ## Developing 46 | 47 | If you wish to work on this plugin, you'll first need 48 | [Go](https://www.golang.org) installed on your machine. 49 | 50 | To compile a development version of this plugin, run `make` or `make dev`. 51 | This will put the plugin binary in the `bin` and `$GOPATH/bin` folders. `dev` 52 | mode will only generate the binary for your platform and is faster: 53 | 54 | ```sh 55 | $ make 56 | $ make dev 57 | ``` 58 | 59 | Put the plugin binary into a location of your choice. This directory 60 | will be specified as the [`plugin_directory`](https://www.vaultproject.io/docs/configuration#plugin_directory) 61 | in the Vault config used to start the server. 62 | 63 | ```hcl 64 | ... 65 | plugin_directory = "path/to/plugin/directory" 66 | ... 67 | ``` 68 | 69 | Start a Vault server with this config file: 70 | 71 | ```sh 72 | $ vault server -config=path/to/config.hcl ... 73 | ... 74 | ``` 75 | 76 | Once the server is started, register the plugin in the Vault server's [plugin catalog](https://developer.hashicorp.com/vault/docs/plugins/plugin-architecture#plugin-catalog): 77 | 78 | ```sh 79 | $ vault plugin register \ 80 | -sha256= \ 81 | -command="vault-plugin-auth-kubernetes" \ 82 | auth kubernetes 83 | ... 84 | Success! Data written to: sys/plugins/catalog/kubernetes 85 | ``` 86 | 87 | Note you should generate a new sha256 checksum if you have made changes 88 | to the plugin. Example using openssl: 89 | 90 | ```sh 91 | openssl dgst -sha256 $GOPATH/vault-plugin-auth-kubernetes 92 | ... 93 | SHA256(.../go/bin/vault-plugin-auth-kubernetes)= 896c13c0f5305daed381952a128322e02bc28a57d0c862a78cbc2ea66e8c6fa1 94 | ``` 95 | 96 | Enable the auth plugin backend using the Kubernetes auth plugin: 97 | 98 | ```sh 99 | $ vault auth enable kubernetes 100 | ... 101 | 102 | Successfully enabled 'plugin' at 'kubernetes'! 103 | ``` 104 | 105 | ### Tests 106 | 107 | If you are developing this plugin and want to verify it is still 108 | functioning (and you haven't broken anything else), we recommend 109 | running the tests. 110 | 111 | To run the tests, invoke `make test`: 112 | 113 | ```sh 114 | $ make test 115 | ``` 116 | 117 | You can also specify a `TESTARGS` variable to filter tests like so: 118 | 119 | ```sh 120 | $ make test TESTARGS='--run=TestConfig' 121 | ``` 122 | 123 | To run integration tests, you'll need [`kind`](https://kind.sigs.k8s.io/) installed. 124 | 125 | ```sh 126 | # Create the Kubernetes cluster for testing in 127 | make setup-kind 128 | # Build the plugin and register it with a Vault instance running in the cluster 129 | make setup-integration-test 130 | # Run the integration tests against Vault inside the cluster 131 | make integration-test 132 | ``` 133 | 134 | -------------------------------------------------------------------------------- /token_review.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package kubeauth 5 | 6 | import ( 7 | "bytes" 8 | "context" 9 | "encoding/json" 10 | "errors" 11 | "fmt" 12 | "io" 13 | "net/http" 14 | "strings" 15 | 16 | "github.com/hashicorp/go-secure-stdlib/strutil" 17 | authv1 "k8s.io/api/authentication/v1" 18 | kubeerrors "k8s.io/apimachinery/pkg/api/errors" 19 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 20 | "k8s.io/apimachinery/pkg/runtime" 21 | "k8s.io/apimachinery/pkg/runtime/schema" 22 | ) 23 | 24 | // This is the result from the token review 25 | type tokenReviewResult struct { 26 | Name string 27 | Namespace string 28 | UID string 29 | } 30 | 31 | // This exists so we can use a mock TokenReview when running tests 32 | type tokenReviewer interface { 33 | Review(context.Context, *http.Client, string, []string) (*tokenReviewResult, error) 34 | } 35 | 36 | type tokenReviewFactory func(*kubeConfig) tokenReviewer 37 | 38 | // This is the real implementation that calls the kubernetes API 39 | type tokenReviewAPI struct { 40 | config *kubeConfig 41 | } 42 | 43 | func tokenReviewAPIFactory(config *kubeConfig) tokenReviewer { 44 | return &tokenReviewAPI{ 45 | config: config, 46 | } 47 | } 48 | 49 | func (t *tokenReviewAPI) Review(ctx context.Context, client *http.Client, jwt string, aud []string) (*tokenReviewResult, error) { 50 | // Create the TokenReview Object and marshal it into json 51 | trReq := &authv1.TokenReview{ 52 | Spec: authv1.TokenReviewSpec{ 53 | Token: jwt, 54 | Audiences: aud, 55 | }, 56 | } 57 | trJSON, err := json.Marshal(trReq) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | // Build the request to the token review API 63 | url := fmt.Sprintf("%s/apis/authentication.k8s.io/v1/tokenreviews", strings.TrimSuffix(t.config.Host, "/")) 64 | req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(trJSON)) 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | // If we have a configured TokenReviewer JWT use it as the bearer, otherwise 70 | // try to use the passed in JWT. 71 | bearer := fmt.Sprintf("Bearer %s", jwt) 72 | if len(t.config.TokenReviewerJWT) > 0 { 73 | bearer = fmt.Sprintf("Bearer %s", t.config.TokenReviewerJWT) 74 | } 75 | setRequestHeader(req, bearer) 76 | 77 | resp, err := client.Do(req) 78 | if err != nil { 79 | return nil, err 80 | } 81 | 82 | // Parse the resp into a tokenreview object or a kubernetes error type 83 | r, err := parseResponse(resp) 84 | switch { 85 | case kubeerrors.IsUnauthorized(err): 86 | // If the err is unauthorized that means the token has since been deleted; 87 | // this can happen if the service account is deleted, and even if it has 88 | // since been recreated the token will have changed, which means our 89 | // caller will need to be updated accordingly. 90 | return nil, errors.New("lookup failed: service account unauthorized; this could mean it has been deleted or recreated with a new token") 91 | case err != nil: 92 | return nil, err 93 | } 94 | 95 | if r.Status.Error != "" { 96 | return nil, fmt.Errorf("lookup failed: %s", r.Status.Error) 97 | } 98 | 99 | if !r.Status.Authenticated { 100 | return nil, errors.New("lookup failed: service account jwt not valid") 101 | } 102 | 103 | // Ensure the token review endpoint is audience-aware if we requested 104 | // audience validation. 105 | wantAud := trReq.Spec.Audiences 106 | if len(wantAud) != 0 { 107 | intersectionFound := false 108 | for _, aud := range trReq.Spec.Audiences { 109 | if strutil.StrListContains(r.Status.Audiences, aud) { 110 | intersectionFound = true 111 | break 112 | } 113 | } 114 | if !intersectionFound { 115 | return nil, fmt.Errorf("lookup failed: service account jwt valid for audience(s) %v, but wanted %v", r.Status.Audiences, wantAud) 116 | } 117 | } 118 | 119 | // The username is of format: system:serviceaccount:(NAMESPACE):(SERVICEACCOUNT) 120 | parts := strings.Split(r.Status.User.Username, ":") 121 | if len(parts) != 4 { 122 | return nil, errors.New("lookup failed: unexpected username format") 123 | } 124 | 125 | // Validate the user that comes back from token review is a service account 126 | if parts[0] != "system" || parts[1] != "serviceaccount" { 127 | return nil, errors.New("lookup failed: username returned is not a service account") 128 | } 129 | 130 | return &tokenReviewResult{ 131 | Name: parts[3], 132 | Namespace: parts[2], 133 | UID: string(r.Status.User.UID), 134 | }, nil 135 | } 136 | 137 | // parseResponse takes the API response and either returns the appropriate error 138 | // or the TokenReview Object. 139 | func parseResponse(resp *http.Response) (*authv1.TokenReview, error) { 140 | body, err := io.ReadAll(resp.Body) 141 | if err != nil { 142 | return nil, err 143 | } 144 | defer resp.Body.Close() 145 | 146 | // If the request was not a success create a kuberenets error 147 | if resp.StatusCode < http.StatusOK || resp.StatusCode > http.StatusPartialContent { 148 | return nil, kubeerrors.NewGenericServerResponse(resp.StatusCode, "POST", schema.GroupResource{}, "", strings.TrimSpace(string(body)), 0, true) 149 | } 150 | 151 | // If we can successfully Unmarshal into a status object that means there is 152 | // an error to return 153 | errStatus := &metav1.Status{} 154 | err = json.Unmarshal(body, errStatus) 155 | if err == nil && errStatus.Status != metav1.StatusSuccess { 156 | return nil, kubeerrors.FromObject(runtime.Object(errStatus)) 157 | } 158 | 159 | // Unmarshal the resp body into a TokenReview Object 160 | trResp := &authv1.TokenReview{} 161 | err = json.Unmarshal(body, trResp) 162 | if err != nil { 163 | return nil, err 164 | } 165 | 166 | return trResp, nil 167 | } 168 | 169 | // mock review is used while testing 170 | type mockTokenReview struct { 171 | saName string 172 | saNamespace string 173 | saUID string 174 | } 175 | 176 | func mockTokenReviewFactory(name, namespace, UID string) tokenReviewFactory { 177 | return func(config *kubeConfig) tokenReviewer { 178 | return &mockTokenReview{ 179 | saName: name, 180 | saNamespace: namespace, 181 | saUID: UID, 182 | } 183 | } 184 | } 185 | 186 | func (t *mockTokenReview) Review(ctx context.Context, client *http.Client, cjwt string, aud []string) (*tokenReviewResult, error) { 187 | if ctx.Err() != nil { 188 | return nil, ctx.Err() 189 | } 190 | 191 | httpTransport, ok := client.Transport.(*http.Transport) 192 | if !ok { 193 | return nil, errors.New("failed to check whether DisableKeepAlives is false as Transport is not *http.Transport") 194 | } 195 | if httpTransport.DisableKeepAlives { 196 | return nil, errors.New("expected DisableKeepAlives to be false but was true") 197 | } 198 | 199 | return &tokenReviewResult{ 200 | Name: t.saName, 201 | Namespace: t.saNamespace, 202 | UID: t.saUID, 203 | }, nil 204 | } 205 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hashicorp/vault-plugin-auth-kubernetes 2 | 3 | go 1.25.3 4 | 5 | require ( 6 | github.com/go-jose/go-jose/v4 v4.1.3 7 | github.com/go-test/deep v1.1.1 8 | github.com/hashicorp/cap v0.11.0 9 | github.com/hashicorp/go-cleanhttp v0.5.2 10 | github.com/hashicorp/go-hclog v1.6.3 11 | github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 12 | github.com/hashicorp/go-sockaddr v1.0.7 13 | github.com/hashicorp/go-uuid v1.0.3 14 | github.com/hashicorp/vault/api v1.22.0 15 | github.com/hashicorp/vault/sdk v0.20.0 16 | github.com/mitchellh/mapstructure v1.5.0 17 | k8s.io/api v0.34.1 18 | k8s.io/apimachinery v0.34.1 19 | ) 20 | 21 | require ( 22 | cloud.google.com/go/auth v0.14.1 // indirect 23 | cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect 24 | cloud.google.com/go/cloudsqlconn v1.4.3 // indirect 25 | cloud.google.com/go/compute/metadata v0.6.0 // indirect 26 | github.com/Microsoft/go-winio v0.6.2 // indirect 27 | github.com/armon/go-metrics v0.4.1 // indirect 28 | github.com/armon/go-radix v1.0.0 // indirect 29 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 30 | github.com/containerd/errdefs v1.0.0 // indirect 31 | github.com/containerd/errdefs/pkg v0.3.0 // indirect 32 | github.com/coreos/go-oidc/v3 v3.11.0 // indirect 33 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 34 | github.com/distribution/reference v0.6.0 // indirect 35 | github.com/docker/docker v28.3.3+incompatible // indirect 36 | github.com/docker/go-connections v0.5.0 // indirect 37 | github.com/docker/go-units v0.5.0 // indirect 38 | github.com/evanphx/json-patch/v5 v5.6.0 // indirect 39 | github.com/fatih/color v1.18.0 // indirect 40 | github.com/felixge/httpsnoop v1.0.4 // indirect 41 | github.com/fxamacker/cbor/v2 v2.9.0 // indirect 42 | github.com/go-jose/go-jose/v3 v3.0.4 // indirect 43 | github.com/go-logr/logr v1.4.2 // indirect 44 | github.com/go-logr/stdr v1.2.2 // indirect 45 | github.com/gogo/protobuf v1.3.2 // indirect 46 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 47 | github.com/golang/protobuf v1.5.4 // indirect 48 | github.com/golang/snappy v0.0.4 // indirect 49 | github.com/google/certificate-transparency-go v1.3.1 // indirect 50 | github.com/google/s2a-go v0.1.9 // indirect 51 | github.com/google/uuid v1.6.0 // indirect 52 | github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect 53 | github.com/googleapis/gax-go/v2 v2.14.1 // indirect 54 | github.com/hashicorp/errwrap v1.1.0 // indirect 55 | github.com/hashicorp/go-hmac-drbg v0.0.0-20210916214228-a6e5a68489f6 // indirect 56 | github.com/hashicorp/go-immutable-radix v1.3.1 // indirect 57 | github.com/hashicorp/go-kms-wrapping/entropy/v2 v2.0.1 // indirect 58 | github.com/hashicorp/go-kms-wrapping/v2 v2.0.18 // indirect 59 | github.com/hashicorp/go-metrics v0.5.4 // indirect 60 | github.com/hashicorp/go-multierror v1.1.1 // indirect 61 | github.com/hashicorp/go-plugin v1.6.1 // indirect 62 | github.com/hashicorp/go-retryablehttp v0.7.8 // indirect 63 | github.com/hashicorp/go-rootcerts v1.0.2 // indirect 64 | github.com/hashicorp/go-secure-stdlib/cryptoutil v0.1.1 // indirect 65 | github.com/hashicorp/go-secure-stdlib/mlock v0.1.3 // indirect 66 | github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect 67 | github.com/hashicorp/go-secure-stdlib/permitpool v1.0.0 // indirect 68 | github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.2 // indirect 69 | github.com/hashicorp/go-secure-stdlib/regexp v1.0.0 // indirect 70 | github.com/hashicorp/go-version v1.7.0 // indirect 71 | github.com/hashicorp/golang-lru v1.0.2 // indirect 72 | github.com/hashicorp/hcl v1.0.1-vault-7 // indirect 73 | github.com/hashicorp/yamux v0.1.2 // indirect 74 | github.com/jackc/chunkreader/v2 v2.0.1 // indirect 75 | github.com/jackc/pgconn v1.14.3 // indirect 76 | github.com/jackc/pgio v1.0.0 // indirect 77 | github.com/jackc/pgpassfile v1.0.0 // indirect 78 | github.com/jackc/pgproto3/v2 v2.3.3 // indirect 79 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 80 | github.com/jackc/pgtype v1.14.3 // indirect 81 | github.com/jackc/pgx/v4 v4.18.3 // indirect 82 | github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 // indirect 83 | github.com/json-iterator/go v1.1.12 // indirect 84 | github.com/mattn/go-colorable v0.1.14 // indirect 85 | github.com/mattn/go-isatty v0.0.20 // indirect 86 | github.com/mitchellh/copystructure v1.2.0 // indirect 87 | github.com/mitchellh/go-homedir v1.1.0 // indirect 88 | github.com/mitchellh/go-testing-interface v1.14.1 // indirect 89 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 90 | github.com/moby/docker-image-spec v1.3.1 // indirect 91 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 92 | github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect 93 | github.com/oklog/run v1.1.0 // indirect 94 | github.com/opencontainers/go-digest v1.0.0 // indirect 95 | github.com/opencontainers/image-spec v1.1.0 // indirect 96 | github.com/petermattis/goid v0.0.0-20250721140440-ea1c0173183e // indirect 97 | github.com/pierrec/lz4 v2.6.1+incompatible // indirect 98 | github.com/pkg/errors v0.9.1 // indirect 99 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 100 | github.com/robfig/cron/v3 v3.0.1 // indirect 101 | github.com/ryanuber/go-glob v1.0.0 // indirect 102 | github.com/sasha-s/go-deadlock v0.3.5 // indirect 103 | github.com/stretchr/testify v1.10.0 // indirect 104 | github.com/x448/float16 v0.8.4 // indirect 105 | go.opencensus.io v0.24.0 // indirect 106 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 107 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect 108 | go.opentelemetry.io/otel v1.35.0 // indirect 109 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 // indirect 110 | go.opentelemetry.io/otel/metric v1.35.0 // indirect 111 | go.opentelemetry.io/otel/trace v1.35.0 // indirect 112 | go.opentelemetry.io/proto/otlp v1.3.1 // indirect 113 | go.uber.org/atomic v1.11.0 // indirect 114 | go.yaml.in/yaml/v2 v2.4.2 // indirect 115 | golang.org/x/crypto v0.40.0 // indirect 116 | golang.org/x/net v0.42.0 // indirect 117 | golang.org/x/oauth2 v0.28.0 // indirect 118 | golang.org/x/sys v0.34.0 // indirect 119 | golang.org/x/text v0.27.0 // indirect 120 | golang.org/x/time v0.12.0 // indirect 121 | google.golang.org/api v0.221.0 // indirect 122 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250207221924-e9438ea467c6 // indirect 123 | google.golang.org/grpc v1.70.0 // indirect 124 | google.golang.org/protobuf v1.36.5 // indirect 125 | gopkg.in/inf.v0 v0.9.1 // indirect 126 | gopkg.in/yaml.v3 v3.0.1 // indirect 127 | k8s.io/klog/v2 v2.130.1 // indirect 128 | k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect 129 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect 130 | sigs.k8s.io/randfill v1.0.0 // indirect 131 | sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect 132 | sigs.k8s.io/yaml v1.6.0 // indirect 133 | ) 134 | -------------------------------------------------------------------------------- /common_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package kubeauth 5 | 6 | const ( 7 | testLocalCACert = `-----BEGIN CERTIFICATE----- 8 | MIIDVDCCAjwCCQDFiyFY1M6afTANBgkqhkiG9w0BAQsFADBsMQswCQYDVQQGEwJV 9 | UzETMBEGA1UECAwKV2FzaGluZ3RvbjEQMA4GA1UEBwwHU2VhdHRsZTEgMB4GA1UE 10 | CgwXVmF1bHQgVGVzdGluZyBBdXRob3JpdHkxFDASBgNVBAMMC2V4YW1wbGUubmV0 11 | MB4XDTIwMDkxODAxMjkxM1oXDTQ1MDkxODAxMjkxM1owbDELMAkGA1UEBhMCVVMx 12 | EzARBgNVBAgMCldhc2hpbmd0b24xEDAOBgNVBAcMB1NlYXR0bGUxIDAeBgNVBAoM 13 | F1ZhdWx0IFRlc3RpbmcgQXV0aG9yaXR5MRQwEgYDVQQDDAtleGFtcGxlLm5ldDCC 14 | ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALCA9oKv+ESRHX2e/iq1PlGr 15 | zD23/MBS0V+fWQDY0hyEqY98CGwRtF6pEcLEYsreArj5/zznsIevLkNOD+beg43y 16 | WpEJlCPgDhGXI/Oima6ooHVEIMaIKLjK7GrSzAb3rNRGACwrR/u/IKaFl+XJG0qx 17 | g8mOZ3fByaAlIk+shVLUcIedNN1tNR+6/4ZpHg7PDjrZXP4XKrmKPTh4yqfu+BtZ 18 | 9IY2oyregqEsGW1/3h1NM+LHGVakTV2d/mwMYHhwoq9Y8BD+PemT5z8TmhH/cIk5 19 | P8Q8ud5/q6YTIJg9TELKebLAeNtRNnNoHeUoRTjiW1MBwNHtgyTTY+H3W/9Dne0C 20 | AwEAATANBgkqhkiG9w0BAQsFAAOCAQEAXmygFkGIBnXxKlsTDiV8RW2iHLgFdZFJ 21 | hcU8UpxZhhaL5JbQl6byfbHjrX31q7ii8uC8FcbW0AEdnEQAb9Ui6a+if7HwXNmI 22 | DTlYl+lMlk9RtWvExw6AEEbg5nCpGaKexm7wJgzYGP9by9pQ7wX/CS7ofCzCK+Al 23 | uSIqjPkMC201ZXH39n1lxxq6BacdYjv8wo4mMzi8iTSQGVWPdjHZVYOClFgN6hoj 24 | 8SkrrSe888a0H+i7EknRxC4sLRaMUK/FAvwtXaSZi2djruAtQzQGQ56m1phC2C/k 25 | k9aL00AQ9Y4KTfiJD7LK8YIZDnFKLOCJhYgKCLCOVwOHb7836SNCxA== 26 | -----END CERTIFICATE-----` 27 | 28 | testLocalJWT = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlZhdWx0IFRlc3QiLCJpYXQiOjExMjM1OH0.GOC8w-MyhorgojB20SPNyH_ECsBjYJH89hjntOxSywA` 29 | 30 | testRSACert = `-----BEGIN CERTIFICATE----- 31 | MIIDcjCCAlqgAwIBAgIBAjANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDEwptaW5p 32 | a3ViZUNBMB4XDTE3MDgzMDE5MDgzNloXDTE4MDgzMDE5MDgzNlowLDEXMBUGA1UE 33 | ChMOc3lzdGVtOm1hc3RlcnMxETAPBgNVBAMTCG1pbmlrdWJlMIIBIjANBgkqhkiG 34 | 9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxD3eM3+WNc4phxAeQxNOmcybKlNJWowuC12u 35 | v+cGJWxxpDx/OoEIxKI5wmgHxEwFCZL545sjfLqyBcgxQR2xSCib+bYzjBtfA6uV 36 | 6d/35nurzz21okcMffc5xKMyZhEwt98WAvYWD71Bihz7iGBq5Sw9md6pqnkNoScR 37 | Hhi3Vl94a6D6shwb6nXA2hlwYLcnoKtpe3Ptq6MW6CpfBA8C11q5eeW4xdvrwKt3 38 | Vd1TgFeEnnqwzUWGapU2uwwUfbRkLTDvrp6791uq0Vo7mzz00xYhV1PLCeAdpJEK 39 | 3Vr74FT7jHIbPlzi/qjRBVFKf9IRXnhbjrCl7S0Ayev1Fao4TQIDAQABo4G1MIGy 40 | MA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIw 41 | DAYDVR0TAQH/BAIwADBzBgNVHREEbDBqgiRrdWJlcm5ldGVzLmRlZmF1bHQuc3Zj 42 | LmNsdXN0ZXIubG9jYWyCFmt1YmVybmV0ZXMuZGVmYXVsdC5zdmOCEmt1YmVybmV0 43 | ZXMuZGVmYXVsdIIKa3ViZXJuZXRlc4cEwKhjZIcECgAAATANBgkqhkiG9w0BAQsF 44 | AAOCAQEAIw8rKuryhhl527wf9q/VrWixzZ1jCLvyc/60z9rWpXxKFxT8AyCsHirM 45 | F4fHXW4Brcoh/Dc2ci36cUbuywIyxHjgVUG45D4jPPWskY1++ZSfJfSXAuA8eFew 46 | c+No3WPkmZB6ZOZ6q5iPY+FOgDZC7ddWmGuZrle51gBL347cU7H1BrTm6Lm6kXRs 47 | fHRZJX2+B8lnsXsS3QF2BTU0ymuCxCCQxub/GhPZVz3nNNtro1z7/szLUVP1c1/8 48 | p7HP3k7caxfp346TZ/HgbV9sJEkHP7Ym7n9E7LSyUTSxXwBRPraH1WQzEgFNPSUV 49 | V0n6FBLiejOTPKapJ2F0tIqAyJHFug== 50 | -----END CERTIFICATE-----` 51 | 52 | testECCert = `-----BEGIN CERTIFICATE----- 53 | MIICZDCCAeugAwIBAgIJALM9NbK8WRuBMAkGByqGSM49BAEwRTELMAkGA1UEBhMC 54 | dXMxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNVBAoTGEludGVybmV0IFdpZGdp 55 | dHMgUHR5IEx0ZDAeFw0xNzA5MTExNzQ2NDNaFw0yNzA5MDkxNzQ2NDNaMEUxCzAJ 56 | BgNVBAYTAnVzMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5l 57 | dCBXaWRnaXRzIFB0eSBMdGQwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATcqsBLxKP+ 58 | UHk7Y6ktGGFvfrIfIXHxeZe3Xwt691CWfdmJFvrGzyzW5/AbJIuO1utdOsqUStAm 59 | W/Scfxop/FGadKqR4nAWLNBI4intgnf0r1rzBCSOmanolHqxQPqQ0UOjgacwgaQw 60 | HQYDVR0OBBYEFHxh1pTd8ApEzg0gKMwwt01aA10TMHUGA1UdIwRuMGyAFHxh1pTd 61 | 8ApEzg0gKMwwt01aA10ToUmkRzBFMQswCQYDVQQGEwJ1czETMBEGA1UECBMKU29t 62 | ZS1TdGF0ZTEhMB8GA1UEChMYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkggkAsz01 63 | srxZG4EwDAYDVR0TBAUwAwEB/zAJBgcqhkjOPQQBA2gAMGUCMCR+CvAoNBhqSe2M 64 | 4qWWD/9XX/0qmf0O442Qowcg5MWH1+mwl1s7ozinvbTPDPaYDwIxAM54qKhuL6xt 65 | GxqJpa7Onn15Hu8zTsdzeYBqUUXA6wtn+Pa7197CgUkfty9yc2eeQw== 66 | -----END CERTIFICATE-----` 67 | 68 | testCACert = ` 69 | -----BEGIN CERTIFICATE----- 70 | MIIC5zCCAc+gAwIBAgIBATANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDEwptaW5p 71 | a3ViZUNBMB4XDTE3MDgxMDIzMTQ1NVoXDTI3MDgwODIzMTQ1NVowFTETMBEGA1UE 72 | AxMKbWluaWt1YmVDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAN8d 73 | w2p/KXRkm+vzOO0eT1vYBWP7fKsnng9/g5nnXAJlt9NxpOSolRcyItm/04R0E1jx 74 | jpgsdzkybc+QU5ZiszOYN833/D5hCNVAABVivpDd2P8wVKXN46cB99e24etUVBqG 75 | 5aR0Ku3IBsJjCN9efhF+XRCA2gy/KaXMdKJhHfdtc8hCr7G9+2wO2G58FLmIfEyH 76 | owviOGt0BSnCtMpsA8ZgGQyfqgSd5u466aCv6oj0MyzsMnfS38niM53Rlv4IY6ol 77 | taYbWXtCNndQ2S687qE0qTCxhE95Bm2Nfkqct4R1798sJz83xNv8hALvxr/vPK/J 78 | 2XkIm3oo3YKG4n/CHXcCAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgKkMB0GA1UdJQQW 79 | MBQGCCsGAQUFBwMCBggrBgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3 80 | DQEBCwUAA4IBAQCSkrhE1PczqeqXfRaWayJUbXWPwKFbszO0MhGB1zwnPZq39qjY 81 | ySQiGvnjV3fP+N5CTQAwMNe79Xiw31fSoexgceCPJpraWrTOLdCv04SbGDBapMFM 82 | aezBu9jzZm0CNt60jHXWXuHHVPFX6u7ZR8W+RiBvsT8GZ5U6sNs3aN3M9Vym06BL 83 | aSphIw1v+hRlPfnrlJwUnQp158DRgkt/9ncTa/k88KoIoZAbulaiGB4zHxxkbura 84 | GSlgpZzhHSrBDLuXf65GHwwGxSExhgY5AA/n8rumGVvE8IYohS9yg/jOG0xP2WQH 85 | u/ABoYtOyseO+lgElA8R4PB9MtwgN6c/b0xH 86 | -----END CERTIFICATE-----` 87 | 88 | testOtherCACert = `-----BEGIN CERTIFICATE----- 89 | MIIC/jCCAeagAwIBAgIBADANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDEwprdWJl 90 | cm5ldGVzMB4XDTIyMTEyOTIwNDYwNVoXDTMyMTEyNjIwNDYwNVowFTETMBEGA1UE 91 | AxMKa3ViZXJuZXRlczCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOYO 92 | AGi6hysYe4PF3sCyVXAFM72X96F51ncmDg+Ihhvt9Touj7Yo0LLMmy1UPM3YzEdV 93 | z2IV1iksLIBObkct2eBGt2SFjLMhphZdA34mPAzFZhpvNgn1U2uUSqfp5phg00Mg 94 | DdXLE0LFIVqAGkoBysr89l6P2MaTibcKoZwAhpMbATLcn1QcXF/NLzYuO9FPvrUL 95 | mAR/HslDz10LBsMjtgRKXd2dX4yrQlYSB7YmLlu/bLKdjiE1a/+EO4wNcl/JJ+vu 96 | fzPwxWALej/k61mcP+4JxfjgY53AM7vaZ+P9Yb0bGw7r1bozgP7+FGL1f6onTxeG 97 | 7+FECpErmrv9IQocgh8CAwEAAaNZMFcwDgYDVR0PAQH/BAQDAgKkMA8GA1UdEwEB 98 | /wQFMAMBAf8wHQYDVR0OBBYEFE0Y+CYtusQDfr8tqSZZ7PEcDJjwMBUGA1UdEQQO 99 | MAyCCmt1YmVybmV0ZXMwDQYJKoZIhvcNAQELBQADggEBAJK57wm9rH0yVmjmY1ES 100 | kE8e+pTnXZkKaqUce/d7cPRn1+0Dtutvxl/j3P4DUjba7lVGYYNKp1xy2xVvg7Dl 101 | mXyJigvBoTGyzJzNDIUJz8Kgse4eCrwl59WP94K83cVRLeUq+3amLwzubUNbezsW 102 | QwcCyACuzTetR5ZXEg7iIS4HDy+ER2yjuY6d0GPLG+FH02WMrlE7mmxNfZOSy/5E 103 | pEDcN/HcXM47TP7XgWW0rfQli3RucuqMV7LHvvpiGIWwfutrK9g7Py91W2JbQCA0 104 | D14XDzgsruCwlWAP1FMvLMIPhSknpIJd9Xql+0/Ae1yl9f3Uamj3mDtBKg8/U5nJ 105 | 0wU= 106 | -----END CERTIFICATE----- 107 | ` 108 | 109 | testInvalidCACert = ` 110 | -----BEGIN CERTIFICATE----- 111 | MIIC5zCCAc+gAwIBAgIBATANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDEwptaW5p 112 | a3ViZUNBMB4XDTE3MDgxMDIzMTQ1NVoXDTI3MDgwODIzMTQ1NVowFTETMBEGA1UE 113 | AxMKbWluaWt1YmVDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAN8d 114 | w2p/KXRkm+vzOO0eT1vYBWP7fKsnng9/g5nnXAJlt9NxpOSolRcyItm/04R0E1jx 115 | jpgsdzkybc+QU5ZiszOYN833/D5hCNVAABVivpDd2P8wVKXN46cB99e24etUVBqG 116 | 5aR0Ku3IBsJjCN9efhF+XRCA2gy/KaXMdKJhHfdtc8hCr7G9+2wO2G58FLmIfEyH 117 | owviOGt0BSnCtMpsA8ZgGQyfqgSd5u466aCv6oj0MyzsMnfS38niM53Rlv4IY6ol 118 | taYbWXtCNndQ2S687qE0qTCxhE95Bm2Nfkqct4R1798sJz83xNv8hALvxr/vPK/J 119 | 2XkIm3oo3YKG4n/CHXcCAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgKkMB0GA1UdJQQW 120 | MBQGCCsGAQUFBwMCBggrBgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3 121 | ` 122 | ) 123 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v0.22.5 2 | ### November 7, 2025 3 | 4 | * Upgrade go version to 1.24.9 for 1.20.x (#345) 5 | * backport of commit 348b9bf599d39c85c74e7f7ded78511ad653c5ab (#341) 6 | * backport of commit 6a5e5566019464c0e67d69de996687844b02c8a4 (#338) 7 | * upgrade go version (#334) 8 | * Automated dependency upgrades (#333) 9 | * backport of commit 22beabdde9e785f4e02f4463000180b94d1c7c5f (#331) 10 | * Add space in warning message for roles requiring audiences. (#312) 11 | * Log Warning When Kubernetes Role Does not Have an Audience (#301) 12 | 13 | ## v0.23.1 14 | ### November 5, 2025 15 | 16 | * upgrade go version to 1.25.3 (#340) 17 | * Automated dependency upgrades (#337) 18 | * Update path_role.go (#336) 19 | * Bump actions/setup-go in the github-actions-breaking group (#328) 20 | * chore: remove changie (#335) 21 | * Update changelog for v0.23.0 release (#332) 22 | 23 | ## v0.23.0 24 | ### October 3, 2025 25 | 26 | * change audience warning text since audiences wont be required in Vault 1.21 (#330) 27 | * Upgrade Go Version (#329) 28 | * Automated dependency upgrades (#324) 29 | * ci: Update k8s versions to 1.33-1.29 (#326) 30 | * init changie (#323) 31 | * Bump actions/checkout in the github-actions-breaking group (#322) 32 | * Automated dependency upgrades (#317) 33 | * Add backport assistant workflow (#320) 34 | * VAULT-36974: Add backport assistant workflow (#319) 35 | * [Compliance] - PR Template Changes Required (#318) 36 | 37 | ## 0.22.2 (June 17, 2025) 38 | 39 | ### Build 40 | * Build with go 1.24.4 41 | * Test with k8s versions 1.28-1.32 42 | 43 | ### Changes 44 | 45 | Added warning that roles will require an audience starting in v0.23.0 [GH-301](https://github.com/hashicorp/vault-plugin-auth-kubernetes/pull/301). 46 | 47 | ### Dependency updates 48 | 49 | * `github.com/hashicorp/vault/api` v1.16.0 -> v1.20.0 50 | * `github.com/hashicorp/vault/sdk` v0.17.0 -> v0.18.0 51 | 52 | ## 0.22.1 (May 30, 2025) 53 | 54 | ### Build 55 | * Build with go 1.24.3 56 | * Test with k8s versions 1.28-1.32 57 | 58 | ### Dependency updates 59 | 60 | * `github.com/go-jose/go-jose/v4` v4.0.4 -> v4.1.0 61 | * `github.com/hashicorp/cap` v0.8.0 -> v0.9.0 62 | * `github.com/hashicorp/vault/sdk` v0.15.0 -> v0.17.0 63 | * `k8s.io/api` v0.32.1 -> v0.33.1 64 | * `k8s.io/apimachinery` v0.32.1 -> v0.33.1 65 | 66 | ## 0.21.1 (June 17, 2025) 67 | 68 | ### Build 69 | * Build with go 1.24.4 70 | * Test with k8s versions 1.28-1.32 71 | 72 | ### Changes 73 | 74 | Added warning that roles will require an audience starting in v0.23.0 [GH-301](https://github.com/hashicorp/vault-plugin-auth-kubernetes/pull/301). 75 | 76 | ### Dependency updates 77 | 78 | * `github.com/go-jose/go-jose/v4` v4.0.4 -> v4.1.0 79 | 80 | ## 0.21.0 (Feb 12, 2025) 81 | 82 | ### Build: 83 | * Build with go 1.23.6 84 | * Test with k8s versions 1.27-1.31 85 | 86 | ### Changes 87 | * `github.com/hashicorp/cap` v0.7.0 -> v0.8.0 88 | * `github.com/hashicorp/vault/api` v1.14.0 -> v1.16.0 89 | * `github.com/hashicorp/vault/sdk` v0.13.0 -> v0.15.0 90 | * `k8s.io/api` v0.31.0 -> v0.32.1 91 | * `k8s.io/apimachinery` v0.31.0 -> v0.32.1 92 | * `github.com/hashicorp/go-sockaddr` v1.0.6 -> v1.0.7 93 | 94 | ## 0.20.1 (June 17, 2025) 95 | 96 | ### Build 97 | * Build with go 1.24.4 98 | * Test with k8s versions 1.28-1.32 99 | 100 | ### Changes 101 | 102 | Added warning that roles will require an audience starting in v0.23.0 [GH-301](https://github.com/hashicorp/vault-plugin-auth-kubernetes/pull/301). 103 | 104 | ### Dependency updates 105 | 106 | * `github.com/go-jose/go-jose/v4` v4.0.4 -> v4.1.0 107 | 108 | ## 0.20.0 (Sept 4, 2024) 109 | 110 | ### Build: 111 | * Build with go 1.22.6 112 | * Test with k8s versions 1.26-1.30 113 | * Migrate from gopkg.in/go-jose/go-jose.v2 to github.com/go-jose/go-jose/v4 114 | 115 | 116 | ### Dependency updates: 117 | * `github.com/go-test/deep` v1.1.0 -> v1.1.1 118 | * `github.com/hashicorp/cap` v0.6.0 -> v0.7.0 119 | * `github.com/hashicorp/go-hclog` v1.6.2 -> v1.6.3 120 | * `github.com/hashicorp/vault/api` v1.12.2 -> v1.14.0 121 | * `github.com/hashicorp/vault/sdk` v0.11.1 -> v0.13.0 122 | * `k8s.io/api` v0.29.3 -> v0.31.0 123 | * `k8s.io/apimachinery` v0.29.3 -> v0.31.0 124 | 125 | 126 | ## 0.19.0 (May 20, 2024) 127 | 128 | ### Changes 129 | 130 | * Updated `gopkg.in/square/go-jose.v2@2.6.0` to `gopkg.in/go-jose/go-jose.v2@2.6.3` 131 | * Updated dependencies 132 | * `github.com/docker/docker` v24.0.7+incompatible -> v24.0.9+incompatible 133 | * `github.com/go-jose/go-jose/v3` v3.0.1 -> v3.0.3 134 | * `github.com/hashicorp/cap` v0.4.1 -> v0.6.0 135 | * `github.com/hashicorp/vault/api` v1.11.0 -> v1.12.2 136 | * `github.com/hashicorp/vault/sdk` v0.10.2 -> v0.11.1 137 | * `golang.org/x/net` v0.22.0 -> v0.23.0 138 | * `k8s.io/api` v0.29.1 -> v0.29.3 139 | * `k8s.io/apimachinery` v0.29.1 -> v0.29.3 140 | 141 | ### Improvements 142 | 143 | * Allow TLS client to use the host's root CA set when no CA certificates are provided and 144 | `disable_local_ca_jwt` is true if running Vault in a Kubernetes pod. Additionally, validate the 145 | configuration's provided CA PEM bundle. [GH-238](https://github.com/hashicorp/vault-plugin-auth-kubernetes/pull/238) 146 | 147 | ## 0.18.0 (Feb 2, 2024) 148 | 149 | ### Changes 150 | 151 | * Build with go 1.21.3 152 | * Test with k8s versions 1.24-1.28 153 | * Updated dependencies [GH-209](https://github.com/hashicorp/vault-plugin-auth-kubernetes/pull/209) [GH-225](https://github.com/hashicorp/vault-plugin-auth-kubernetes/pull/225) [GH-230](https://github.com/hashicorp/vault-plugin-auth-kubernetes/pull/230): 154 | * `github.com/docker/docker` v24.0.5+incompatible -> v24.0.7+incompatible 155 | * `github.com/go-jose/go-jose/v3` v3.0.0 -> v3.0.1 156 | * `github.com/hashicorp/cap` v0.3.4 -> v0.4.1 157 | * `github.com/hashicorp/go-hclog` v1.5.0 -> v1.6.2 158 | * `github.com/hashicorp/go-sockaddr` v1.0.2 -> v1.0.6 159 | * `github.com/hashicorp/vault/api` v1.9.2 -> v1.11.0 160 | * `github.com/hashicorp/vault/sdk` v0.9.2 -> v0.10.2 161 | * `golang.org/x/crypto` v0.11.0 -> v0.14.0 162 | * `golang.org/x/mod` v0.12.0 -> v0.14.0 163 | * `golang.org/x/net` v0.13.0 -> v0.19.0 164 | * `golang.org/x/sys` v0.10.0 -> v0.13.0 165 | * `golang.org/x/text` v0.11.0 -> v0.13.0 166 | * `golang.org/x/tools` v0.12.0 -> v0.16.1 167 | * `k8s.io/api` v0.28.1 -> v0.29.1 168 | * `k8s.io/apimachinery` v0.28.1 -> v0.29.1 169 | 170 | ### Features 171 | 172 | * Use annotations with the prefix `vault.hashicorp.com/alias-metadata-` from the client token's associated 173 | service account as alias metadata for the Vault entity [GH-226](https://github.com/hashicorp/vault-plugin-auth-kubernetes/pull/226) 174 | 175 | ### Improvements 176 | 177 | * Support bound service account namespace selector [GH-218](https://github.com/hashicorp/vault-plugin-auth-kubernetes/pull/218) 178 | * Indicate that token reviewer JWT is set on config read [GH-221](https://github.com/hashicorp/vault-plugin-auth-kubernetes/pull/221) 179 | 180 | ## 0.17.1 (Sept 7, 2023) 181 | 182 | ### Improvements 183 | 184 | * Allow any token type for TokenReviewer validation [GH-207](https://github.com/hashicorp/vault-plugin-auth-kubernetes/pull/207) 185 | 186 | ## 0.17.0 (Aug 31, 2023) 187 | * update dependencies [GH-206](https://github.com/hashicorp/vault-plugin-auth-kubernetes/pull/206) 188 | * github.com/hashicorp/cap v0.3.4 189 | * github.com/hashicorp/vault/api v1.9.2 190 | * github.com/hashicorp/vault/sdk v0.9.2 191 | * k8s.io/api v0.28.1 192 | * k8s.io/apimachinery v0.28.1 193 | 194 | ## 0.16.0 (May 25, 2023) 195 | * Add display attributes for OpenAPI OperationID's [GH-192](https://github.com/hashicorp/vault-plugin-auth-kubernetes/pull/192) 196 | * update dependencies [GH-196](https://github.com/hashicorp/vault-plugin-secrets-kubernetes/pull/196) 197 | * github.com/hashicorp/cap v0.3.0 198 | * github.com/hashicorp/vault/api v1.9.1 199 | * k8s.io/api v0.27.2 200 | * k8s.io/apimachinery v0.27.2 201 | 202 | ## 0.15.1 (March 27, 2023) 203 | 204 | ### Changes 205 | 206 | * enable plugin multiplexing [GH-186](https://github.com/hashicorp/vault-plugin-auth-kubernetes/pull/186) 207 | * update dependencies 208 | * `github.com/hashicorp/vault/api` v1.9.0 209 | * `github.com/hashicorp/vault/sdk` v0.8.1 210 | * `github.com/go-test/deep` v1.0.8 -> v1.1.0 211 | * `github.com/hashicorp/go-hclog` v1.3.1 -> v1.5.0 212 | * `k8s.io/api` v0.25.3 -> v0.26.3 213 | * `k8s.io/apimachinery` v0.25.3 -> v0.26.3 214 | 215 | ## 0.15.0 (February 9, 2023) 216 | 217 | ### Changes 218 | 219 | * Return HTTP 403 error code instead of 500 when JWT validation fails due to invalid issuer, audiences, or signing algorithm [GH-179](https://github.com/hashicorp/vault-plugin-auth-kubernetes/pull/179) 220 | * Checks the Kubernetes API is audience-aware by checking for at least one compatible audience in the response from TokenReviews [GH-179](https://github.com/hashicorp/vault-plugin-auth-kubernetes/pull/179) 221 | * Update to Go 1.19 [GH-166](https://github.com/hashicorp/vault-plugin-auth-kubernetes/pull/166) 222 | * Update dependencies [GH-166](https://github.com/hashicorp/vault-plugin-auth-kubernetes/pull/166): 223 | | MODULE | VERSION | NEW VERSION | DIRECT | VALID TIMESTAMPS | 224 | |---------------------------------|------------------------------------|-------------|--------|------------------| 225 | | github.com/hashicorp/go-hclog | v1.1.0 | v1.3.1 | true | true | 226 | | github.com/hashicorp/go-uuid | v1.0.2 | v1.0.3 | true | true | 227 | | github.com/hashicorp/go-version | v1.2.0 | v1.6.0 | true | true | 228 | | github.com/hashicorp/vault/api | v1.5.0 | v1.8.2 | true | true | 229 | | github.com/hashicorp/vault/sdk | v0.5.3 | v0.6.1 | true | true | 230 | | k8s.io/api | v0.0.0-20190409092523-d687e77c8ae9 | v0.25.3 | true | true | 231 | | k8s.io/apimachinery | v0.22.2 | v0.25.3 | true | true | 232 | -------------------------------------------------------------------------------- /path_config.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package kubeauth 5 | 6 | import ( 7 | "context" 8 | "crypto" 9 | "crypto/ecdsa" 10 | "crypto/rsa" 11 | "crypto/x509" 12 | "encoding/pem" 13 | "errors" 14 | 15 | "github.com/hashicorp/vault/sdk/framework" 16 | "github.com/hashicorp/vault/sdk/logical" 17 | ) 18 | 19 | const ( 20 | localCACertPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" 21 | localJWTPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" 22 | ) 23 | 24 | // pathConfig returns the path configuration for CRUD operations on the backend 25 | // configuration. 26 | func pathConfig(b *kubeAuthBackend) *framework.Path { 27 | return &framework.Path{ 28 | Pattern: "config$", 29 | DisplayAttrs: &framework.DisplayAttributes{ 30 | OperationPrefix: operationPrefixKubernetes, 31 | }, 32 | Fields: map[string]*framework.FieldSchema{ 33 | "kubernetes_host": { 34 | Type: framework.TypeString, 35 | Description: "Host must be a host string, a host:port pair, or a URL to the base of the Kubernetes API server.", 36 | }, 37 | "kubernetes_ca_cert": { 38 | Type: framework.TypeString, 39 | Description: `Optional PEM encoded CA cert for use by the TLS client used to talk with the API. 40 | If it is not set and disable_local_ca_jwt is true, the system's trusted CA certificate pool will be used.`, 41 | DisplayAttrs: &framework.DisplayAttributes{ 42 | Name: "Kubernetes CA Certificate", 43 | }, 44 | }, 45 | "token_reviewer_jwt": { 46 | Type: framework.TypeString, 47 | Description: `A service account JWT (or other token) used as a bearer token to access the 48 | TokenReview API to validate other JWTs during login. If not set 49 | the JWT used for login will be used to access the API.`, 50 | DisplayAttrs: &framework.DisplayAttributes{ 51 | Name: "Token Reviewer JWT", 52 | }, 53 | }, 54 | "pem_keys": { 55 | Type: framework.TypeCommaStringSlice, 56 | Description: `Optional list of PEM-formated public keys or certificates 57 | used to verify the signatures of kubernetes service account 58 | JWTs. If a certificate is given, its public key will be 59 | extracted. Not every installation of Kubernetes exposes these keys.`, 60 | DisplayAttrs: &framework.DisplayAttributes{ 61 | Name: "Service account verification keys", 62 | }, 63 | }, 64 | "issuer": { 65 | Type: framework.TypeString, 66 | Deprecated: true, 67 | Description: `Optional JWT issuer. If no issuer is specified, 68 | then this plugin will use kubernetes.io/serviceaccount as the default issuer. 69 | (Deprecated, will be removed in a future release)`, 70 | DisplayAttrs: &framework.DisplayAttributes{ 71 | Name: "JWT Issuer", 72 | }, 73 | }, 74 | "disable_iss_validation": { 75 | Type: framework.TypeBool, 76 | Deprecated: true, 77 | Description: `Disable JWT issuer validation (Deprecated, will be removed in a future release)`, 78 | Default: true, 79 | DisplayAttrs: &framework.DisplayAttributes{ 80 | Name: "Disable JWT Issuer Validation", 81 | }, 82 | }, 83 | "disable_local_ca_jwt": { 84 | Type: framework.TypeBool, 85 | Description: "Disable defaulting to the local CA cert and service account JWT when running in a Kubernetes pod", 86 | Default: false, 87 | DisplayAttrs: &framework.DisplayAttributes{ 88 | Name: "Disable use of local CA and service account JWT", 89 | }, 90 | }, 91 | "use_annotations_as_alias_metadata": { 92 | Type: framework.TypeBool, 93 | Description: `Use annotations from the client token's 94 | associated service account as alias metadata for the Vault entity. 95 | Only annotations with the prefix "vault.hashicorp.com/alias-metadata-" 96 | will be used. Note that Vault will need permission to read service 97 | accounts from the Kubernetes API.`, 98 | Default: false, 99 | DisplayAttrs: &framework.DisplayAttributes{ 100 | Name: "Use annotations of JWT service account as alias metadata", 101 | }, 102 | }, 103 | }, 104 | 105 | Operations: map[logical.Operation]framework.OperationHandler{ 106 | logical.UpdateOperation: &framework.PathOperation{ 107 | Callback: b.pathConfigWrite, 108 | DisplayAttrs: &framework.DisplayAttributes{ 109 | OperationVerb: "configure", 110 | OperationSuffix: "auth", 111 | }, 112 | }, 113 | logical.ReadOperation: &framework.PathOperation{ 114 | Callback: b.pathConfigRead, 115 | DisplayAttrs: &framework.DisplayAttributes{ 116 | OperationVerb: "read", 117 | OperationSuffix: "auth-configuration", 118 | }, 119 | }, 120 | }, 121 | 122 | HelpSynopsis: confHelpSyn, 123 | HelpDescription: confHelpDesc, 124 | } 125 | } 126 | 127 | // pathConfigWrite handles create and update commands to the config 128 | func (b *kubeAuthBackend) pathConfigRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { 129 | if config, err := b.config(ctx, req.Storage); err != nil { 130 | return nil, err 131 | } else if config == nil { 132 | return nil, nil 133 | } else { 134 | // Create a map of data to be returned 135 | resp := &logical.Response{ 136 | Data: map[string]interface{}{ 137 | "kubernetes_host": config.Host, 138 | "kubernetes_ca_cert": config.CACert, 139 | "pem_keys": config.PEMKeys, 140 | "issuer": config.Issuer, 141 | "disable_iss_validation": config.DisableISSValidation, 142 | "disable_local_ca_jwt": config.DisableLocalCAJwt, 143 | "token_reviewer_jwt_set": config.TokenReviewerJWT != "", 144 | "use_annotations_as_alias_metadata": config.UseAnnotationsAsAliasMetadata, 145 | }, 146 | } 147 | 148 | return resp, nil 149 | } 150 | } 151 | 152 | // pathConfigWrite handles create and update commands to the config 153 | func (b *kubeAuthBackend) pathConfigWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { 154 | b.l.Lock() 155 | defer b.l.Unlock() 156 | 157 | host := data.Get("kubernetes_host").(string) 158 | if host == "" { 159 | return logical.ErrorResponse("no host provided"), nil 160 | } 161 | 162 | disableLocalCAJwt := data.Get("disable_local_ca_jwt").(bool) 163 | pemList := data.Get("pem_keys").([]string) 164 | caCert := data.Get("kubernetes_ca_cert").(string) 165 | issuer := data.Get("issuer").(string) 166 | disableIssValidation := data.Get("disable_iss_validation").(bool) 167 | tokenReviewer := data.Get("token_reviewer_jwt").(string) 168 | useAnnotationsAsAliasMetadata := data.Get("use_annotations_as_alias_metadata").(bool) 169 | 170 | // hasCerts returns true if caCert contains at least one valid certificate. It 171 | // does not check if any of the certificates from caCert are CAs, although that 172 | // might be something that we want in the future. 173 | hasCerts := func(certBundle string) bool { 174 | var b *pem.Block 175 | rest := []byte(certBundle) 176 | for { 177 | b, rest = pem.Decode(rest) 178 | if b == nil { 179 | break 180 | } 181 | 182 | if pem.EncodeToMemory(b) != nil { 183 | return true 184 | } 185 | } 186 | 187 | return false 188 | } 189 | 190 | if caCert != "" && !hasCerts(caCert) { 191 | return logical.ErrorResponse( 192 | "The provided CA PEM data contains no valid certificates", 193 | ), nil 194 | } 195 | 196 | config := &kubeConfig{ 197 | PublicKeys: make([]crypto.PublicKey, len(pemList)), 198 | PEMKeys: pemList, 199 | Host: host, 200 | CACert: caCert, 201 | TokenReviewerJWT: tokenReviewer, 202 | Issuer: issuer, 203 | DisableISSValidation: disableIssValidation, 204 | DisableLocalCAJwt: disableLocalCAJwt, 205 | UseAnnotationsAsAliasMetadata: useAnnotationsAsAliasMetadata, 206 | } 207 | 208 | var err error 209 | for i, pem := range pemList { 210 | config.PublicKeys[i], err = parsePublicKeyPEM([]byte(pem)) 211 | if err != nil { 212 | return logical.ErrorResponse(err.Error()), nil 213 | } 214 | } 215 | 216 | if err := b.updateTLSConfig(config); err != nil { 217 | return logical.ErrorResponse(err.Error()), nil 218 | } 219 | 220 | entry, err := logical.StorageEntryJSON(configPath, config) 221 | if err != nil { 222 | return nil, err 223 | } 224 | 225 | if err := req.Storage.Put(ctx, entry); err != nil { 226 | return nil, err 227 | } 228 | 229 | return nil, nil 230 | } 231 | 232 | // kubeConfig contains the public key certificate used to verify the signature 233 | // on the service account JWTs 234 | type kubeConfig struct { 235 | // PublicKeys is the list of public key objects used to verify JWTs 236 | PublicKeys []crypto.PublicKey `json:"-"` 237 | // PEMKeys is the list of public key PEMs used to store the keys 238 | // in storage. 239 | PEMKeys []string `json:"pem_keys"` 240 | // Host is the url string for the kubernetes API 241 | Host string `json:"host"` 242 | // CACert is the CA Cert to use to call into the kubernetes API 243 | CACert string `json:"ca_cert"` 244 | // TokenReviewJWT is the bearer to use during the TokenReview API call 245 | TokenReviewerJWT string `json:"token_reviewer_jwt"` 246 | // Issuer is the claim that specifies who issued the token 247 | Issuer string `json:"issuer"` 248 | // DisableISSValidation is optional parameter to allow to skip ISS validation 249 | DisableISSValidation bool `json:"disable_iss_validation"` 250 | // DisableLocalJWT is an optional parameter to disable defaulting to using 251 | // the local CA cert and service account jwt when running in a Kubernetes 252 | // pod 253 | DisableLocalCAJwt bool `json:"disable_local_ca_jwt"` 254 | // UseAnnotationsAsAliasMetadata is an optional parameter to enable using 255 | // annotations from the client token's associated service account as 256 | // alias metadata for the Vault entity. Only annotations with the prefix 257 | // "vault.hashicorp.com/alias-metadata-" will be used. Note that Vault will 258 | // need permission to read service accounts from the Kubernetes API. 259 | UseAnnotationsAsAliasMetadata bool `json:"use_annotations_as_alias_metadata"` 260 | } 261 | 262 | // PasrsePublicKeyPEM is used to parse RSA and ECDSA public keys from PEMs 263 | func parsePublicKeyPEM(data []byte) (crypto.PublicKey, error) { 264 | block, data := pem.Decode(data) 265 | if block != nil { 266 | var rawKey interface{} 267 | var err error 268 | if rawKey, err = x509.ParsePKIXPublicKey(block.Bytes); err != nil { 269 | if cert, err := x509.ParseCertificate(block.Bytes); err == nil { 270 | rawKey = cert.PublicKey 271 | } else { 272 | return nil, err 273 | } 274 | } 275 | 276 | if rsaPublicKey, ok := rawKey.(*rsa.PublicKey); ok { 277 | return rsaPublicKey, nil 278 | } 279 | if ecPublicKey, ok := rawKey.(*ecdsa.PublicKey); ok { 280 | return ecPublicKey, nil 281 | } 282 | } 283 | 284 | return nil, errors.New("data does not contain any valid RSA or ECDSA public keys") 285 | } 286 | 287 | const ( 288 | confHelpSyn = `Configures the JWT Public Key and Kubernetes API information.` 289 | confHelpDesc = ` 290 | The Kubernetes Auth backend validates service account JWTs and verifies their 291 | existence with the Kubernetes TokenReview API. This endpoint configures the 292 | public key used to validate the JWT signature and the necessary information to 293 | access the Kubernetes API. 294 | ` 295 | ) 296 | -------------------------------------------------------------------------------- /backend_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package kubeauth 5 | 6 | import ( 7 | "context" 8 | "crypto/tls" 9 | "crypto/x509" 10 | "fmt" 11 | "net/http" 12 | "os" 13 | "path/filepath" 14 | "reflect" 15 | "testing" 16 | "time" 17 | 18 | "github.com/hashicorp/go-hclog" 19 | "github.com/hashicorp/vault/sdk/framework" 20 | "github.com/hashicorp/vault/sdk/logical" 21 | ) 22 | 23 | func Test_kubeAuthBackend_updateTLSConfig(t *testing.T) { 24 | defaultCertPool := getTestCertPool(t, testCACert) 25 | localCertPool := getTestCertPool(t, testLocalCACert) 26 | otherCertPool := getTestCertPool(t, testOtherCACert) 27 | 28 | systemCertPool, err := x509.SystemCertPool() 29 | if err != nil { 30 | t.Fatal(err) 31 | } 32 | 33 | type testConfig struct { 34 | config *kubeConfig 35 | expectTLSConfig *tls.Config 36 | localCACert string 37 | wantErr bool 38 | expectError error 39 | } 40 | tests := []struct { 41 | name string 42 | httpClient *http.Client 43 | tlsConfig *tls.Config 44 | wantErr bool 45 | configs []testConfig 46 | }{ 47 | { 48 | name: "fail-client-not-set", 49 | httpClient: nil, 50 | configs: []testConfig{ 51 | { 52 | wantErr: true, 53 | expectError: errHTTPClientNotSet, 54 | }, 55 | }, 56 | }, 57 | { 58 | name: "fail-tlsConfig-not-set", 59 | httpClient: getDefaultHTTPClient(), 60 | configs: []testConfig{ 61 | { 62 | wantErr: true, 63 | expectError: errTLSConfigNotSet, 64 | }, 65 | }, 66 | }, 67 | { 68 | name: "ca-certs-from-config-source", 69 | httpClient: getDefaultHTTPClient(), 70 | tlsConfig: getDefaultTLSConfig(), 71 | wantErr: false, 72 | configs: []testConfig{ 73 | { 74 | config: &kubeConfig{ 75 | CACert: testCACert, 76 | DisableLocalCAJwt: false, 77 | }, 78 | expectTLSConfig: &tls.Config{ 79 | MinVersion: minTLSVersion, 80 | RootCAs: defaultCertPool, 81 | }, 82 | }, 83 | { 84 | config: &kubeConfig{ 85 | CACert: testLocalCACert, 86 | DisableLocalCAJwt: false, 87 | }, 88 | expectTLSConfig: &tls.Config{ 89 | MinVersion: minTLSVersion, 90 | RootCAs: localCertPool, 91 | }, 92 | }, 93 | { 94 | config: &kubeConfig{ 95 | CACert: testCACert, 96 | DisableLocalCAJwt: false, 97 | }, 98 | expectTLSConfig: &tls.Config{ 99 | MinVersion: minTLSVersion, 100 | RootCAs: defaultCertPool, 101 | }, 102 | }, 103 | }, 104 | }, 105 | { 106 | name: "ca-certs-from-file-source", 107 | httpClient: getDefaultHTTPClient(), 108 | tlsConfig: getDefaultTLSConfig(), 109 | configs: []testConfig{ 110 | { 111 | config: &kubeConfig{ 112 | DisableLocalCAJwt: false, 113 | }, 114 | expectTLSConfig: &tls.Config{ 115 | MinVersion: minTLSVersion, 116 | RootCAs: defaultCertPool, 117 | }, 118 | localCACert: testCACert, 119 | }, 120 | { 121 | config: &kubeConfig{ 122 | DisableLocalCAJwt: false, 123 | }, 124 | localCACert: testLocalCACert, 125 | expectTLSConfig: &tls.Config{ 126 | MinVersion: minTLSVersion, 127 | RootCAs: localCertPool, 128 | }, 129 | }, 130 | }, 131 | wantErr: false, 132 | }, 133 | { 134 | name: "ca-certs-mixed-source", 135 | httpClient: getDefaultHTTPClient(), 136 | tlsConfig: getDefaultTLSConfig(), 137 | configs: []testConfig{ 138 | { 139 | config: &kubeConfig{ 140 | CACert: testCACert, 141 | DisableLocalCAJwt: false, 142 | }, 143 | expectTLSConfig: &tls.Config{ 144 | MinVersion: minTLSVersion, 145 | RootCAs: defaultCertPool, 146 | }, 147 | }, 148 | { 149 | config: &kubeConfig{ 150 | DisableLocalCAJwt: false, 151 | }, 152 | localCACert: testLocalCACert, 153 | expectTLSConfig: &tls.Config{ 154 | MinVersion: minTLSVersion, 155 | RootCAs: localCertPool, 156 | }, 157 | }, 158 | { 159 | config: &kubeConfig{ 160 | CACert: testOtherCACert, 161 | DisableLocalCAJwt: false, 162 | }, 163 | expectTLSConfig: &tls.Config{ 164 | MinVersion: minTLSVersion, 165 | RootCAs: otherCertPool, 166 | }, 167 | }, 168 | { 169 | config: &kubeConfig{ 170 | DisableLocalCAJwt: false, 171 | }, 172 | expectTLSConfig: &tls.Config{ 173 | MinVersion: minTLSVersion, 174 | RootCAs: defaultCertPool, 175 | }, 176 | localCACert: testCACert, 177 | }, 178 | }, 179 | wantErr: false, 180 | }, 181 | { 182 | name: "ca-certs-not-set", 183 | httpClient: getDefaultHTTPClient(), 184 | tlsConfig: getDefaultTLSConfig(), 185 | configs: []testConfig{ 186 | { 187 | config: &kubeConfig{ 188 | DisableLocalCAJwt: true, 189 | }, 190 | expectTLSConfig: &tls.Config{ 191 | MinVersion: minTLSVersion, 192 | RootCAs: systemCertPool, 193 | }, 194 | }, 195 | }, 196 | wantErr: false, 197 | }, 198 | } 199 | for _, tt := range tests { 200 | t.Run(tt.name, func(t *testing.T) { 201 | b := &kubeAuthBackend{ 202 | Backend: &framework.Backend{}, 203 | httpClient: tt.httpClient, 204 | tlsConfig: tt.tlsConfig, 205 | } 206 | 207 | if err := b.Setup(context.Background(), 208 | &logical.BackendConfig{ 209 | Logger: hclog.NewNullLogger(), 210 | }); err != nil { 211 | t.Fatalf("failed to setup the backend, err=%v", err) 212 | } 213 | 214 | localFile := filepath.Join(t.TempDir(), "ca.crt") 215 | b.localCACertReader = &cachingFileReader{ 216 | path: localFile, 217 | currentTime: time.Now().UTC, 218 | ttl: 0, 219 | } 220 | for idx, config := range tt.configs { 221 | t.Run(fmt.Sprintf("config-%d", idx), func(t *testing.T) { 222 | if config.localCACert != "" { 223 | if err := os.WriteFile(localFile, []byte(config.localCACert), 0o600); err != nil { 224 | t.Fatalf("failed to write local file %q", localFile) 225 | } 226 | t.Cleanup(func() { 227 | if err := os.Remove(localFile); err != nil { 228 | t.Fatal(err) 229 | } 230 | }) 231 | } 232 | 233 | err := b.updateTLSConfig(config.config) 234 | if config.wantErr && err == nil { 235 | t.Fatalf("updateTLSConfig() error = %v, wantErr %v", err, config.wantErr) 236 | } 237 | 238 | if !reflect.DeepEqual(err, config.expectError) { 239 | t.Fatalf("updateTLSConfig() error = %v, expectErr %v", err, config.expectError) 240 | } 241 | 242 | if config.wantErr { 243 | return 244 | } 245 | 246 | assertTLSConfigEquals(t, b.tlsConfig, config.expectTLSConfig) 247 | assertValidTransport(t, b, config.expectTLSConfig) 248 | }) 249 | } 250 | }) 251 | } 252 | } 253 | 254 | func Test_kubeAuthBackend_initialize(t *testing.T) { 255 | defaultCertPool := getTestCertPool(t, testCACert) 256 | 257 | tests := []struct { 258 | name string 259 | httpClient *http.Client 260 | ctx context.Context 261 | req *logical.InitializationRequest 262 | config *kubeConfig 263 | tlsConfig *tls.Config 264 | expectTLSConfig *tls.Config 265 | wantErr bool 266 | expectErr error 267 | }{ 268 | { 269 | name: "fail-client-not-set", 270 | ctx: context.Background(), 271 | httpClient: nil, 272 | tlsConfig: getDefaultTLSConfig(), 273 | req: &logical.InitializationRequest{ 274 | Storage: &logical.InmemStorage{}, 275 | }, 276 | config: &kubeConfig{ 277 | CACert: testCACert, 278 | DisableLocalCAJwt: false, 279 | }, 280 | wantErr: true, 281 | expectErr: errHTTPClientNotSet, 282 | }, 283 | { 284 | name: "no-config", 285 | ctx: context.Background(), 286 | httpClient: getDefaultHTTPClient(), 287 | tlsConfig: getDefaultTLSConfig(), 288 | req: &logical.InitializationRequest{ 289 | Storage: &logical.InmemStorage{}, 290 | }, 291 | wantErr: false, 292 | expectErr: nil, 293 | }, 294 | { 295 | name: "initialized-from-config", 296 | ctx: context.Background(), 297 | httpClient: getDefaultHTTPClient(), 298 | tlsConfig: getDefaultTLSConfig(), 299 | req: &logical.InitializationRequest{ 300 | Storage: &logical.InmemStorage{}, 301 | }, 302 | config: &kubeConfig{ 303 | CACert: testCACert, 304 | DisableLocalCAJwt: false, 305 | }, 306 | expectTLSConfig: &tls.Config{ 307 | MinVersion: minTLSVersion, 308 | RootCAs: defaultCertPool, 309 | }, 310 | wantErr: false, 311 | expectErr: nil, 312 | }, 313 | } 314 | for _, tt := range tests { 315 | t.Run(tt.name, func(t *testing.T) { 316 | b := &kubeAuthBackend{ 317 | Backend: &framework.Backend{}, 318 | httpClient: tt.httpClient, 319 | tlsConfig: tt.tlsConfig, 320 | } 321 | 322 | if err := b.Setup(context.Background(), 323 | &logical.BackendConfig{ 324 | Logger: hclog.NewNullLogger(), 325 | StorageView: tt.req.Storage, 326 | }); err != nil { 327 | t.Fatalf("failed to setup the backend, err=%v", err) 328 | } 329 | 330 | if tt.config != nil { 331 | entry, err := logical.StorageEntryJSON(configPath, tt.config) 332 | if err != nil { 333 | t.Fatal(err) 334 | } 335 | 336 | if err := tt.req.Storage.Put(tt.ctx, entry); err != nil { 337 | t.Fatal(err) 338 | } 339 | } 340 | 341 | if b.tlsConfigUpdaterRunning { 342 | t.Fatalf("tlsConfigUpdater started before initialize()") 343 | } 344 | 345 | ctx, _ := context.WithTimeout(tt.ctx, time.Second*30) 346 | err := b.initialize(ctx, tt.req) 347 | if tt.wantErr && err == nil { 348 | t.Errorf("initialize() error = %v, wantErr %v", err, tt.wantErr) 349 | } 350 | 351 | if !reflect.DeepEqual(err, tt.expectErr) { 352 | t.Fatalf("initialize() error = %v, expectErr %v", err, tt.expectErr) 353 | } 354 | 355 | if tt.wantErr { 356 | return 357 | } 358 | 359 | if tt.config != nil { 360 | assertTLSConfigEquals(t, b.tlsConfig, tt.expectTLSConfig) 361 | assertValidTransport(t, b, tt.expectTLSConfig) 362 | } 363 | 364 | if !b.tlsConfigUpdaterRunning { 365 | t.Fatalf("tlsConfigUpdater not started from initialize()") 366 | } 367 | }) 368 | } 369 | } 370 | 371 | func Test_kubeAuthBackend_runTLSConfigUpdater(t *testing.T) { 372 | defaultCertPool := getTestCertPool(t, testCACert) 373 | otherCertPool := getTestCertPool(t, testOtherCACert) 374 | 375 | type testConfig struct { 376 | config *kubeConfig 377 | expectTLSConfig *tls.Config 378 | } 379 | 380 | tests := []struct { 381 | name string 382 | ctx context.Context 383 | storage logical.Storage 384 | tlsConfig *tls.Config 385 | horizon time.Duration 386 | minHorizon time.Duration 387 | wantErr bool 388 | expectErr error 389 | configs []*testConfig 390 | }{ 391 | { 392 | name: "initialized-from-config", 393 | tlsConfig: getDefaultTLSConfig(), 394 | ctx: context.Background(), 395 | storage: &logical.InmemStorage{}, 396 | horizon: time.Millisecond * 500, 397 | minHorizon: time.Millisecond * 499, 398 | wantErr: false, 399 | expectErr: nil, 400 | configs: []*testConfig{ 401 | { 402 | config: &kubeConfig{ 403 | CACert: testCACert, 404 | DisableLocalCAJwt: false, 405 | }, 406 | expectTLSConfig: &tls.Config{ 407 | MinVersion: minTLSVersion, 408 | RootCAs: defaultCertPool, 409 | }, 410 | }, 411 | { 412 | config: &kubeConfig{ 413 | CACert: testOtherCACert, 414 | DisableLocalCAJwt: false, 415 | }, 416 | expectTLSConfig: &tls.Config{ 417 | MinVersion: minTLSVersion, 418 | RootCAs: otherCertPool, 419 | }, 420 | }, 421 | }, 422 | }, 423 | { 424 | name: "fail-min-horizon", 425 | ctx: context.Background(), 426 | storage: &logical.InmemStorage{}, 427 | horizon: time.Millisecond * 500, 428 | wantErr: true, 429 | expectErr: fmt.Errorf("update horizon must be equal to or greater than %s", defaultMinHorizon), 430 | }, 431 | } 432 | 433 | d := defaultMinHorizon 434 | for _, tt := range tests { 435 | t.Run(tt.name, func(t *testing.T) { 436 | if tt.minHorizon > 0 { 437 | defer (func() { 438 | defaultMinHorizon = d 439 | })() 440 | defaultMinHorizon = tt.minHorizon 441 | } 442 | b := &kubeAuthBackend{ 443 | Backend: &framework.Backend{}, 444 | httpClient: getDefaultHTTPClient(), 445 | tlsConfig: tt.tlsConfig, 446 | } 447 | 448 | if err := b.Setup(context.Background(), 449 | &logical.BackendConfig{ 450 | Logger: hclog.NewNullLogger(), 451 | StorageView: tt.storage, 452 | }); err != nil { 453 | t.Fatalf("failed to setup the backend, err=%v", err) 454 | } 455 | 456 | if b.tlsConfigUpdaterRunning { 457 | t.Fatalf("tlsConfigUpdater already started") 458 | } 459 | 460 | configCount := len(tt.configs) 461 | ctx, cancel := context.WithTimeout(tt.ctx, tt.horizon*time.Duration(configCount*2)) 462 | defer cancel() 463 | err := b.runTLSConfigUpdater(ctx, tt.storage, tt.horizon) 464 | if tt.wantErr && err == nil { 465 | t.Errorf("runTLSConfigUpdater() error = %v, wantErr %v", err, tt.wantErr) 466 | } 467 | 468 | if !reflect.DeepEqual(err, tt.expectErr) { 469 | t.Fatalf("runTLSConfigUpdater() error = %v, expectErr %v", err, tt.expectErr) 470 | } 471 | 472 | if tt.wantErr { 473 | return 474 | } 475 | 476 | if !b.tlsConfigUpdaterRunning { 477 | t.Fatalf("tlsConfigUpdater not started") 478 | } 479 | 480 | if configCount > 0 { 481 | for idx := 0; idx < configCount; idx++ { 482 | t.Run(fmt.Sprintf("config-%d", idx), func(t *testing.T) { 483 | config := tt.configs[idx] 484 | if config.config != nil { 485 | entry, err := logical.StorageEntryJSON(configPath, config.config) 486 | if err != nil { 487 | t.Fatal(err) 488 | } 489 | 490 | if err := tt.storage.Put(tt.ctx, entry); err != nil { 491 | t.Fatal(err) 492 | } 493 | } 494 | 495 | time.Sleep(tt.horizon * 3) 496 | if b.tlsConfig == nil { 497 | t.Fatalf("runTLSConfigUpdater(), expected tlsConfig initialization") 498 | } 499 | assertTLSConfigEquals(t, b.tlsConfig, config.expectTLSConfig) 500 | assertValidTransport(t, b, config.expectTLSConfig) 501 | }) 502 | } 503 | } else { 504 | if b.tlsConfig != nil { 505 | t.Errorf("runTLSConfigUpdater(), unexpected tlsConfig initialization") 506 | } 507 | } 508 | 509 | cancel() 510 | time.Sleep(tt.horizon) 511 | if b.tlsConfigUpdaterRunning { 512 | t.Fatalf("tlsConfigUpdater did not shutdown cleanly") 513 | } 514 | }) 515 | } 516 | } 517 | 518 | func assertTLSConfigEquals(t *testing.T, actual, expected *tls.Config) { 519 | t.Helper() 520 | 521 | if !actual.RootCAs.Equal(expected.RootCAs) { 522 | t.Errorf("updateTLSConfig() actual RootCAs = %v, expected RootCAs %v", 523 | actual.RootCAs, expected.RootCAs) 524 | } 525 | if actual.MinVersion != expected.MinVersion { 526 | t.Errorf("updateTLSConfig() actual MinVersion = %v, expected MinVersion %v", 527 | actual.MinVersion, expected.MinVersion) 528 | } 529 | } 530 | 531 | func assertValidTransport(t *testing.T, b *kubeAuthBackend, expected *tls.Config) { 532 | t.Helper() 533 | 534 | transport, ok := b.httpClient.Transport.(*http.Transport) 535 | if !ok { 536 | t.Fatalf("type assertion failed for %T", b.httpClient.Transport) 537 | } 538 | 539 | assertTLSConfigEquals(t, transport.TLSClientConfig, expected) 540 | } 541 | 542 | func getTestCertPool(t *testing.T, cert string) *x509.CertPool { 543 | t.Helper() 544 | 545 | pool := x509.NewCertPool() 546 | if ok := pool.AppendCertsFromPEM([]byte(cert)); !ok { 547 | t.Fatalf("test certificate contains no valid certificates") 548 | } 549 | return pool 550 | } 551 | -------------------------------------------------------------------------------- /backend.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package kubeauth 5 | 6 | import ( 7 | "context" 8 | "crypto" 9 | "crypto/tls" 10 | "crypto/x509" 11 | "encoding/json" 12 | "errors" 13 | "fmt" 14 | "net/http" 15 | "strings" 16 | "sync" 17 | "time" 18 | 19 | "github.com/hashicorp/go-cleanhttp" 20 | "github.com/hashicorp/vault/sdk/framework" 21 | "github.com/hashicorp/vault/sdk/logical" 22 | ) 23 | 24 | const ( 25 | configPath = "config" 26 | rolePrefix = "role/" 27 | 28 | // aliasNameSourceUnset provides backwards compatibility with preexisting roles. 29 | aliasNameSourceUnset = "" 30 | aliasNameSourceSAUid = "serviceaccount_uid" 31 | aliasNameSourceSAName = "serviceaccount_name" 32 | aliasNameSourceDefault = aliasNameSourceSAUid 33 | minTLSVersion = tls.VersionTLS12 34 | 35 | // operationPrefixKubernetes is used as a prefix for OpenAPI operation id's. 36 | operationPrefixKubernetes = "kubernetes" 37 | ) 38 | 39 | var ( 40 | // when adding new alias name sources make sure to update the corresponding FieldSchema description in path_role.go 41 | aliasNameSources = []string{aliasNameSourceSAUid, aliasNameSourceSAName} 42 | errInvalidAliasNameSource = fmt.Errorf(`invalid alias_name_source, must be one of: %s`, strings.Join(aliasNameSources, ", ")) 43 | 44 | // jwtReloadPeriod is the time period how often the in-memory copy of local 45 | // service account token can be used, before reading it again from disk. 46 | // 47 | // The value is selected according to recommendation in Kubernetes 1.21 changelog: 48 | // "Clients should reload the token from disk periodically (once per minute 49 | // is recommended) to ensure they continue to use a valid token." 50 | jwtReloadPeriod = 1 * time.Minute 51 | 52 | // caReloadPeriod is the time period how often the in-memory copy of local 53 | // CA cert can be used, before reading it again from disk. 54 | caReloadPeriod = 1 * time.Hour 55 | 56 | // defaultHorizon provides the default duration to be used 57 | // in the tlsConfigUpdater's time.Ticker, setup in runTLSConfigUpdater() 58 | defaultHorizon = time.Second * 30 59 | 60 | // defaultMinHorizon provides the minimum duration that can be specified 61 | // in the tlsConfigUpdater's time.Ticker, setup in runTLSConfigUpdater() 62 | defaultMinHorizon = time.Second * 5 63 | 64 | errTLSConfigNotSet = errors.New("TLSConfig not set") 65 | errHTTPClientNotSet = errors.New("http.Client not set") 66 | ) 67 | 68 | // kubeAuthBackend implements logical.Backend 69 | type kubeAuthBackend struct { 70 | *framework.Backend 71 | 72 | // default HTTP client for connection reuse 73 | httpClient *http.Client 74 | 75 | // tlsConfig is periodically updated whenever the CA certificate configuration changes. 76 | tlsConfig *tls.Config 77 | 78 | // reviewFactory is used to configure the strategy for doing a token review. 79 | // Currently, the only options are using the kubernetes API or mocking the 80 | // review. Mocks should only be used in tests. 81 | reviewFactory tokenReviewFactory 82 | 83 | // namespaceValidatorFactory is used to configure the strategy for validating 84 | // namespace properties (currently labels). Currently, the only options 85 | // are using the kubernetes API or mocking the validation. Mocks should 86 | // only be used in tests. 87 | namespaceValidatorFactory namespaceValidatorFactory 88 | 89 | // serviceAccountGetterFactory is used to configure the strategy for retrieving 90 | // service account properties (currently metadata). Currently, the only options 91 | // are using the kubernetes API or mocking the retrieval. Mocks should 92 | // only be used in tests. 93 | serviceAccountGetterFactory serviceAccountGetterFactory 94 | 95 | // localSATokenReader caches the service account token in memory. 96 | // It periodically reloads the token to support token rotation/renewal. 97 | // Local token is used when running in a pod with following configuration 98 | // - token_reviewer_jwt is not set 99 | // - disable_local_ca_jwt is false 100 | localSATokenReader *cachingFileReader 101 | 102 | // localCACertReader contains the local CA certificate. Local CA certificate is 103 | // used when running in a pod with following configuration 104 | // - kubernetes_ca_cert is not set 105 | // - disable_local_ca_jwt is false 106 | localCACertReader *cachingFileReader 107 | 108 | // tlsConfigUpdaterRunning is used to signal the current state of the tlsConfig updater routine. 109 | tlsConfigUpdaterRunning bool 110 | 111 | // tlsConfigUpdateCancelFunc should be called in the backend's Clean(), set in initialize(). 112 | tlsConfigUpdateCancelFunc context.CancelFunc 113 | 114 | l sync.RWMutex 115 | 116 | // tlsMu provides the lock for synchronizing updates to the tlsConfig. 117 | tlsMu sync.RWMutex 118 | } 119 | 120 | // Factory returns a new backend as logical.Backend. 121 | func Factory(ctx context.Context, conf *logical.BackendConfig) (logical.Backend, error) { 122 | b := Backend() 123 | 124 | if err := b.Setup(ctx, conf); err != nil { 125 | return nil, err 126 | } 127 | 128 | return b, nil 129 | } 130 | 131 | // getDefaultTLSConfig returns a http.Client with the supported, default 132 | // tls.Config from getDefaultTLSConfig() set on the 133 | // cleanhttp.DefaultPooledTransport() http.Transport. 134 | func getDefaultHTTPClient() *http.Client { 135 | transport := cleanhttp.DefaultPooledTransport() 136 | transport.TLSClientConfig = getDefaultTLSConfig() 137 | return &http.Client{ 138 | Transport: transport, 139 | } 140 | } 141 | 142 | func getDefaultTLSConfig() *tls.Config { 143 | return &tls.Config{ 144 | MinVersion: minTLSVersion, 145 | } 146 | } 147 | 148 | func Backend() *kubeAuthBackend { 149 | b := &kubeAuthBackend{ 150 | localSATokenReader: newCachingFileReader(localJWTPath, jwtReloadPeriod, time.Now), 151 | localCACertReader: newCachingFileReader(localCACertPath, caReloadPeriod, time.Now), 152 | // Set default HTTP client 153 | httpClient: getDefaultHTTPClient(), 154 | // Set the default TLSConfig 155 | tlsConfig: getDefaultTLSConfig(), 156 | // Set the review factory to default to calling into the kubernetes API. 157 | reviewFactory: tokenReviewAPIFactory, 158 | namespaceValidatorFactory: newNsValidatorWrapper, 159 | serviceAccountGetterFactory: newServiceAccountGetterWrapper, 160 | } 161 | 162 | b.Backend = &framework.Backend{ 163 | AuthRenew: b.pathLoginRenew(), 164 | BackendType: logical.TypeCredential, 165 | Help: backendHelp, 166 | PathsSpecial: &logical.Paths{ 167 | Unauthenticated: []string{ 168 | "login", 169 | }, 170 | SealWrapStorage: []string{ 171 | configPath, 172 | }, 173 | }, 174 | Paths: framework.PathAppend( 175 | []*framework.Path{ 176 | pathConfig(b), 177 | pathLogin(b), 178 | }, 179 | pathsRole(b), 180 | ), 181 | InitializeFunc: b.initialize, 182 | Clean: b.cleanup, 183 | } 184 | 185 | return b 186 | } 187 | 188 | // initialize is used to handle the state of config values just after the K8s plugin has been mounted 189 | func (b *kubeAuthBackend) initialize(ctx context.Context, req *logical.InitializationRequest) error { 190 | updaterCtx, cancel := context.WithCancel(context.Background()) 191 | if err := b.runTLSConfigUpdater(updaterCtx, req.Storage, defaultHorizon); err != nil { 192 | cancel() 193 | return err 194 | } 195 | 196 | b.tlsConfigUpdateCancelFunc = cancel 197 | 198 | config, err := b.config(ctx, req.Storage) 199 | if err != nil { 200 | return err 201 | } 202 | 203 | if config != nil { 204 | if err := b.updateTLSConfig(config); err != nil { 205 | return err 206 | } 207 | } 208 | 209 | return nil 210 | } 211 | 212 | func (b *kubeAuthBackend) cleanup(_ context.Context) { 213 | b.shutdownTLSConfigUpdater() 214 | } 215 | 216 | // validateHTTPClientInit that the Backend's HTTPClient and TLSConfig has been properly instantiated. 217 | func (b *kubeAuthBackend) validateHTTPClientInit() error { 218 | if b.httpClient == nil { 219 | return errHTTPClientNotSet 220 | } 221 | if b.tlsConfig == nil { 222 | return errTLSConfigNotSet 223 | } 224 | 225 | return nil 226 | } 227 | 228 | // runTLSConfigUpdater sets up a routine that periodically calls b.updateTLSConfig(). This ensures that the 229 | // httpClient's TLS configuration is consistent with the backend's stored configuration. 230 | func (b *kubeAuthBackend) runTLSConfigUpdater(ctx context.Context, s logical.Storage, horizon time.Duration) error { 231 | b.tlsMu.Lock() 232 | defer b.tlsMu.Unlock() 233 | 234 | if b.tlsConfigUpdaterRunning { 235 | return nil 236 | } 237 | 238 | if horizon < defaultMinHorizon { 239 | return fmt.Errorf("update horizon must be equal to or greater than %s", defaultMinHorizon) 240 | } 241 | 242 | if err := b.validateHTTPClientInit(); err != nil { 243 | return err 244 | } 245 | 246 | updateTLSConfig := func(ctx context.Context, s logical.Storage) error { 247 | config, err := b.config(ctx, s) 248 | if err != nil { 249 | return fmt.Errorf("failed config read, err=%w", err) 250 | } 251 | 252 | if config == nil { 253 | b.Logger().Trace("Skipping TLSConfig update, no configuration set") 254 | return nil 255 | } 256 | 257 | if err := b.updateTLSConfig(config); err != nil { 258 | return err 259 | } 260 | 261 | return nil 262 | } 263 | 264 | var wg sync.WaitGroup 265 | wg.Add(1) 266 | ticker := time.NewTicker(horizon) 267 | go func(ctx context.Context, s logical.Storage) { 268 | defer func() { 269 | b.tlsMu.Lock() 270 | defer b.tlsMu.Unlock() 271 | ticker.Stop() 272 | b.tlsConfigUpdaterRunning = false 273 | b.Logger().Trace("TLSConfig updater shutdown completed") 274 | }() 275 | 276 | b.Logger().Trace("TLSConfig updater starting", "horizon", horizon) 277 | b.tlsConfigUpdaterRunning = true 278 | wg.Done() 279 | for { 280 | select { 281 | case <-ctx.Done(): 282 | b.Logger().Trace("TLSConfig updater shutting down") 283 | return 284 | case <-ticker.C: 285 | if err := updateTLSConfig(ctx, s); err != nil { 286 | b.Logger().Warn("TLSConfig update failed, retrying", 287 | "horizon", defaultHorizon.String(), "err", err) 288 | } 289 | } 290 | } 291 | }(ctx, s) 292 | wg.Wait() 293 | 294 | return nil 295 | } 296 | 297 | func (b *kubeAuthBackend) shutdownTLSConfigUpdater() { 298 | if b.tlsConfigUpdateCancelFunc != nil { 299 | b.Logger().Debug("TLSConfig updater shutdown requested") 300 | b.tlsConfigUpdateCancelFunc() 301 | b.tlsConfigUpdateCancelFunc = nil 302 | } 303 | } 304 | 305 | // config takes a storage object and returns a kubeConfig object. 306 | // It does not return local token and CA file which are specific to the pod we run in. 307 | func (b *kubeAuthBackend) config(ctx context.Context, s logical.Storage) (*kubeConfig, error) { 308 | raw, err := s.Get(ctx, configPath) 309 | if err != nil { 310 | return nil, err 311 | } 312 | if raw == nil { 313 | return nil, nil 314 | } 315 | 316 | conf := &kubeConfig{} 317 | if err := json.Unmarshal(raw.Value, conf); err != nil { 318 | return nil, err 319 | } 320 | 321 | // Parse the public keys from the CertificatesBytes 322 | conf.PublicKeys = make([]crypto.PublicKey, len(conf.PEMKeys)) 323 | for i, cert := range conf.PEMKeys { 324 | conf.PublicKeys[i], err = parsePublicKeyPEM([]byte(cert)) 325 | if err != nil { 326 | return nil, err 327 | } 328 | } 329 | 330 | return conf, nil 331 | } 332 | 333 | // loadConfig fetches the kubeConfig from storage and optionally decorates it with 334 | // local token and CA certificate. Since loadConfig does not return an error if the kubeConfig reference 335 | // is nil, we should nil-check. This behavior exists to allow loadConfig's caller to 336 | // make a decision based on the returned reference. 337 | func (b *kubeAuthBackend) loadConfig(ctx context.Context, s logical.Storage) (*kubeConfig, error) { 338 | config, err := b.config(ctx, s) 339 | if err != nil { 340 | return nil, err 341 | } 342 | // We know the config is empty so exit early 343 | if config == nil { 344 | return config, nil 345 | } 346 | // Nothing more to do if loading local CA cert and JWT token is disabled. 347 | if config.DisableLocalCAJwt { 348 | return config, nil 349 | } 350 | 351 | // Read local JWT token unless it was not stored in config. 352 | if config.TokenReviewerJWT == "" { 353 | config.TokenReviewerJWT, err = b.localSATokenReader.ReadFile() 354 | if err != nil { 355 | // Ignore error: make the best effort trying to load local JWT, 356 | // otherwise the JWT submitted in login payload will be used. 357 | b.Logger().Debug("failed to read local service account token, will use client token", "error", err) 358 | } 359 | } 360 | 361 | // Read local CA cert unless it was stored in config. 362 | // Else build the TLSConfig with the trusted CA cert and load into client 363 | if config.CACert == "" { 364 | config.CACert, err = b.localCACertReader.ReadFile() 365 | if err != nil { 366 | return nil, err 367 | } 368 | } 369 | 370 | return config, nil 371 | } 372 | 373 | // role takes a storage backend and the name and returns the role's storage 374 | // entry 375 | func (b *kubeAuthBackend) role(ctx context.Context, s logical.Storage, name string) (*roleStorageEntry, error) { 376 | raw, err := s.Get(ctx, fmt.Sprintf("%s%s", rolePrefix, strings.ToLower(name))) 377 | if err != nil { 378 | return nil, err 379 | } 380 | if raw == nil { 381 | return nil, nil 382 | } 383 | 384 | role := &roleStorageEntry{} 385 | if err := json.Unmarshal(raw.Value, role); err != nil { 386 | return nil, err 387 | } 388 | 389 | if role.TokenTTL == 0 && role.TTL > 0 { 390 | role.TokenTTL = role.TTL 391 | } 392 | if role.TokenMaxTTL == 0 && role.MaxTTL > 0 { 393 | role.TokenMaxTTL = role.MaxTTL 394 | } 395 | if role.TokenPeriod == 0 && role.Period > 0 { 396 | role.TokenPeriod = role.Period 397 | } 398 | if role.TokenNumUses == 0 && role.NumUses > 0 { 399 | role.TokenNumUses = role.NumUses 400 | } 401 | if len(role.TokenPolicies) == 0 && len(role.Policies) > 0 { 402 | role.TokenPolicies = role.Policies 403 | } 404 | if len(role.TokenBoundCIDRs) == 0 && len(role.BoundCIDRs) > 0 { 405 | role.TokenBoundCIDRs = role.BoundCIDRs 406 | } 407 | 408 | return role, nil 409 | } 410 | 411 | // getHTTPClient return the backend's HTTP client for connecting to the Kubernetes API. 412 | func (b *kubeAuthBackend) getHTTPClient() (*http.Client, error) { 413 | b.tlsMu.RLock() 414 | defer b.tlsMu.RUnlock() 415 | 416 | if err := b.validateHTTPClientInit(); err != nil { 417 | return nil, err 418 | } 419 | 420 | return b.httpClient, nil 421 | } 422 | 423 | // updateTLSConfig ensures that the httpClient's TLS configuration is consistent 424 | // with the backend's stored configuration. 425 | func (b *kubeAuthBackend) updateTLSConfig(config *kubeConfig) error { 426 | b.tlsMu.Lock() 427 | defer b.tlsMu.Unlock() 428 | 429 | if err := b.validateHTTPClientInit(); err != nil { 430 | return err 431 | } 432 | 433 | // attempt to read the CA certificates from the config directly or from the filesystem. 434 | var caCertBytes []byte 435 | if config.CACert != "" { 436 | caCertBytes = []byte(config.CACert) 437 | } else if !config.DisableLocalCAJwt && b.localCACertReader != nil { 438 | data, err := b.localCACertReader.ReadFile() 439 | if err != nil { 440 | return err 441 | } 442 | caCertBytes = []byte(data) 443 | } 444 | 445 | var certPool *x509.CertPool 446 | if len(caCertBytes) == 0 { 447 | // since the CA chain is not configured, we use the system's cert pool. 448 | var err error 449 | certPool, err = x509.SystemCertPool() 450 | if err != nil { 451 | return err 452 | } 453 | } else { 454 | // since we have a CA chain configured, we create a new x509.CertPool with its 455 | // contents. 456 | certPool = x509.NewCertPool() 457 | if ok := certPool.AppendCertsFromPEM(caCertBytes); !ok { 458 | b.Logger().Warn("Configured CA PEM data contains no valid certificates, TLS verification will fail") 459 | } 460 | } 461 | 462 | setTLSClientConfig := func() error { 463 | transport, ok := b.httpClient.Transport.(*http.Transport) 464 | if !ok { 465 | // should never happen 466 | return fmt.Errorf("type assertion failed for %T", b.httpClient.Transport) 467 | } 468 | 469 | b.tlsConfig.RootCAs = certPool 470 | transport.TLSClientConfig = b.tlsConfig 471 | return nil 472 | } 473 | 474 | // only refresh the Root CAs if they have changed since the last full update. 475 | if b.tlsConfig.RootCAs == nil { 476 | return setTLSClientConfig() 477 | } else if !b.tlsConfig.RootCAs.Equal(certPool) { 478 | b.Logger().Trace("Root CA certificate pool has changed, updating the client's transport") 479 | return setTLSClientConfig() 480 | } else { 481 | b.Logger().Trace("Root CA certificate pool is unchanged, no update required") 482 | } 483 | 484 | return nil 485 | } 486 | 487 | func validateAliasNameSource(source string) error { 488 | for _, s := range aliasNameSources { 489 | if s == source { 490 | return nil 491 | } 492 | } 493 | return errInvalidAliasNameSource 494 | } 495 | 496 | var backendHelp string = ` 497 | The Kubernetes Auth Backend allows authentication for Kubernetes service accounts. 498 | ` 499 | -------------------------------------------------------------------------------- /path_role.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package kubeauth 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "strings" 10 | "time" 11 | 12 | "github.com/hashicorp/go-secure-stdlib/strutil" 13 | "github.com/hashicorp/go-sockaddr" 14 | "github.com/hashicorp/vault/sdk/framework" 15 | "github.com/hashicorp/vault/sdk/helper/tokenutil" 16 | "github.com/hashicorp/vault/sdk/logical" 17 | ) 18 | 19 | // pathsRole returns the path configurations for the CRUD operations on roles 20 | func pathsRole(b *kubeAuthBackend) []*framework.Path { 21 | p := []*framework.Path{ 22 | { 23 | Pattern: "role/?", 24 | Callbacks: map[logical.Operation]framework.OperationFunc{ 25 | logical.ListOperation: b.pathRoleList, 26 | }, 27 | HelpSynopsis: strings.TrimSpace(roleHelp["role-list"][0]), 28 | HelpDescription: strings.TrimSpace(roleHelp["role-list"][1]), 29 | DisplayAttrs: &framework.DisplayAttributes{ 30 | OperationPrefix: operationPrefixKubernetes, 31 | OperationSuffix: "auth-roles", 32 | Navigation: true, 33 | ItemType: "Role", 34 | }, 35 | }, 36 | { 37 | Pattern: "role/" + framework.GenericNameRegex("name"), 38 | Fields: map[string]*framework.FieldSchema{ 39 | "name": { 40 | Type: framework.TypeString, 41 | Description: "Name of the role.", 42 | }, 43 | "bound_service_account_names": { 44 | Type: framework.TypeCommaStringSlice, 45 | Description: `List of service account names able to access this role. If set to "*" all names 46 | are allowed.`, 47 | }, 48 | "bound_service_account_namespaces": { 49 | Type: framework.TypeCommaStringSlice, 50 | Description: `List of namespaces allowed to access this role. If set to "*" all namespaces 51 | are allowed.`, 52 | }, 53 | "bound_service_account_namespace_selector": { 54 | Type: framework.TypeString, 55 | Description: `A label selector for Kubernetes namespaces which are allowed to access this role. 56 | Accepts either a JSON or YAML object. If set with bound_service_account_namespaces, 57 | the conditions are ORed.`, 58 | }, 59 | "audience": { 60 | Type: framework.TypeString, 61 | Description: "Optional Audience claim to verify in the jwt.", 62 | }, 63 | "alias_name_source": { 64 | Type: framework.TypeString, 65 | Description: fmt.Sprintf(`Source to use when deriving the Alias name. 66 | valid choices: 67 | %q : e.g. 474b11b5-0f20-4f9d-8ca5-65715ab325e0 (most secure choice) 68 | %q : / e.g. vault/vault-agent 69 | default: %q 70 | `, aliasNameSourceSAUid, aliasNameSourceSAName, aliasNameSourceDefault), 71 | Default: aliasNameSourceDefault, 72 | }, 73 | "policies": { 74 | Type: framework.TypeCommaStringSlice, 75 | Description: tokenutil.DeprecationText("token_policies"), 76 | Deprecated: true, 77 | }, 78 | "num_uses": { 79 | Type: framework.TypeInt, 80 | Description: tokenutil.DeprecationText("token_num_uses"), 81 | Deprecated: true, 82 | }, 83 | "ttl": { 84 | Type: framework.TypeDurationSecond, 85 | Description: tokenutil.DeprecationText("token_ttl"), 86 | Deprecated: true, 87 | }, 88 | "max_ttl": { 89 | Type: framework.TypeDurationSecond, 90 | Description: tokenutil.DeprecationText("token_max_ttl"), 91 | Deprecated: true, 92 | }, 93 | "period": { 94 | Type: framework.TypeDurationSecond, 95 | Description: tokenutil.DeprecationText("token_period"), 96 | Deprecated: true, 97 | }, 98 | "bound_cidrs": { 99 | Type: framework.TypeCommaStringSlice, 100 | Description: tokenutil.DeprecationText("token_bound_cidrs"), 101 | Deprecated: true, 102 | }, 103 | }, 104 | ExistenceCheck: b.pathRoleExistenceCheck, 105 | Callbacks: map[logical.Operation]framework.OperationFunc{ 106 | logical.CreateOperation: b.pathRoleCreateUpdate, 107 | logical.UpdateOperation: b.pathRoleCreateUpdate, 108 | logical.ReadOperation: b.pathRoleRead, 109 | logical.DeleteOperation: b.pathRoleDelete, 110 | }, 111 | HelpSynopsis: strings.TrimSpace(roleHelp["role"][0]), 112 | HelpDescription: strings.TrimSpace(roleHelp["role"][1]), 113 | DisplayAttrs: &framework.DisplayAttributes{ 114 | OperationPrefix: operationPrefixKubernetes, 115 | OperationSuffix: "auth-role", 116 | ItemType: "Role", 117 | Action: "Create", 118 | }, 119 | }, 120 | } 121 | 122 | tokenutil.AddTokenFields(p[1].Fields) 123 | return p 124 | } 125 | 126 | // pathRoleExistenceCheck returns whether the role with the given name exists or not. 127 | func (b *kubeAuthBackend) pathRoleExistenceCheck(ctx context.Context, req *logical.Request, data *framework.FieldData) (bool, error) { 128 | b.l.RLock() 129 | defer b.l.RUnlock() 130 | 131 | role, err := b.role(ctx, req.Storage, data.Get("name").(string)) 132 | if err != nil { 133 | return false, err 134 | } 135 | return role != nil, nil 136 | } 137 | 138 | // pathRoleList is used to list all the Roles registered with the backend. 139 | func (b *kubeAuthBackend) pathRoleList(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { 140 | b.l.RLock() 141 | defer b.l.RUnlock() 142 | 143 | roles, err := req.Storage.List(ctx, "role/") 144 | if err != nil { 145 | return nil, err 146 | } 147 | return logical.ListResponse(roles), nil 148 | } 149 | 150 | // pathRoleRead grabs a read lock and reads the options set on the role from the storage 151 | func (b *kubeAuthBackend) pathRoleRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { 152 | roleName := data.Get("name").(string) 153 | if roleName == "" { 154 | return logical.ErrorResponse("missing name"), nil 155 | } 156 | 157 | b.l.RLock() 158 | defer b.l.RUnlock() 159 | 160 | role, err := b.role(ctx, req.Storage, roleName) 161 | if err != nil { 162 | return nil, err 163 | } 164 | if role == nil { 165 | return nil, nil 166 | } 167 | 168 | // Create a map of data to be returned 169 | d := map[string]interface{}{ 170 | "bound_service_account_names": role.ServiceAccountNames, 171 | "bound_service_account_namespaces": role.ServiceAccountNamespaces, 172 | "bound_service_account_namespace_selector": role.ServiceAccountNamespaceSelector, 173 | } 174 | 175 | if role.Audience != "" { 176 | d["audience"] = role.Audience 177 | } 178 | 179 | role.PopulateTokenData(d) 180 | 181 | if len(role.Policies) > 0 { 182 | d["policies"] = d["token_policies"] 183 | } 184 | if len(role.BoundCIDRs) > 0 { 185 | d["bound_cidrs"] = d["token_bound_cidrs"] 186 | } 187 | if role.TTL > 0 { 188 | d["ttl"] = int64(role.TTL.Seconds()) 189 | } 190 | if role.MaxTTL > 0 { 191 | d["max_ttl"] = int64(role.MaxTTL.Seconds()) 192 | } 193 | if role.Period > 0 { 194 | d["period"] = int64(role.Period.Seconds()) 195 | } 196 | if role.NumUses > 0 { 197 | d["num_uses"] = role.NumUses 198 | } 199 | 200 | d["alias_name_source"] = role.AliasNameSource 201 | 202 | return &logical.Response{ 203 | Data: d, 204 | }, nil 205 | } 206 | 207 | // pathRoleDelete removes the role from storage 208 | func (b *kubeAuthBackend) pathRoleDelete(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { 209 | roleName := data.Get("name").(string) 210 | if roleName == "" { 211 | return logical.ErrorResponse("missing role name"), nil 212 | } 213 | 214 | // Acquire the lock before deleting the role. 215 | b.l.Lock() 216 | defer b.l.Unlock() 217 | 218 | // Delete the role itself 219 | if err := req.Storage.Delete(ctx, "role/"+strings.ToLower(roleName)); err != nil { 220 | return nil, err 221 | } 222 | 223 | return nil, nil 224 | } 225 | 226 | // pathRoleCreateUpdate registers a new role with the backend or updates the options 227 | // of an existing role 228 | func (b *kubeAuthBackend) pathRoleCreateUpdate(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { 229 | roleName := data.Get("name").(string) 230 | if roleName == "" { 231 | return logical.ErrorResponse("missing role name"), nil 232 | } 233 | 234 | b.l.Lock() 235 | defer b.l.Unlock() 236 | 237 | // Check if the role already exists 238 | role, err := b.role(ctx, req.Storage, roleName) 239 | if err != nil { 240 | return nil, err 241 | } 242 | 243 | // Create a new entry object if this is a CreateOperation 244 | if role == nil && req.Operation == logical.CreateOperation { 245 | role = &roleStorageEntry{} 246 | } else if role == nil { 247 | return nil, fmt.Errorf("role entry not found during update operation") 248 | } 249 | 250 | if err := role.ParseTokenFields(req, data); err != nil { 251 | return logical.ErrorResponse(err.Error()), logical.ErrInvalidRequest 252 | } 253 | 254 | // Handle upgrade cases 255 | { 256 | if err := tokenutil.UpgradeValue(data, "policies", "token_policies", &role.Policies, &role.TokenPolicies); err != nil { 257 | return logical.ErrorResponse(err.Error()), nil 258 | } 259 | 260 | if err := tokenutil.UpgradeValue(data, "bound_cidrs", "token_bound_cidrs", &role.BoundCIDRs, &role.TokenBoundCIDRs); err != nil { 261 | return logical.ErrorResponse(err.Error()), nil 262 | } 263 | 264 | if err := tokenutil.UpgradeValue(data, "num_uses", "token_num_uses", &role.NumUses, &role.TokenNumUses); err != nil { 265 | return logical.ErrorResponse(err.Error()), nil 266 | } 267 | 268 | if err := tokenutil.UpgradeValue(data, "ttl", "token_ttl", &role.TTL, &role.TokenTTL); err != nil { 269 | return logical.ErrorResponse(err.Error()), nil 270 | } 271 | 272 | if err := tokenutil.UpgradeValue(data, "max_ttl", "token_max_ttl", &role.MaxTTL, &role.TokenMaxTTL); err != nil { 273 | return logical.ErrorResponse(err.Error()), nil 274 | } 275 | 276 | if err := tokenutil.UpgradeValue(data, "period", "token_period", &role.Period, &role.TokenPeriod); err != nil { 277 | return logical.ErrorResponse(err.Error()), nil 278 | } 279 | } 280 | 281 | if role.TokenPeriod > b.System().MaxLeaseTTL() { 282 | return logical.ErrorResponse(fmt.Sprintf("token period of '%q' is greater than the backend's maximum lease TTL of '%q'", role.TokenPeriod.String(), b.System().MaxLeaseTTL().String())), nil 283 | } 284 | 285 | // Check that the TTL value provided is less than the MaxTTL. 286 | // Sanitizing the TTL and MaxTTL is not required now and can be performed 287 | // at credential issue time. 288 | if role.TokenMaxTTL > time.Duration(0) && role.TokenTTL > role.TokenMaxTTL { 289 | return logical.ErrorResponse("token ttl should not be greater than token max ttl"), nil 290 | } 291 | 292 | var resp *logical.Response 293 | 294 | // Warn if max_ttl is greater than system or backend mount's maximum TTL 295 | if role.TokenMaxTTL > b.System().MaxLeaseTTL() { 296 | if resp == nil { 297 | resp = &logical.Response{} 298 | } 299 | 300 | resp.AddWarning("max_ttl is greater than the system or backend mount's maximum TTL value; issued tokens' max TTL value will be truncated") 301 | } 302 | 303 | if serviceAccountUUIDs, ok := data.GetOk("bound_service_account_names"); ok { 304 | role.ServiceAccountNames = serviceAccountUUIDs.([]string) 305 | } else if req.Operation == logical.CreateOperation { 306 | role.ServiceAccountNames = data.Get("bound_service_account_names").([]string) 307 | } 308 | // Verify names was not empty 309 | if len(role.ServiceAccountNames) == 0 { 310 | return logical.ErrorResponse("%q can not be empty", "bound_service_account_names"), nil 311 | } 312 | // Verify * was not set with other data 313 | if len(role.ServiceAccountNames) > 1 && strutil.StrListContains(role.ServiceAccountNames, "*") { 314 | return logical.ErrorResponse("can not mix %q with values", "*"), nil 315 | } 316 | 317 | if namespaces, ok := data.GetOk("bound_service_account_namespaces"); ok { 318 | role.ServiceAccountNamespaces = namespaces.([]string) 319 | } 320 | 321 | role.ServiceAccountNamespaceSelector = data.Get("bound_service_account_namespace_selector").(string) 322 | 323 | // Verify namespaces is not empty unless selector is set 324 | saNamespaceLen := len(role.ServiceAccountNamespaces) 325 | if saNamespaceLen == 0 && role.ServiceAccountNamespaceSelector == "" { 326 | return logical.ErrorResponse("%q can not be empty if %q is not set", 327 | "bound_service_account_namespaces", "bound_service_account_namespace_selector"), nil 328 | } 329 | 330 | // Verify namespace selector is correct 331 | if role.ServiceAccountNamespaceSelector != "" { 332 | if _, err := makeNsLabelSelector(role.ServiceAccountNamespaceSelector); err != nil { 333 | return logical.ErrorResponse("invalid %q configured", "bound_service_account_namespace_selector"), nil 334 | } 335 | } 336 | 337 | // Verify * was not set with other data 338 | if saNamespaceLen > 1 && strutil.StrListContains(role.ServiceAccountNamespaces, "*") { 339 | return logical.ErrorResponse("can not mix %q with values", "*"), nil 340 | } 341 | 342 | if audience, ok := data.GetOk("audience"); ok { 343 | role.Audience = audience.(string) 344 | } 345 | 346 | // Warn if audience is not set 347 | if strings.TrimSpace(role.Audience) == "" { 348 | if resp == nil { 349 | resp = &logical.Response{} 350 | } 351 | 352 | b.Logger().Debug("This role does not have an audience configured. While audiences are not required, consider specifying one if your use case would benefit from additional JWT claim verification.", "role_name", roleName) 353 | resp.AddWarning(fmt.Sprintf("Role %s does not have an audience configured. While audiences are not required, consider specifying one if your use case would benefit from additional JWT claim verification.", roleName)) 354 | } 355 | 356 | if source, ok := data.GetOk("alias_name_source"); ok { 357 | // migrate the role.AliasNameSource to be the default 358 | // if both it and the field value are unset 359 | if role.AliasNameSource == aliasNameSourceUnset && source.(string) == aliasNameSourceUnset { 360 | role.AliasNameSource = data.GetDefaultOrZero("alias_name_source").(string) 361 | } else { 362 | role.AliasNameSource = source.(string) 363 | } 364 | } else if role.AliasNameSource == aliasNameSourceUnset { 365 | role.AliasNameSource = data.Get("alias_name_source").(string) 366 | } 367 | 368 | if err := validateAliasNameSource(role.AliasNameSource); err != nil { 369 | return logical.ErrorResponse(err.Error()), nil 370 | } 371 | 372 | // Store the entry. 373 | entry, err := logical.StorageEntryJSON("role/"+strings.ToLower(roleName), role) 374 | if err != nil { 375 | return nil, err 376 | } 377 | if entry == nil { 378 | return nil, fmt.Errorf("failed to create storage entry for role %s", roleName) 379 | } 380 | if err = req.Storage.Put(ctx, entry); err != nil { 381 | return nil, err 382 | } 383 | 384 | return resp, nil 385 | } 386 | 387 | // roleStorageEntry stores all the options that are set on an role 388 | type roleStorageEntry struct { 389 | tokenutil.TokenParams 390 | 391 | // ServiceAccountNames is the array of service accounts able to 392 | // access this role. 393 | ServiceAccountNames []string `json:"bound_service_account_names" mapstructure:"bound_service_account_names" structs:"bound_service_account_names"` 394 | 395 | // ServiceAccountNamespaces is the array of namespaces able to access this 396 | // role. 397 | ServiceAccountNamespaces []string `json:"bound_service_account_namespaces" mapstructure:"bound_service_account_namespaces" structs:"bound_service_account_namespaces"` 398 | 399 | // ServiceAccountNamespaceSelector is the label selector string of the 400 | // namespaces able to access this role. 401 | ServiceAccountNamespaceSelector string `json:"bound_service_account_namespace_selector" mapstructure:"bound_service_account_namespace_selector" structs:"bound_service_account_namespace_selector"` 402 | 403 | // Audience is an optional jwt claim to verify 404 | Audience string `json:"audience" mapstructure:"audience" structs:"audience"` 405 | 406 | // AliasNameSource used when deriving the Alias' name. 407 | AliasNameSource string `json:"alias_name_source" mapstructure:"alias_name_source" structs:"alias_name_source"` 408 | 409 | // Deprecated by TokenParams 410 | Policies []string `json:"policies" structs:"policies" mapstructure:"policies"` 411 | NumUses int `json:"num_uses" mapstructure:"num_uses" structs:"num_uses"` 412 | TTL time.Duration `json:"ttl" structs:"ttl" mapstructure:"ttl"` 413 | MaxTTL time.Duration `json:"max_ttl" structs:"max_ttl" mapstructure:"max_ttl"` 414 | Period time.Duration `json:"period" mapstructure:"period" structs:"period"` 415 | BoundCIDRs []*sockaddr.SockAddrMarshaler 416 | } 417 | 418 | var roleHelp = map[string][2]string{ 419 | "role-list": { 420 | "Lists all the roles registered with the backend.", 421 | "The list will contain the names of the roles.", 422 | }, 423 | "role": { 424 | "Register an role with the backend.", 425 | `A role is required to authenticate with this backend. The role binds 426 | kubernetes service account metadata with token policies and settings. 427 | The bindings, token polices and token settings can all be configured 428 | using this endpoint`, 429 | }, 430 | } 431 | -------------------------------------------------------------------------------- /integrationtest/integration_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package integrationtest 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "net/http" 10 | "os" 11 | "strings" 12 | "testing" 13 | 14 | "github.com/hashicorp/vault-plugin-auth-kubernetes/integrationtest/k8s" 15 | "github.com/hashicorp/vault/api" 16 | authenticationv1 "k8s.io/api/authentication/v1" 17 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 18 | ) 19 | 20 | const ( 21 | matchLabelsKeyValue = `{ 22 | "matchLabels": { 23 | "target": "integration-test" 24 | } 25 | }` 26 | mismatchLabelsKeyValue = `{ 27 | "matchLabels": { 28 | "target": "not-integration-test" 29 | } 30 | }` 31 | ) 32 | 33 | // Set the environment variable INTEGRATION_TESTS to any non-empty value to run 34 | // the tests in this package. The test assumes it has available: 35 | // - A Kubernetes cluster in which: 36 | // - it can use the `test` namespace 37 | // - Vault is deployed and accessible 38 | // - There is a serviceaccount called test-token-reviewer-account with access to the TokenReview API 39 | // 40 | // See `make setup-integration-test` for manual testing. 41 | func TestMain(m *testing.M) { 42 | if os.Getenv("INTEGRATION_TESTS") != "" { 43 | os.Exit(run(m)) 44 | } 45 | } 46 | 47 | func run(m *testing.M) int { 48 | localPort, close, err := k8s.SetupPortForwarding(os.Getenv("KUBE_CONTEXT"), "test", "vault-0") 49 | if err != nil { 50 | fmt.Println(err) 51 | return 1 52 | } 53 | defer close() 54 | 55 | os.Setenv("VAULT_ADDR", fmt.Sprintf("http://127.0.0.1:%d", localPort)) 56 | os.Setenv("VAULT_TOKEN", "root") 57 | 58 | return m.Run() 59 | } 60 | 61 | func createToken(t *testing.T, sa string, audiences []string) string { 62 | t.Helper() 63 | 64 | k8sClient, err := k8s.ClientFromKubeConfig(os.Getenv("KUBE_CONTEXT")) 65 | if err != nil { 66 | t.Fatal(err) 67 | } 68 | 69 | resp, err := k8sClient.CoreV1().ServiceAccounts("test").CreateToken(context.Background(), sa, &authenticationv1.TokenRequest{ 70 | Spec: authenticationv1.TokenRequestSpec{ 71 | Audiences: audiences, 72 | }, 73 | }, metav1.CreateOptions{}) 74 | if err != nil { 75 | t.Fatal(err) 76 | } 77 | 78 | return resp.Status.Token 79 | } 80 | 81 | func annotateServiceAccount(t *testing.T, name string, annotations map[string]string) { 82 | t.Helper() 83 | 84 | k8sClient, err := k8s.ClientFromKubeConfig(os.Getenv("KUBE_CONTEXT")) 85 | if err != nil { 86 | t.Fatal(err) 87 | } 88 | 89 | sa, err := k8sClient.CoreV1().ServiceAccounts("test").Get(context.Background(), name, metav1.GetOptions{}) 90 | if err != nil { 91 | t.Fatal(err) 92 | } 93 | 94 | for k, v := range annotations { 95 | sa.Annotations[k] = v 96 | } 97 | 98 | sa, err = k8sClient.CoreV1().ServiceAccounts("test").Update(context.Background(), sa, metav1.UpdateOptions{}) 99 | if err != nil { 100 | t.Fatal(err) 101 | } 102 | } 103 | 104 | func createPolicy(t *testing.T, name, policy string) { 105 | t.Helper() 106 | // Pick up VAULT_ADDR and VAULT_TOKEN from env vars 107 | client, err := api.NewClient(nil) 108 | if err != nil { 109 | t.Fatal(err) 110 | } 111 | 112 | _, err = client.Logical().Write(fmt.Sprintf("/sys/policy/%s", name), map[string]interface{}{ 113 | "policy": policy, 114 | }) 115 | if err != nil { 116 | t.Fatal(err) 117 | } 118 | 119 | t.Cleanup(func() { 120 | _, err = client.Logical().Delete(fmt.Sprintf("/sys/policy/%s", name)) 121 | if err != nil { 122 | t.Fatal(err) 123 | } 124 | }) 125 | } 126 | 127 | func setupKubernetesAuth(t *testing.T, mountConfigOverride map[string]interface{}) *api.Client { 128 | t.Helper() 129 | // Pick up VAULT_ADDR and VAULT_TOKEN from env vars 130 | client, err := api.NewClient(nil) 131 | if err != nil { 132 | t.Fatal(err) 133 | } 134 | 135 | _, err = client.Logical().Write("sys/auth/kubernetes", map[string]interface{}{ 136 | "type": "kubernetes-dev", 137 | }) 138 | if err != nil { 139 | t.Fatal(err) 140 | } 141 | 142 | t.Cleanup(func() { 143 | _, err = client.Logical().Delete("sys/auth/kubernetes") 144 | if err != nil { 145 | t.Fatal(err) 146 | } 147 | }) 148 | 149 | mountConfig := map[string]interface{}{ 150 | "kubernetes_host": "https://kubernetes.default.svc.cluster.local", 151 | } 152 | if len(mountConfigOverride) != 0 { 153 | mountConfig = mountConfigOverride 154 | } 155 | 156 | _, err = client.Logical().Write("auth/kubernetes/config", mountConfig) 157 | if err != nil { 158 | t.Fatal(err) 159 | } 160 | 161 | return client 162 | } 163 | 164 | func setupKubernetesAuthRole(t *testing.T, client *api.Client, boundServiceAccountName string, roleConfigOverride map[string]interface{}) { 165 | t.Helper() 166 | 167 | roleConfig := map[string]interface{}{ 168 | "bound_service_account_names": boundServiceAccountName, 169 | "bound_service_account_namespaces": "test", 170 | } 171 | if len(roleConfigOverride) != 0 { 172 | roleConfig = roleConfigOverride 173 | } 174 | 175 | _, err := client.Logical().Write("auth/kubernetes/role/test-role", roleConfig) 176 | if err != nil { 177 | t.Fatal(err) 178 | } 179 | } 180 | 181 | func setupKVV1Mount(t *testing.T, client *api.Client, path string) { 182 | _, err := client.Logical().Write(fmt.Sprintf("/sys/mounts/%s", path), map[string]interface{}{ 183 | "type": "kv", 184 | }) 185 | if err != nil { 186 | t.Fatalf("Expected to enable kv v1 secrets engine but got: %v", err) 187 | } 188 | 189 | t.Cleanup(func() { 190 | _, err = client.Logical().Delete(fmt.Sprintf("/sys/mounts/%s", path)) 191 | if err != nil { 192 | t.Fatalf("Expected successful kv v1 secrets engine mount delete but got: %v", err) 193 | } 194 | }) 195 | } 196 | 197 | func TestSuccess(t *testing.T) { 198 | client := setupKubernetesAuth(t, nil) 199 | 200 | setupKubernetesAuthRole(t, client, "vault", nil) 201 | 202 | _, err := client.Logical().Write("auth/kubernetes/login", map[string]interface{}{ 203 | "role": "test-role", 204 | "jwt": createToken(t, "vault", nil), 205 | }) 206 | if err != nil { 207 | t.Fatalf("Expected successful login but got: %v", err) 208 | } 209 | } 210 | 211 | func TestSuccessWithTokenReviewerJwt(t *testing.T) { 212 | client := setupKubernetesAuth(t, map[string]interface{}{ 213 | "kubernetes_host": "https://kubernetes.default.svc.cluster.local", 214 | "token_reviewer_jwt": createToken(t, "test-token-reviewer-account", nil), 215 | }) 216 | 217 | setupKubernetesAuthRole(t, client, "vault", nil) 218 | 219 | _, err := client.Logical().Write("auth/kubernetes/login", map[string]interface{}{ 220 | "role": "test-role", 221 | "jwt": createToken(t, "vault", nil), 222 | }) 223 | if err != nil { 224 | t.Fatalf("Expected successful login but got: %v", err) 225 | } 226 | } 227 | 228 | func TestSuccessWithNamespaceLabels(t *testing.T) { 229 | client := setupKubernetesAuth(t, nil) 230 | 231 | roleConfigOverride := map[string]interface{}{ 232 | "bound_service_account_names": "vault", 233 | "bound_service_account_namespace_selector": matchLabelsKeyValue, 234 | } 235 | setupKubernetesAuthRole(t, client, "vault", roleConfigOverride) 236 | 237 | _, err := client.Logical().Write("auth/kubernetes/login", map[string]interface{}{ 238 | "role": "test-role", 239 | "jwt": createToken(t, "vault", nil), 240 | }) 241 | if err != nil { 242 | t.Fatalf("Expected successful login but got: %v", err) 243 | } 244 | } 245 | 246 | func TestFailWithMismatchNamespaceLabels(t *testing.T) { 247 | client := setupKubernetesAuth(t, nil) 248 | 249 | roleConfigOverride := map[string]interface{}{ 250 | "bound_service_account_names": "vault", 251 | "bound_service_account_namespace_selector": mismatchLabelsKeyValue, 252 | } 253 | setupKubernetesAuthRole(t, client, "vault", roleConfigOverride) 254 | 255 | _, err := client.Logical().Write("auth/kubernetes/login", map[string]interface{}{ 256 | "role": "test-role", 257 | "jwt": createToken(t, "vault", nil), 258 | }) 259 | respErr, ok := err.(*api.ResponseError) 260 | if !ok { 261 | t.Fatalf("Expected api.ResponseError but was: %T", err) 262 | } 263 | if respErr.StatusCode != http.StatusForbidden { 264 | t.Fatalf("Expected 403 but was %d: %s", respErr.StatusCode, respErr.Error()) 265 | } 266 | } 267 | 268 | func TestFailWithBadTokenReviewerJwt(t *testing.T) { 269 | client := setupKubernetesAuth(t, map[string]interface{}{ 270 | "kubernetes_host": "https://kubernetes.default.svc.cluster.local", 271 | "token_reviewer_jwt": badTokenReviewerJwt, 272 | }) 273 | 274 | setupKubernetesAuthRole(t, client, "vault", nil) 275 | 276 | _, err := client.Logical().Write("auth/kubernetes/login", map[string]interface{}{ 277 | "role": "test-role", 278 | "jwt": createToken(t, "vault", nil), 279 | }) 280 | respErr, ok := err.(*api.ResponseError) 281 | if !ok { 282 | t.Fatalf("Expected api.ResponseError but was: %T", err) 283 | } 284 | if respErr.StatusCode != http.StatusForbidden { 285 | t.Fatalf("Expected 403 but was %d: %s", respErr.StatusCode, respErr.Error()) 286 | } 287 | } 288 | 289 | func TestSuccessWithAuthAliasMetadataAssignment(t *testing.T) { 290 | // annotate the service account 291 | expMetadata := map[string]string{ 292 | "key-1": "foo", 293 | "key-2": "bar", 294 | } 295 | 296 | const annotationPrefix = "vault.hashicorp.com/alias-metadata-" 297 | annotations := map[string]string{} 298 | for k, v := range expMetadata { 299 | annotations[annotationPrefix+k] = v 300 | } 301 | annotateServiceAccount(t, "vault", annotations) 302 | 303 | client := setupKubernetesAuth(t, map[string]interface{}{ 304 | "kubernetes_host": "https://kubernetes.default.svc.cluster.local", 305 | "use_annotations_as_alias_metadata": true, 306 | }) 307 | 308 | // create policy 309 | secret, err := client.Logical().Read("sys/auth/kubernetes") 310 | if err != nil { 311 | t.Fatalf("Expected successful auth configuration GET but got: %v", err) 312 | } 313 | 314 | mountAccessor, ok := secret.Data["accessor"] 315 | if !ok { 316 | t.Fatal("Expected auth configuration GET response to have \"accessor\"") 317 | } 318 | 319 | const kvPath = "kv-v1" 320 | setupKVV1Mount(t, client, kvPath) 321 | 322 | const policyNameFoo = "alias-metadata-foo" 323 | policy := fmt.Sprintf(` 324 | path "%s/{{identity.entity.aliases.%s.metadata.key-1}}" { 325 | capabilities = [ "read", "update", "create" ] 326 | }`, kvPath, mountAccessor) 327 | createPolicy(t, policyNameFoo, policy) 328 | 329 | // config kubernetes auth role and login 330 | roleConfigOverride := map[string]interface{}{ 331 | "bound_service_account_names": "vault", 332 | "bound_service_account_namespaces": "test", 333 | "policies": []string{"default", policyNameFoo}, 334 | } 335 | setupKubernetesAuthRole(t, client, "vault", roleConfigOverride) 336 | 337 | loginSecret, err := client.Logical().Write("auth/kubernetes/login", map[string]interface{}{ 338 | "role": "test-role", 339 | "jwt": createToken(t, "vault", nil), 340 | }) 341 | if err != nil { 342 | t.Fatalf("Expected successful login but got: %v", err) 343 | } 344 | 345 | // verify that the templated policy works by creating key value pairs at kv-v1/data/foo with the kubernetes auth token 346 | token, err := loginSecret.TokenID() 347 | if err != nil { 348 | t.Fatalf("Expected successful token ID read but got: %v", err) 349 | } 350 | 351 | kvClient, err := api.NewClient(nil) 352 | if err != nil { 353 | t.Fatal(err) 354 | } 355 | kvClient.SetToken(token) 356 | if err != nil { 357 | t.Fatal(err) 358 | } 359 | 360 | err = kvClient.KVv1(kvPath).Put(context.Background(), "foo", 361 | map[string]interface{}{ 362 | "apiKey": "abc123", 363 | }) 364 | if err != nil { 365 | t.Fatalf("Expected successful KVV1 PUT but got: %v", err) 366 | } 367 | } 368 | 369 | func TestFailWithAuthAliasMetadataAssignmentOnReservedKeys(t *testing.T) { 370 | // annotate the service account with disallowed keys 371 | expMetadata := map[string]string{ 372 | "service_account_secret_name": "foo", 373 | "other-key": "bar", 374 | } 375 | 376 | const annotationPrefix = "vault.hashicorp.com/alias-metadata-" 377 | annotations := map[string]string{} 378 | for k, v := range expMetadata { 379 | annotations[annotationPrefix+k] = v 380 | } 381 | annotateServiceAccount(t, "vault", annotations) 382 | 383 | client := setupKubernetesAuth(t, map[string]interface{}{ 384 | "kubernetes_host": "https://kubernetes.default.svc.cluster.local", 385 | "use_annotations_as_alias_metadata": true, 386 | }) 387 | 388 | // config kubernetes auth role and login 389 | setupKubernetesAuthRole(t, client, "vault", nil) 390 | 391 | _, err := client.Logical().Write("auth/kubernetes/login", map[string]interface{}{ 392 | "role": "test-role", 393 | "jwt": createToken(t, "vault", nil), 394 | }) 395 | 396 | if err == nil { 397 | t.Fatalf("Expected failed login but got nil err") 398 | } 399 | 400 | respErr, ok := err.(*api.ResponseError) 401 | if !ok { 402 | t.Fatalf("Expected api.ResponseError but was: %T", err) 403 | } 404 | if respErr.StatusCode != http.StatusBadRequest { 405 | t.Fatalf("Expected 400 but was %d: %s", respErr.StatusCode, respErr.Error()) 406 | } 407 | 408 | errMsgAliasMetadataReservedKeysFound := "entity alias metadata keys for only internal use found from the client" + 409 | " token's associated service account annotations" 410 | if !strings.Contains(respErr.Error(), errMsgAliasMetadataReservedKeysFound) { 411 | t.Fatalf("Expected failed err to contain %s but got err %s", errMsgAliasMetadataReservedKeysFound, 412 | respErr.Error()) 413 | } 414 | } 415 | 416 | func TestUnauthorizedServiceAccountErrorCode(t *testing.T) { 417 | client := setupKubernetesAuth(t, nil) 418 | 419 | setupKubernetesAuthRole(t, client, "badServiceAccount", nil) 420 | 421 | _, err := client.Logical().Write("auth/kubernetes/login", map[string]interface{}{ 422 | "role": "test-role", 423 | "jwt": createToken(t, "vault", nil), 424 | }) 425 | respErr, ok := err.(*api.ResponseError) 426 | if !ok { 427 | t.Fatalf("Expected api.ResponseError but was: %T", err) 428 | } 429 | if respErr.StatusCode != http.StatusForbidden { 430 | t.Fatalf("Expected 403 but was %d: %s", respErr.StatusCode, respErr.Error()) 431 | } 432 | } 433 | 434 | const badTokenReviewerJwt = "eyJhbGciOiJSUzI1NiIsImtpZCI6IkZza1ViNWREek8tQ05uaVk3TU5mRWZ2dEx5bzFuU0tsV3JhUU5nekhVQ28ifQ.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiXSwiZXhwIjoxNjgwODg5NjQ4LCJpYXQiOjE2NDkzNTM2NDgsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJ0ZXN0IiwicG9kIjp7Im5hbWUiOiJ2YXVsdC0wIiwidWlkIjoiYTQwNGZiMTktNWQ4MC00OTBlLTkwYjktMGJjNWE3NzA5ODdkIn0sInNlcnZpY2VhY2NvdW50Ijp7Im5hbWUiOiJ2YXVsdCIsInVpZCI6ImI2ZTM2ZDMxLTA2MDQtNDE5MS04Y2JjLTAwYzg4ZWViZDlmOSJ9LCJ3YXJuYWZ0ZXIiOjE2NDkzNTcyNTV9LCJuYmYiOjE2NDkzNTM2NDgsInN1YiI6InN5c3RlbTpzZXJ2aWNlYWNjb3VudDp0ZXN0OnZhdWx0In0.hxzMpKx38rKvaWUBNEg49TioRXt_JT1Z5st4A9NeBWO2xiC8hCDgVJRWqPzejz-sYoQGhZyZcrTa0cbNRIevcR7XH4DnHd27OOzSoj198I2DAdLfw_pntzOjq35-tZhxSYXsfKH69DSpHACpu5HHUAf1aiY3B6cq5Z3gXbtaoHBocfNwvtOirGL8pTYXo1kNCkcahDPfpf3faztyUQ77v0viBKIAqwxDuGks4crqIG5jT_tOnXbb7PahwtE5cS3bMLjQb1j5oEcgq6HF4NMV46Ly479QRoXtYWWsI9OSwl4H7G9Rel3fr9q4IMdCCI5A-FLxL2Fpep9TDwrNQ3mhBQ" 435 | 436 | func TestAudienceValidation(t *testing.T) { 437 | jwtWithDefaultAud := createToken(t, "vault", nil) 438 | jwtWithAudA := createToken(t, "vault", []string{"a"}) 439 | jwtWithAudB := createToken(t, "vault", []string{"b"}) 440 | 441 | for name, tc := range map[string]struct { 442 | audienceConfig string 443 | jwt string 444 | expectSuccess bool 445 | }{ 446 | "config: default, JWT: default": {"https://kubernetes.default.svc.cluster.local", jwtWithDefaultAud, true}, 447 | "config: default, JWT: a": {"https://kubernetes.default.svc.cluster.local", jwtWithAudA, false}, 448 | "config: a, JWT: a": {"a", jwtWithAudA, true}, 449 | "config: a, JWT: b": {"a", jwtWithAudB, false}, 450 | "config: unset, JWT: default": {"", jwtWithDefaultAud, true}, 451 | "config: unset, JWT: a": {"", jwtWithAudA, true}, 452 | } { 453 | t.Run(name, func(t *testing.T) { 454 | roleConfig := map[string]interface{}{ 455 | "bound_service_account_names": "vault", 456 | "bound_service_account_namespaces": "test", 457 | } 458 | if tc.audienceConfig != "" { 459 | roleConfig["audience"] = tc.audienceConfig 460 | } 461 | client := setupKubernetesAuth(t, nil) 462 | 463 | setupKubernetesAuthRole(t, client, "vault", roleConfig) 464 | 465 | login := func(jwt string) error { 466 | _, err := client.Logical().Write("auth/kubernetes/login", map[string]interface{}{ 467 | "role": "test-role", 468 | "jwt": jwt, 469 | }) 470 | return err 471 | } 472 | 473 | err := login(tc.jwt) 474 | if err != nil { 475 | if tc.expectSuccess { 476 | t.Fatal("Expected successful login", err) 477 | } else { 478 | respErr, ok := err.(*api.ResponseError) 479 | if !ok { 480 | t.Fatalf("Expected api.ResponseError but was: %T", err) 481 | } 482 | if respErr.StatusCode != http.StatusForbidden { 483 | t.Fatalf("Expected 403 but was %d: %s", respErr.StatusCode, respErr.Error()) 484 | } 485 | } 486 | } else if !tc.expectSuccess { 487 | t.Fatal("Expected error but successfully logged in") 488 | } 489 | }) 490 | } 491 | } 492 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 HashiCorp, Inc. 2 | 3 | Mozilla Public License, version 2.0 4 | 5 | 1. Definitions 6 | 7 | 1.1. "Contributor" 8 | 9 | means each individual or legal entity that creates, contributes to the 10 | creation of, or owns Covered Software. 11 | 12 | 1.2. "Contributor Version" 13 | 14 | means the combination of the Contributions of others (if any) used by a 15 | Contributor and that particular Contributor's Contribution. 16 | 17 | 1.3. "Contribution" 18 | 19 | means Covered Software of a particular Contributor. 20 | 21 | 1.4. "Covered Software" 22 | 23 | means Source Code Form to which the initial Contributor has attached the 24 | notice in Exhibit A, the Executable Form of such Source Code Form, and 25 | Modifications of such Source Code Form, in each case including portions 26 | thereof. 27 | 28 | 1.5. "Incompatible With Secondary Licenses" 29 | means 30 | 31 | a. that the initial Contributor has attached the notice described in 32 | Exhibit B to the Covered Software; or 33 | 34 | b. that the Covered Software was made available under the terms of 35 | version 1.1 or earlier of the License, but not also under the terms of 36 | a Secondary License. 37 | 38 | 1.6. "Executable Form" 39 | 40 | means any form of the work other than Source Code Form. 41 | 42 | 1.7. "Larger Work" 43 | 44 | means a work that combines Covered Software with other material, in a 45 | separate file or files, that is not Covered Software. 46 | 47 | 1.8. "License" 48 | 49 | means this document. 50 | 51 | 1.9. "Licensable" 52 | 53 | means having the right to grant, to the maximum extent possible, whether 54 | at the time of the initial grant or subsequently, any and all of the 55 | rights conveyed by this License. 56 | 57 | 1.10. "Modifications" 58 | 59 | means any of the following: 60 | 61 | a. any file in Source Code Form that results from an addition to, 62 | deletion from, or modification of the contents of Covered Software; or 63 | 64 | b. any new file in Source Code Form that contains any Covered Software. 65 | 66 | 1.11. "Patent Claims" of a Contributor 67 | 68 | means any patent claim(s), including without limitation, method, 69 | process, and apparatus claims, in any patent Licensable by such 70 | Contributor that would be infringed, but for the grant of the License, 71 | by the making, using, selling, offering for sale, having made, import, 72 | or transfer of either its Contributions or its Contributor Version. 73 | 74 | 1.12. "Secondary License" 75 | 76 | means either the GNU General Public License, Version 2.0, the GNU Lesser 77 | General Public License, Version 2.1, the GNU Affero General Public 78 | License, Version 3.0, or any later versions of those licenses. 79 | 80 | 1.13. "Source Code Form" 81 | 82 | means the form of the work preferred for making modifications. 83 | 84 | 1.14. "You" (or "Your") 85 | 86 | means an individual or a legal entity exercising rights under this 87 | License. For legal entities, "You" includes any entity that controls, is 88 | controlled by, or is under common control with You. For purposes of this 89 | definition, "control" means (a) the power, direct or indirect, to cause 90 | the direction or management of such entity, whether by contract or 91 | otherwise, or (b) ownership of more than fifty percent (50%) of the 92 | outstanding shares or beneficial ownership of such entity. 93 | 94 | 95 | 2. License Grants and Conditions 96 | 97 | 2.1. Grants 98 | 99 | Each Contributor hereby grants You a world-wide, royalty-free, 100 | non-exclusive license: 101 | 102 | a. under intellectual property rights (other than patent or trademark) 103 | Licensable by such Contributor to use, reproduce, make available, 104 | modify, display, perform, distribute, and otherwise exploit its 105 | Contributions, either on an unmodified basis, with Modifications, or 106 | as part of a Larger Work; and 107 | 108 | b. under Patent Claims of such Contributor to make, use, sell, offer for 109 | sale, have made, import, and otherwise transfer either its 110 | Contributions or its Contributor Version. 111 | 112 | 2.2. Effective Date 113 | 114 | The licenses granted in Section 2.1 with respect to any Contribution 115 | become effective for each Contribution on the date the Contributor first 116 | distributes such Contribution. 117 | 118 | 2.3. Limitations on Grant Scope 119 | 120 | The licenses granted in this Section 2 are the only rights granted under 121 | this License. No additional rights or licenses will be implied from the 122 | distribution or licensing of Covered Software under this License. 123 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 124 | Contributor: 125 | 126 | a. for any code that a Contributor has removed from Covered Software; or 127 | 128 | b. for infringements caused by: (i) Your and any other third party's 129 | modifications of Covered Software, or (ii) the combination of its 130 | Contributions with other software (except as part of its Contributor 131 | Version); or 132 | 133 | c. under Patent Claims infringed by Covered Software in the absence of 134 | its Contributions. 135 | 136 | This License does not grant any rights in the trademarks, service marks, 137 | or logos of any Contributor (except as may be necessary to comply with 138 | the notice requirements in Section 3.4). 139 | 140 | 2.4. Subsequent Licenses 141 | 142 | No Contributor makes additional grants as a result of Your choice to 143 | distribute the Covered Software under a subsequent version of this 144 | License (see Section 10.2) or under the terms of a Secondary License (if 145 | permitted under the terms of Section 3.3). 146 | 147 | 2.5. Representation 148 | 149 | Each Contributor represents that the Contributor believes its 150 | Contributions are its original creation(s) or it has sufficient rights to 151 | grant the rights to its Contributions conveyed by this License. 152 | 153 | 2.6. Fair Use 154 | 155 | This License is not intended to limit any rights You have under 156 | applicable copyright doctrines of fair use, fair dealing, or other 157 | equivalents. 158 | 159 | 2.7. Conditions 160 | 161 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in 162 | Section 2.1. 163 | 164 | 165 | 3. Responsibilities 166 | 167 | 3.1. Distribution of Source Form 168 | 169 | All distribution of Covered Software in Source Code Form, including any 170 | Modifications that You create or to which You contribute, must be under 171 | the terms of this License. You must inform recipients that the Source 172 | Code Form of the Covered Software is governed by the terms of this 173 | License, and how they can obtain a copy of this License. You may not 174 | attempt to alter or restrict the recipients' rights in the Source Code 175 | Form. 176 | 177 | 3.2. Distribution of Executable Form 178 | 179 | If You distribute Covered Software in Executable Form then: 180 | 181 | a. such Covered Software must also be made available in Source Code Form, 182 | as described in Section 3.1, and You must inform recipients of the 183 | Executable Form how they can obtain a copy of such Source Code Form by 184 | reasonable means in a timely manner, at a charge no more than the cost 185 | of distribution to the recipient; and 186 | 187 | b. You may distribute such Executable Form under the terms of this 188 | License, or sublicense it under different terms, provided that the 189 | license for the Executable Form does not attempt to limit or alter the 190 | recipients' rights in the Source Code Form under this License. 191 | 192 | 3.3. Distribution of a Larger Work 193 | 194 | You may create and distribute a Larger Work under terms of Your choice, 195 | provided that You also comply with the requirements of this License for 196 | the Covered Software. If the Larger Work is a combination of Covered 197 | Software with a work governed by one or more Secondary Licenses, and the 198 | Covered Software is not Incompatible With Secondary Licenses, this 199 | License permits You to additionally distribute such Covered Software 200 | under the terms of such Secondary License(s), so that the recipient of 201 | the Larger Work may, at their option, further distribute the Covered 202 | Software under the terms of either this License or such Secondary 203 | License(s). 204 | 205 | 3.4. Notices 206 | 207 | You may not remove or alter the substance of any license notices 208 | (including copyright notices, patent notices, disclaimers of warranty, or 209 | limitations of liability) contained within the Source Code Form of the 210 | Covered Software, except that You may alter any license notices to the 211 | extent required to remedy known factual inaccuracies. 212 | 213 | 3.5. Application of Additional Terms 214 | 215 | You may choose to offer, and to charge a fee for, warranty, support, 216 | indemnity or liability obligations to one or more recipients of Covered 217 | Software. However, You may do so only on Your own behalf, and not on 218 | behalf of any Contributor. You must make it absolutely clear that any 219 | such warranty, support, indemnity, or liability obligation is offered by 220 | You alone, and You hereby agree to indemnify every Contributor for any 221 | liability incurred by such Contributor as a result of warranty, support, 222 | indemnity or liability terms You offer. You may include additional 223 | disclaimers of warranty and limitations of liability specific to any 224 | jurisdiction. 225 | 226 | 4. Inability to Comply Due to Statute or Regulation 227 | 228 | If it is impossible for You to comply with any of the terms of this License 229 | with respect to some or all of the Covered Software due to statute, 230 | judicial order, or regulation then You must: (a) comply with the terms of 231 | this License to the maximum extent possible; and (b) describe the 232 | limitations and the code they affect. Such description must be placed in a 233 | text file included with all distributions of the Covered Software under 234 | this License. Except to the extent prohibited by statute or regulation, 235 | such description must be sufficiently detailed for a recipient of ordinary 236 | skill to be able to understand it. 237 | 238 | 5. Termination 239 | 240 | 5.1. The rights granted under this License will terminate automatically if You 241 | fail to comply with any of its terms. However, if You become compliant, 242 | then the rights granted under this License from a particular Contributor 243 | are reinstated (a) provisionally, unless and until such Contributor 244 | explicitly and finally terminates Your grants, and (b) on an ongoing 245 | basis, if such Contributor fails to notify You of the non-compliance by 246 | some reasonable means prior to 60 days after You have come back into 247 | compliance. Moreover, Your grants from a particular Contributor are 248 | reinstated on an ongoing basis if such Contributor notifies You of the 249 | non-compliance by some reasonable means, this is the first time You have 250 | received notice of non-compliance with this License from such 251 | Contributor, and You become compliant prior to 30 days after Your receipt 252 | of the notice. 253 | 254 | 5.2. If You initiate litigation against any entity by asserting a patent 255 | infringement claim (excluding declaratory judgment actions, 256 | counter-claims, and cross-claims) alleging that a Contributor Version 257 | directly or indirectly infringes any patent, then the rights granted to 258 | You by any and all Contributors for the Covered Software under Section 259 | 2.1 of this License shall terminate. 260 | 261 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user 262 | license agreements (excluding distributors and resellers) which have been 263 | validly granted by You or Your distributors under this License prior to 264 | termination shall survive termination. 265 | 266 | 6. Disclaimer of Warranty 267 | 268 | Covered Software is provided under this License on an "as is" basis, 269 | without warranty of any kind, either expressed, implied, or statutory, 270 | including, without limitation, warranties that the Covered Software is free 271 | of defects, merchantable, fit for a particular purpose or non-infringing. 272 | The entire risk as to the quality and performance of the Covered Software 273 | is with You. Should any Covered Software prove defective in any respect, 274 | You (not any Contributor) assume the cost of any necessary servicing, 275 | repair, or correction. This disclaimer of warranty constitutes an essential 276 | part of this License. No use of any Covered Software is authorized under 277 | this License except under this disclaimer. 278 | 279 | 7. Limitation of Liability 280 | 281 | Under no circumstances and under no legal theory, whether tort (including 282 | negligence), contract, or otherwise, shall any Contributor, or anyone who 283 | distributes Covered Software as permitted above, be liable to You for any 284 | direct, indirect, special, incidental, or consequential damages of any 285 | character including, without limitation, damages for lost profits, loss of 286 | goodwill, work stoppage, computer failure or malfunction, or any and all 287 | other commercial damages or losses, even if such party shall have been 288 | informed of the possibility of such damages. This limitation of liability 289 | shall not apply to liability for death or personal injury resulting from 290 | such party's negligence to the extent applicable law prohibits such 291 | limitation. Some jurisdictions do not allow the exclusion or limitation of 292 | incidental or consequential damages, so this exclusion and limitation may 293 | not apply to You. 294 | 295 | 8. Litigation 296 | 297 | Any litigation relating to this License may be brought only in the courts 298 | of a jurisdiction where the defendant maintains its principal place of 299 | business and such litigation shall be governed by laws of that 300 | jurisdiction, without reference to its conflict-of-law provisions. Nothing 301 | in this Section shall prevent a party's ability to bring cross-claims or 302 | counter-claims. 303 | 304 | 9. Miscellaneous 305 | 306 | This License represents the complete agreement concerning the subject 307 | matter hereof. If any provision of this License is held to be 308 | unenforceable, such provision shall be reformed only to the extent 309 | necessary to make it enforceable. Any law or regulation which provides that 310 | the language of a contract shall be construed against the drafter shall not 311 | be used to construe this License against a Contributor. 312 | 313 | 314 | 10. Versions of the License 315 | 316 | 10.1. New Versions 317 | 318 | Mozilla Foundation is the license steward. Except as provided in Section 319 | 10.3, no one other than the license steward has the right to modify or 320 | publish new versions of this License. Each version will be given a 321 | distinguishing version number. 322 | 323 | 10.2. Effect of New Versions 324 | 325 | You may distribute the Covered Software under the terms of the version 326 | of the License under which You originally received the Covered Software, 327 | or under the terms of any subsequent version published by the license 328 | steward. 329 | 330 | 10.3. Modified Versions 331 | 332 | If you create software not governed by this License, and you want to 333 | create a new license for such software, you may create and use a 334 | modified version of this License if you rename the license and remove 335 | any references to the name of the license steward (except to note that 336 | such modified license differs from this License). 337 | 338 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 339 | Licenses If You choose to distribute Source Code Form that is 340 | Incompatible With Secondary Licenses under the terms of this version of 341 | the License, the notice described in Exhibit B of this License must be 342 | attached. 343 | 344 | Exhibit A - Source Code Form License Notice 345 | 346 | This Source Code Form is subject to the 347 | terms of the Mozilla Public License, v. 348 | 2.0. If a copy of the MPL was not 349 | distributed with this file, You can 350 | obtain one at 351 | http://mozilla.org/MPL/2.0/. 352 | 353 | If it is not possible or desirable to put the notice in a particular file, 354 | then You may include the notice in a location (such as a LICENSE file in a 355 | relevant directory) where a recipient would be likely to look for such a 356 | notice. 357 | 358 | You may add additional accurate notices of copyright ownership. 359 | 360 | Exhibit B - "Incompatible With Secondary Licenses" Notice 361 | 362 | This Source Code Form is "Incompatible 363 | With Secondary Licenses", as defined by 364 | the Mozilla Public License, v. 2.0. 365 | 366 | -------------------------------------------------------------------------------- /path_login.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package kubeauth 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "fmt" 10 | "net/http" 11 | "strings" 12 | 13 | "github.com/go-jose/go-jose/v4" 14 | josejwt "github.com/go-jose/go-jose/v4/jwt" 15 | capjwt "github.com/hashicorp/cap/jwt" 16 | "github.com/hashicorp/go-secure-stdlib/strutil" 17 | "github.com/hashicorp/vault/sdk/framework" 18 | "github.com/hashicorp/vault/sdk/helper/cidrutil" 19 | "github.com/hashicorp/vault/sdk/logical" 20 | "github.com/mitchellh/mapstructure" 21 | ) 22 | 23 | const ( 24 | metadataKeySAUID = "service_account_uid" 25 | metadataKeySAName = "service_account_name" 26 | metadataKeySANamespace = "service_account_namespace" 27 | metadataKeySASecretName = "service_account_secret_name" 28 | ) 29 | 30 | var reservedAliasMetadataKeys = map[string]struct{}{ 31 | metadataKeySAUID: {}, 32 | metadataKeySAName: {}, 33 | metadataKeySANamespace: {}, 34 | metadataKeySASecretName: {}, 35 | } 36 | 37 | // defaultJWTIssuer is used to verify the iss header on the JWT if the config doesn't specify an issuer. 38 | var defaultJWTIssuer = "kubernetes/serviceaccount" 39 | 40 | var ( 41 | // signing algorithms supported by k8s OIDC 42 | // ref: https://github.com/kubernetes/kubernetes/blob/b4935d910dcf256288694391ef675acfbdb8e7a3/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc.go#L222-L233 43 | allowedSigningAlgs = []jose.SignatureAlgorithm{ 44 | jose.RS256, 45 | jose.RS384, 46 | jose.RS512, 47 | jose.ES256, 48 | jose.ES384, 49 | jose.ES512, 50 | jose.PS256, 51 | jose.PS384, 52 | jose.PS512, 53 | } 54 | // allowedSigningAlgsCap is initialized with the values from allowedSigningAlgs 55 | allowedSigningAlgsCap = make([]capjwt.Alg, len(allowedSigningAlgs)) 56 | ) 57 | 58 | func init() { 59 | for idx := 0; idx < len(allowedSigningAlgs); idx++ { 60 | allowedSigningAlgsCap[idx] = capjwt.Alg(allowedSigningAlgs[idx]) 61 | } 62 | } 63 | 64 | // pathLogin returns the path configurations for login endpoints 65 | func pathLogin(b *kubeAuthBackend) *framework.Path { 66 | return &framework.Path{ 67 | Pattern: "login$", 68 | 69 | DisplayAttrs: &framework.DisplayAttributes{ 70 | OperationPrefix: operationPrefixKubernetes, 71 | OperationVerb: "login", 72 | }, 73 | 74 | Fields: map[string]*framework.FieldSchema{ 75 | "role": { 76 | Type: framework.TypeString, 77 | Description: `Name of the role against which the login is being attempted. This field is required`, 78 | }, 79 | "jwt": { 80 | Type: framework.TypeString, 81 | Description: `A signed JWT for authenticating a service account. This field is required.`, 82 | }, 83 | }, 84 | 85 | Callbacks: map[logical.Operation]framework.OperationFunc{ 86 | logical.UpdateOperation: b.pathLogin, 87 | logical.AliasLookaheadOperation: b.aliasLookahead, 88 | logical.ResolveRoleOperation: b.pathResolveRole, 89 | }, 90 | 91 | HelpSynopsis: pathLoginHelpSyn, 92 | HelpDescription: pathLoginHelpDesc, 93 | } 94 | } 95 | 96 | // pathLogin is used to resolve the role to be used from a login request 97 | func (b *kubeAuthBackend) pathResolveRole(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { 98 | roleName, resp := b.getFieldValueStr(data, "role") 99 | if resp != nil { 100 | return resp, nil 101 | } 102 | 103 | b.l.RLock() 104 | defer b.l.RUnlock() 105 | 106 | role, err := b.role(ctx, req.Storage, roleName) 107 | if err != nil { 108 | return nil, err 109 | } 110 | if role == nil { 111 | return logical.ErrorResponse(fmt.Sprintf("invalid role name %q", roleName)), nil 112 | } 113 | 114 | return logical.ResolveRoleResponse(roleName) 115 | } 116 | 117 | // pathLogin is used to authenticate to this backend 118 | func (b *kubeAuthBackend) pathLogin(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { 119 | roleName, resp := b.getFieldValueStr(data, "role") 120 | if resp != nil { 121 | return resp, nil 122 | } 123 | 124 | jwtStr, resp := b.getFieldValueStr(data, "jwt") 125 | if resp != nil { 126 | return resp, nil 127 | } 128 | 129 | b.l.RLock() 130 | defer b.l.RUnlock() 131 | 132 | role, err := b.role(ctx, req.Storage, roleName) 133 | if err != nil { 134 | return nil, err 135 | } 136 | if role == nil { 137 | return logical.ErrorResponse(fmt.Sprintf("invalid role name %q", roleName)), nil 138 | } 139 | 140 | // Check for a CIDR match. 141 | if len(role.TokenBoundCIDRs) > 0 { 142 | if req.Connection == nil { 143 | b.Logger().Warn("token bound CIDRs found but no connection information available for validation") 144 | return nil, logical.ErrPermissionDenied 145 | } 146 | if !cidrutil.RemoteAddrIsOk(req.Connection.RemoteAddr, role.TokenBoundCIDRs) { 147 | return nil, logical.ErrPermissionDenied 148 | } 149 | } 150 | 151 | config, err := b.loadConfig(ctx, req.Storage) 152 | if err != nil { 153 | return nil, err 154 | } 155 | if config == nil { 156 | return nil, errors.New("could not load backend configuration") 157 | } 158 | 159 | client, err := b.getHTTPClient() 160 | if err != nil { 161 | b.Logger().Error("Failed to get the HTTP client", "err", err) 162 | return nil, logical.ErrUnrecoverable 163 | } 164 | 165 | sa, err := b.parseAndValidateJWT(ctx, client, jwtStr, role, config) 166 | if err != nil { 167 | if err == jose.ErrCryptoFailure || strings.Contains(err.Error(), "verifying token signature") { 168 | b.Logger().Debug(`login unauthorized`, "err", err) 169 | return nil, logical.ErrPermissionDenied 170 | } 171 | return nil, err 172 | } 173 | 174 | aliasName, err := b.getAliasName(role, sa) 175 | if err != nil { 176 | return nil, err 177 | } 178 | 179 | // look up the JWT token in the kubernetes API 180 | err = sa.lookup(ctx, client, jwtStr, role.Audience, b.reviewFactory(config)) 181 | if err != nil { 182 | b.Logger().Debug(`login unauthorized`, "err", err) 183 | return nil, logical.ErrPermissionDenied 184 | } 185 | 186 | annotations := map[string]string{} 187 | if config.UseAnnotationsAsAliasMetadata { 188 | annotations, err = b.serviceAccountGetterFactory(config).annotations(ctx, client, jwtStr, sa.namespace(), sa.name()) 189 | if err != nil { 190 | if errors.Is(err, errAliasMetadataReservedKeysFound) { 191 | return logical.ErrorResponse(err.Error()), nil 192 | } 193 | 194 | return nil, err 195 | } 196 | } 197 | 198 | uid, err := sa.uid() 199 | if err != nil { 200 | return nil, err 201 | } 202 | 203 | metadata := annotations 204 | metadata[metadataKeySAUID] = uid 205 | metadata[metadataKeySAName] = sa.name() 206 | metadata[metadataKeySANamespace] = sa.namespace() 207 | metadata[metadataKeySASecretName] = sa.SecretName 208 | 209 | auth := &logical.Auth{ 210 | Alias: &logical.Alias{ 211 | Name: aliasName, 212 | Metadata: metadata, 213 | }, 214 | InternalData: map[string]interface{}{ 215 | "role": roleName, 216 | }, 217 | Metadata: map[string]string{ 218 | metadataKeySAUID: uid, 219 | metadataKeySAName: sa.name(), 220 | metadataKeySANamespace: sa.namespace(), 221 | metadataKeySASecretName: sa.SecretName, 222 | "role": roleName, 223 | }, 224 | DisplayName: fmt.Sprintf("%s-%s", sa.namespace(), sa.name()), 225 | } 226 | 227 | role.PopulateTokenAuth(auth) 228 | 229 | return &logical.Response{ 230 | Auth: auth, 231 | }, nil 232 | } 233 | 234 | func (b *kubeAuthBackend) getFieldValueStr(data *framework.FieldData, param string) (string, *logical.Response) { 235 | val := data.Get(param).(string) 236 | if len(val) == 0 { 237 | return "", logical.ErrorResponse("missing %s", param) 238 | } 239 | return val, nil 240 | } 241 | 242 | func (b *kubeAuthBackend) getAliasName(role *roleStorageEntry, serviceAccount *serviceAccount) (string, error) { 243 | switch role.AliasNameSource { 244 | case aliasNameSourceSAUid, aliasNameSourceUnset: 245 | uid, err := serviceAccount.uid() 246 | if err != nil { 247 | return "", err 248 | } 249 | return uid, nil 250 | case aliasNameSourceSAName: 251 | ns, name := serviceAccount.namespace(), serviceAccount.name() 252 | if ns == "" || name == "" { 253 | return "", fmt.Errorf("service account namespace and name must be set") 254 | } 255 | return fmt.Sprintf("%s/%s", ns, name), nil 256 | default: 257 | return "", fmt.Errorf("unknown alias_name_source %q", role.AliasNameSource) 258 | } 259 | } 260 | 261 | // aliasLookahead returns the alias object with the SA UID from the JWT 262 | // Claims. 263 | // Only JWTs matching the specified role's configuration will be accepted as valid. 264 | func (b *kubeAuthBackend) aliasLookahead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { 265 | roleName, resp := b.getFieldValueStr(data, "role") 266 | if resp != nil { 267 | return resp, nil 268 | } 269 | 270 | jwtStr, resp := b.getFieldValueStr(data, "jwt") 271 | if resp != nil { 272 | return resp, nil 273 | } 274 | 275 | b.l.RLock() 276 | defer b.l.RUnlock() 277 | 278 | role, err := b.role(ctx, req.Storage, roleName) 279 | if err != nil { 280 | return nil, err 281 | } 282 | if role == nil { 283 | return logical.ErrorResponse(fmt.Sprintf("invalid role name %q", roleName)), nil 284 | } 285 | 286 | config, err := b.loadConfig(ctx, req.Storage) 287 | if err != nil { 288 | return nil, err 289 | } 290 | if config == nil { 291 | return nil, errors.New("could not load backend configuration") 292 | } 293 | // validation of the JWT against the provided role ensures alias look ahead requests 294 | // are authentic. 295 | client, err := b.getHTTPClient() 296 | if err != nil { 297 | b.Logger().Error("Failed to get the HTTP client", "err", err) 298 | return nil, logical.ErrUnrecoverable 299 | } 300 | 301 | sa, err := b.parseAndValidateJWT(ctx, client, jwtStr, role, config) 302 | if err != nil { 303 | return nil, err 304 | } 305 | 306 | aliasName, err := b.getAliasName(role, sa) 307 | if err != nil { 308 | return nil, err 309 | } 310 | 311 | return &logical.Response{ 312 | Auth: &logical.Auth{ 313 | Alias: &logical.Alias{ 314 | Name: aliasName, 315 | }, 316 | }, 317 | }, nil 318 | } 319 | 320 | type DontVerifySignature struct{} 321 | 322 | func (keySet DontVerifySignature) VerifySignature(_ context.Context, token string) (map[string]interface{}, error) { 323 | parsed, err := josejwt.ParseSigned(token, allowedSigningAlgs) 324 | if err != nil { 325 | return nil, err 326 | } 327 | claims := map[string]interface{}{} 328 | err = parsed.UnsafeClaimsWithoutVerification(&claims) 329 | if err != nil { 330 | return nil, err 331 | } 332 | return claims, nil 333 | } 334 | 335 | // parseAndValidateJWT is used to parse, validate and lookup the JWT token. 336 | func (b *kubeAuthBackend) parseAndValidateJWT(ctx context.Context, client *http.Client, jwtStr string, 337 | role *roleStorageEntry, config *kubeConfig, 338 | ) (*serviceAccount, error) { 339 | expected := capjwt.Expected{ 340 | SigningAlgorithms: allowedSigningAlgsCap, 341 | } 342 | 343 | // perform ISS Claim validation if configured 344 | if !config.DisableISSValidation { 345 | // set the expected issuer to the default kubernetes issuer if the config doesn't specify it 346 | if config.Issuer != "" { 347 | expected.Issuer = config.Issuer 348 | } else { 349 | config.Issuer = defaultJWTIssuer 350 | } 351 | } 352 | 353 | // validate the audience if the role expects it 354 | if role.Audience != "" { 355 | expected.Audiences = []string{role.Audience} 356 | } 357 | 358 | // Parse into JWT 359 | var err error 360 | var keySet capjwt.KeySet 361 | if len(config.PublicKeys) == 0 { 362 | // we don't verify the signature if we aren't configured with public keys 363 | keySet = DontVerifySignature{} 364 | } else { 365 | keySet, err = capjwt.NewStaticKeySet(config.PublicKeys) 366 | if err != nil { 367 | return nil, err 368 | } 369 | } 370 | validator, err := capjwt.NewValidator(keySet) 371 | if err != nil { 372 | return nil, err 373 | } 374 | 375 | claims, err := validator.ValidateAllowMissingIatNbfExp(nil, jwtStr, expected) 376 | if err != nil { 377 | return nil, logical.CodedError(http.StatusForbidden, err.Error()) 378 | } 379 | 380 | sa := &serviceAccount{} 381 | 382 | // Decode claims into a service account object 383 | err = mapstructure.Decode(claims, sa) 384 | if err != nil { 385 | return nil, err 386 | } 387 | 388 | // verify the service account name is allowed 389 | if len(role.ServiceAccountNames) > 1 || role.ServiceAccountNames[0] != "*" { 390 | if !strutil.StrListContainsGlob(role.ServiceAccountNames, sa.name()) { 391 | return nil, logical.CodedError(http.StatusForbidden, 392 | fmt.Sprintf("service account name not authorized")) 393 | } 394 | } 395 | 396 | // verify the namespace is allowed 397 | var allowed bool 398 | if len(role.ServiceAccountNamespaces) > 0 { 399 | if role.ServiceAccountNamespaces[0] == "*" || strutil.StrListContainsGlob(role.ServiceAccountNamespaces, sa.namespace()) { 400 | allowed = true 401 | } 402 | } 403 | 404 | // verify the namespace selector matches the namespace 405 | if !allowed && role.ServiceAccountNamespaceSelector != "" { 406 | allowed, err = b.namespaceValidatorFactory(config).validateLabels(ctx, 407 | client, sa.namespace(), role.ServiceAccountNamespaceSelector) 408 | } 409 | 410 | if !allowed { 411 | errMsg := "namespace not authorized" 412 | if err != nil { 413 | errMsg = fmt.Sprintf("%s err=%s", errMsg, err) 414 | } 415 | codedErr := logical.CodedError(http.StatusForbidden, errMsg) 416 | return nil, codedErr 417 | } 418 | 419 | // If we don't have any public keys to verify, return the sa and end early. 420 | if len(config.PublicKeys) == 0 { 421 | return sa, nil 422 | } 423 | 424 | return sa, nil 425 | } 426 | 427 | // serviceAccount holds the metadata from the JWT token and is used to lookup 428 | // the JWT in the kubernetes API and compare the results. 429 | type serviceAccount struct { 430 | Name string `mapstructure:"kubernetes.io/serviceaccount/service-account.name"` 431 | UID string `mapstructure:"kubernetes.io/serviceaccount/service-account.uid"` 432 | SecretName string `mapstructure:"kubernetes.io/serviceaccount/secret.name"` 433 | Namespace string `mapstructure:"kubernetes.io/serviceaccount/namespace"` 434 | Audience []string `mapstructure:"aud"` 435 | 436 | // the JSON returned from reviewing a Projected Service account has a 437 | // different structure, where the information is in a sub-structure instead of 438 | // at the top level 439 | Kubernetes *projectedServiceToken `mapstructure:"kubernetes.io"` 440 | Expiration int64 `mapstructure:"exp"` 441 | IssuedAt int64 `mapstructure:"iat"` 442 | } 443 | 444 | // uid returns the UID for the service account, preferring the projected service 445 | // account value if found 446 | // return an error when the UID is empty. 447 | func (s *serviceAccount) uid() (string, error) { 448 | uid := s.UID 449 | if s.Kubernetes != nil && s.Kubernetes.ServiceAccount != nil { 450 | uid = s.Kubernetes.ServiceAccount.UID 451 | } 452 | 453 | if uid == "" { 454 | return "", errors.New("could not parse UID from claims") 455 | } 456 | return uid, nil 457 | } 458 | 459 | // name returns the name for the service account, preferring the projected 460 | // service account value if found. This is "default" for projected service 461 | // accounts 462 | func (s *serviceAccount) name() string { 463 | if s.Kubernetes != nil && s.Kubernetes.ServiceAccount != nil { 464 | return s.Kubernetes.ServiceAccount.Name 465 | } 466 | return s.Name 467 | } 468 | 469 | // namespace returns the namespace for the service account, preferring the 470 | // projected service account value if found 471 | func (s *serviceAccount) namespace() string { 472 | if s.Kubernetes != nil { 473 | return s.Kubernetes.Namespace 474 | } 475 | return s.Namespace 476 | } 477 | 478 | type projectedServiceToken struct { 479 | Namespace string `mapstructure:"namespace"` 480 | Pod *k8sObjectRef `mapstructure:"pod"` 481 | ServiceAccount *k8sObjectRef `mapstructure:"serviceaccount"` 482 | } 483 | 484 | type k8sObjectRef struct { 485 | Name string `mapstructure:"name"` 486 | UID string `mapstructure:"uid"` 487 | } 488 | 489 | // lookup calls the TokenReview API in kubernetes to verify the token and secret 490 | // still exist. 491 | func (s *serviceAccount) lookup(ctx context.Context, client *http.Client, jwtStr string, aud string, tr tokenReviewer) error { 492 | // This is somewhat redundant, as we are asking k8s if the token's audiences 493 | // overlap with wantAud, but setting wantAud to the token's own audiences by 494 | // default. In the case that `audience` was set on the Vault role, we have 495 | // already validated that it matches one of the token's audiences by this 496 | // point, so we are essentially asking k8s to either ignore audience 497 | // validation or check our homework. 498 | wantAud := s.Audience 499 | if aud != "" { 500 | wantAud = []string{aud} 501 | } 502 | r, err := tr.Review(ctx, client, jwtStr, wantAud) 503 | if err != nil { 504 | return err 505 | } 506 | 507 | // Verify the returned metadata matches the expected data from the service 508 | // account. 509 | if s.name() != r.Name { 510 | return errors.New("JWT names did not match") 511 | } 512 | uid, err := s.uid() 513 | if err != nil { 514 | return err 515 | } 516 | if uid != r.UID { 517 | return errors.New("JWT UIDs did not match") 518 | } 519 | if s.namespace() != r.Namespace { 520 | return errors.New("JWT namepaces did not match") 521 | } 522 | 523 | return nil 524 | } 525 | 526 | // Invoked when the token issued by this backend is attempting a renewal. 527 | func (b *kubeAuthBackend) pathLoginRenew() framework.OperationFunc { 528 | return func(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { 529 | roleName := req.Auth.InternalData["role"].(string) 530 | if roleName == "" { 531 | return nil, fmt.Errorf("failed to fetch role_name during renewal") 532 | } 533 | 534 | b.l.RLock() 535 | defer b.l.RUnlock() 536 | 537 | // Ensure that the Role still exists. 538 | role, err := b.role(ctx, req.Storage, roleName) 539 | if err != nil { 540 | return nil, fmt.Errorf("failed to validate role %s during renewal:%s", roleName, err) 541 | } 542 | if role == nil { 543 | return nil, fmt.Errorf("role %s does not exist during renewal", roleName) 544 | } 545 | 546 | resp := &logical.Response{Auth: req.Auth} 547 | resp.Auth.TTL = role.TokenTTL 548 | resp.Auth.MaxTTL = role.TokenMaxTTL 549 | resp.Auth.Period = role.TokenPeriod 550 | return resp, nil 551 | } 552 | } 553 | 554 | const ( 555 | pathLoginHelpSyn = `Authenticates Kubernetes service accounts with Vault.` 556 | pathLoginHelpDesc = ` 557 | Authenticate Kubernetes service accounts. 558 | ` 559 | ) 560 | -------------------------------------------------------------------------------- /path_config_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package kubeauth 5 | 6 | import ( 7 | "context" 8 | "crypto" 9 | "os" 10 | "reflect" 11 | "testing" 12 | "time" 13 | 14 | "github.com/hashicorp/vault/sdk/logical" 15 | ) 16 | 17 | func setupLocalFiles(t *testing.T, b logical.Backend) func() { 18 | cert, err := os.CreateTemp("", "ca.crt") 19 | if err != nil { 20 | t.Fatal(err) 21 | } 22 | _, err = cert.WriteString(testLocalCACert) 23 | if err != nil { 24 | t.Fatal(err) 25 | } 26 | cert.Close() 27 | 28 | token, err := os.CreateTemp("", "token") 29 | if err != nil { 30 | t.Fatal(err) 31 | } 32 | _, err = token.WriteString(testLocalJWT) 33 | if err != nil { 34 | t.Fatal(err) 35 | } 36 | token.Close() 37 | b.(*kubeAuthBackend).localCACertReader = newCachingFileReader(cert.Name(), caReloadPeriod, time.Now) 38 | b.(*kubeAuthBackend).localSATokenReader = newCachingFileReader(token.Name(), jwtReloadPeriod, time.Now) 39 | 40 | return func() { 41 | os.Remove(cert.Name()) 42 | os.Remove(token.Name()) 43 | } 44 | } 45 | 46 | func TestConfig_Read(t *testing.T) { 47 | tests := []struct { 48 | name string 49 | data map[string]interface{} 50 | want map[string]interface{} 51 | }{ 52 | { 53 | name: "token-review-jwt-is-unset", 54 | data: map[string]interface{}{ 55 | "pem_keys": []string{testRSACert, testECCert}, 56 | "kubernetes_host": "host", 57 | "kubernetes_ca_cert": testCACert, 58 | "issuer": "", 59 | "disable_iss_validation": false, 60 | "disable_local_ca_jwt": false, 61 | }, 62 | want: map[string]interface{}{ 63 | "pem_keys": []string{testRSACert, testECCert}, 64 | "kubernetes_host": "host", 65 | "kubernetes_ca_cert": testCACert, 66 | "issuer": "", 67 | "disable_iss_validation": false, 68 | "disable_local_ca_jwt": false, 69 | "token_reviewer_jwt_set": false, 70 | "use_annotations_as_alias_metadata": false, 71 | }, 72 | }, 73 | { 74 | name: "token-review-jwt-is-set", 75 | data: map[string]interface{}{ 76 | "pem_keys": []string{testRSACert, testECCert}, 77 | "kubernetes_host": "host", 78 | "kubernetes_ca_cert": testCACert, 79 | "issuer": "", 80 | "disable_iss_validation": false, 81 | "disable_local_ca_jwt": false, 82 | "token_reviewer_jwt": "test-token-review-jwt", 83 | }, 84 | want: map[string]interface{}{ 85 | "pem_keys": []string{testRSACert, testECCert}, 86 | "kubernetes_host": "host", 87 | "kubernetes_ca_cert": testCACert, 88 | "issuer": "", 89 | "disable_iss_validation": false, 90 | "disable_local_ca_jwt": false, 91 | "token_reviewer_jwt_set": true, 92 | "use_annotations_as_alias_metadata": false, 93 | }, 94 | }, 95 | } 96 | 97 | for _, tc := range tests { 98 | t.Run(tc.name, func(t *testing.T) { 99 | b, storage := getBackend(t) 100 | cleanup := setupLocalFiles(t, b) 101 | t.Cleanup(cleanup) 102 | 103 | req := &logical.Request{ 104 | Operation: logical.UpdateOperation, 105 | Path: configPath, 106 | Storage: storage, 107 | Data: tc.data, 108 | } 109 | 110 | resp, err := b.HandleRequest(context.Background(), req) 111 | if err != nil || (resp != nil && resp.IsError()) { 112 | t.Fatalf("got unexpected error %s for resp %#v", err, resp) 113 | } 114 | 115 | req = &logical.Request{ 116 | Operation: logical.ReadOperation, 117 | Path: configPath, 118 | Storage: storage, 119 | Data: nil, 120 | } 121 | 122 | resp, err = b.HandleRequest(context.Background(), req) 123 | if err != nil || (resp != nil && resp.IsError()) { 124 | t.Fatalf("got unexpected error %s for resp %#v", err, resp) 125 | } 126 | 127 | if !reflect.DeepEqual(resp.Data, tc.want) { 128 | t.Fatalf("expected %#v, got %#v", tc.want, resp.Data) 129 | } 130 | }) 131 | } 132 | } 133 | 134 | func TestConfig(t *testing.T) { 135 | b, storage := getBackend(t) 136 | 137 | cleanup := setupLocalFiles(t, b) 138 | defer cleanup() 139 | 140 | // test no certificate 141 | data := map[string]interface{}{ 142 | "kubernetes_host": "host", 143 | } 144 | 145 | req := &logical.Request{ 146 | Operation: logical.UpdateOperation, 147 | Path: configPath, 148 | Storage: storage, 149 | Data: data, 150 | } 151 | 152 | resp, err := b.HandleRequest(context.Background(), req) 153 | if err != nil || (resp != nil && resp.IsError()) { 154 | t.Fatalf("err:%s resp:%#v\n", err, resp) 155 | } 156 | 157 | // test no host 158 | data = map[string]interface{}{ 159 | "pem_keys": testRSACert, 160 | } 161 | 162 | req = &logical.Request{ 163 | Operation: logical.UpdateOperation, 164 | Path: configPath, 165 | Storage: storage, 166 | Data: data, 167 | } 168 | 169 | resp, err = b.HandleRequest(context.Background(), req) 170 | if resp == nil || !resp.IsError() { 171 | t.Fatal("expected error") 172 | } 173 | if resp.Error().Error() != "no host provided" { 174 | t.Fatalf("got unexpected error: %v", resp.Error()) 175 | } 176 | 177 | // test invalid pem_keys 178 | data = map[string]interface{}{ 179 | "pem_keys": "bad", 180 | "kubernetes_host": "host", 181 | } 182 | 183 | req = &logical.Request{ 184 | Operation: logical.UpdateOperation, 185 | Path: configPath, 186 | Storage: storage, 187 | Data: data, 188 | } 189 | 190 | resp, err = b.HandleRequest(context.Background(), req) 191 | if resp == nil || !resp.IsError() { 192 | t.Fatal("expected error") 193 | } 194 | if resp.Error().Error() != "data does not contain any valid RSA or ECDSA public keys" { 195 | t.Fatalf("got unexpected error: %v", resp.Error()) 196 | } 197 | 198 | // test invalid kubernetes_ca_cert 199 | data = map[string]interface{}{ 200 | "kubernetes_ca_cert": testInvalidCACert, 201 | "kubernetes_host": "host", 202 | } 203 | 204 | req = &logical.Request{ 205 | Operation: logical.UpdateOperation, 206 | Path: configPath, 207 | Storage: storage, 208 | Data: data, 209 | } 210 | 211 | resp, err = b.HandleRequest(context.Background(), req) 212 | if resp == nil || !resp.IsError() { 213 | t.Fatal("expected error") 214 | } 215 | if resp.Error().Error() != "The provided CA PEM data contains no valid certificates" { 216 | t.Fatalf("got unexpected error: %v", resp.Error()) 217 | } 218 | 219 | // Test success with no certs 220 | data = map[string]interface{}{ 221 | "kubernetes_host": "host", 222 | "kubernetes_ca_cert": testCACert, 223 | } 224 | 225 | req = &logical.Request{ 226 | Operation: logical.UpdateOperation, 227 | Path: configPath, 228 | Storage: storage, 229 | Data: data, 230 | } 231 | 232 | resp, err = b.HandleRequest(context.Background(), req) 233 | if err != nil || (resp != nil && resp.IsError()) { 234 | t.Fatalf("err:%s resp:%#v\n", err, resp) 235 | } 236 | 237 | expected := &kubeConfig{ 238 | PublicKeys: []crypto.PublicKey{}, 239 | PEMKeys: []string{}, 240 | Host: "host", 241 | CACert: testCACert, 242 | DisableISSValidation: true, 243 | } 244 | 245 | conf, err := b.(*kubeAuthBackend).config(context.Background(), storage) 246 | if err != nil { 247 | t.Fatal(err) 248 | } 249 | 250 | if !reflect.DeepEqual(expected, conf) { 251 | t.Fatalf("expected did not match actual: expected %#v\n got %#v\n", expected, conf) 252 | } 253 | 254 | // Test success TokenReviewer 255 | data = map[string]interface{}{ 256 | "kubernetes_host": "host", 257 | "kubernetes_ca_cert": testCACert, 258 | "token_reviewer_jwt": jwtGoodDataToken, 259 | } 260 | 261 | req = &logical.Request{ 262 | Operation: logical.UpdateOperation, 263 | Path: configPath, 264 | Storage: storage, 265 | Data: data, 266 | } 267 | 268 | resp, err = b.HandleRequest(context.Background(), req) 269 | if err != nil || (resp != nil && resp.IsError()) { 270 | t.Fatalf("err:%s resp:%#v\n", err, resp) 271 | } 272 | 273 | cert, err := parsePublicKeyPEM([]byte(testRSACert)) 274 | if err != nil { 275 | t.Fatal(err) 276 | } 277 | 278 | expected = &kubeConfig{ 279 | PublicKeys: []crypto.PublicKey{}, 280 | PEMKeys: []string{}, 281 | Host: "host", 282 | CACert: testCACert, 283 | TokenReviewerJWT: jwtGoodDataToken, 284 | DisableISSValidation: true, 285 | DisableLocalCAJwt: false, 286 | } 287 | 288 | conf, err = b.(*kubeAuthBackend).config(context.Background(), storage) 289 | if err != nil { 290 | t.Fatal(err) 291 | } 292 | 293 | if !reflect.DeepEqual(expected, conf) { 294 | t.Fatalf("expected did not match actual: expected %#v\n got %#v\n", expected, conf) 295 | } 296 | 297 | // Test success with one cert 298 | data = map[string]interface{}{ 299 | "pem_keys": testRSACert, 300 | "kubernetes_host": "host", 301 | "kubernetes_ca_cert": testCACert, 302 | } 303 | 304 | req = &logical.Request{ 305 | Operation: logical.UpdateOperation, 306 | Path: configPath, 307 | Storage: storage, 308 | Data: data, 309 | } 310 | 311 | resp, err = b.HandleRequest(context.Background(), req) 312 | if err != nil || (resp != nil && resp.IsError()) { 313 | t.Fatalf("err:%s resp:%#v\n", err, resp) 314 | } 315 | 316 | cert, err = parsePublicKeyPEM([]byte(testRSACert)) 317 | if err != nil { 318 | t.Fatal(err) 319 | } 320 | 321 | expected = &kubeConfig{ 322 | PublicKeys: []crypto.PublicKey{cert}, 323 | PEMKeys: []string{testRSACert}, 324 | Host: "host", 325 | CACert: testCACert, 326 | DisableISSValidation: true, 327 | DisableLocalCAJwt: false, 328 | } 329 | 330 | conf, err = b.(*kubeAuthBackend).config(context.Background(), storage) 331 | if err != nil { 332 | t.Fatal(err) 333 | } 334 | 335 | if !reflect.DeepEqual(expected, conf) { 336 | t.Fatalf("expected did not match actual: expected %#v\n got %#v\n", expected, conf) 337 | } 338 | 339 | // Test success with two certs 340 | data = map[string]interface{}{ 341 | "pem_keys": []string{testRSACert, testECCert}, 342 | "kubernetes_host": "host", 343 | "kubernetes_ca_cert": testCACert, 344 | } 345 | 346 | req = &logical.Request{ 347 | Operation: logical.UpdateOperation, 348 | Path: configPath, 349 | Storage: storage, 350 | Data: data, 351 | } 352 | 353 | resp, err = b.HandleRequest(context.Background(), req) 354 | if err != nil || (resp != nil && resp.IsError()) { 355 | t.Fatalf("err:%s resp:%#v\n", err, resp) 356 | } 357 | 358 | cert, err = parsePublicKeyPEM([]byte(testRSACert)) 359 | if err != nil { 360 | t.Fatal(err) 361 | } 362 | 363 | cert2, err := parsePublicKeyPEM([]byte(testECCert)) 364 | if err != nil { 365 | t.Fatal(err) 366 | } 367 | 368 | expected = &kubeConfig{ 369 | PublicKeys: []crypto.PublicKey{cert, cert2}, 370 | PEMKeys: []string{testRSACert, testECCert}, 371 | Host: "host", 372 | CACert: testCACert, 373 | DisableISSValidation: true, 374 | DisableLocalCAJwt: false, 375 | } 376 | 377 | conf, err = b.(*kubeAuthBackend).config(context.Background(), storage) 378 | if err != nil { 379 | t.Fatal(err) 380 | } 381 | 382 | if !reflect.DeepEqual(expected, conf) { 383 | t.Fatalf("expected did not match actual: expected %#v\n got %#v\n", expected, conf) 384 | } 385 | 386 | // Test success with disabled iss validation 387 | data = map[string]interface{}{ 388 | "kubernetes_host": "host", 389 | "kubernetes_ca_cert": testCACert, 390 | "disable_iss_validation": true, 391 | } 392 | 393 | req = &logical.Request{ 394 | Operation: logical.UpdateOperation, 395 | Path: configPath, 396 | Storage: storage, 397 | Data: data, 398 | } 399 | 400 | resp, err = b.HandleRequest(context.Background(), req) 401 | if err != nil || (resp != nil && resp.IsError()) { 402 | t.Fatalf("err:%s resp:%#v\n", err, resp) 403 | } 404 | 405 | cert, err = parsePublicKeyPEM([]byte(testRSACert)) 406 | if err != nil { 407 | t.Fatal(err) 408 | } 409 | 410 | expected = &kubeConfig{ 411 | PublicKeys: []crypto.PublicKey{}, 412 | PEMKeys: []string{}, 413 | Host: "host", 414 | CACert: testCACert, 415 | DisableISSValidation: true, 416 | DisableLocalCAJwt: false, 417 | } 418 | 419 | conf, err = b.(*kubeAuthBackend).config(context.Background(), storage) 420 | if err != nil { 421 | t.Fatal(err) 422 | } 423 | 424 | if !reflect.DeepEqual(expected, conf) { 425 | t.Fatalf("expected did not match actual: expected %#v\n got %#v\n", expected, conf) 426 | } 427 | } 428 | 429 | func TestConfig_LocalCaJWT(t *testing.T) { 430 | testCases := map[string]struct { 431 | config map[string]interface{} 432 | setupInClusterFiles bool 433 | expected *kubeConfig 434 | }{ 435 | "no CA or JWT, default to local": { 436 | config: map[string]interface{}{ 437 | "kubernetes_host": "host", 438 | }, 439 | setupInClusterFiles: true, 440 | expected: &kubeConfig{ 441 | PublicKeys: []crypto.PublicKey{}, 442 | PEMKeys: []string{}, 443 | Host: "host", 444 | CACert: testLocalCACert, 445 | TokenReviewerJWT: testLocalJWT, 446 | DisableISSValidation: true, 447 | DisableLocalCAJwt: false, 448 | }, 449 | }, 450 | "CA set, default to local JWT": { 451 | config: map[string]interface{}{ 452 | "kubernetes_host": "host", 453 | "kubernetes_ca_cert": testCACert, 454 | }, 455 | setupInClusterFiles: true, 456 | expected: &kubeConfig{ 457 | PublicKeys: []crypto.PublicKey{}, 458 | PEMKeys: []string{}, 459 | Host: "host", 460 | CACert: testCACert, 461 | TokenReviewerJWT: testLocalJWT, 462 | DisableISSValidation: true, 463 | DisableLocalCAJwt: false, 464 | }, 465 | }, 466 | "JWT set, default to local CA": { 467 | config: map[string]interface{}{ 468 | "kubernetes_host": "host", 469 | "token_reviewer_jwt": jwtGoodDataToken, 470 | }, 471 | setupInClusterFiles: true, 472 | expected: &kubeConfig{ 473 | PublicKeys: []crypto.PublicKey{}, 474 | PEMKeys: []string{}, 475 | Host: "host", 476 | CACert: testLocalCACert, 477 | TokenReviewerJWT: jwtGoodDataToken, 478 | DisableISSValidation: true, 479 | DisableLocalCAJwt: false, 480 | }, 481 | }, 482 | "disable local default, CA set": { 483 | config: map[string]interface{}{ 484 | "kubernetes_host": "host", 485 | "kubernetes_ca_cert": testCACert, 486 | "disable_local_ca_jwt": true, 487 | }, 488 | expected: &kubeConfig{ 489 | PublicKeys: []crypto.PublicKey{}, 490 | PEMKeys: []string{}, 491 | Host: "host", 492 | CACert: testCACert, 493 | TokenReviewerJWT: "", 494 | DisableISSValidation: true, 495 | DisableLocalCAJwt: true, 496 | }, 497 | }, 498 | "disable local default, JWT set": { 499 | config: map[string]interface{}{ 500 | "kubernetes_host": "host", 501 | "token_reviewer_jwt": jwtGoodDataToken, 502 | "disable_local_ca_jwt": true, 503 | }, 504 | setupInClusterFiles: true, 505 | expected: &kubeConfig{ 506 | PublicKeys: []crypto.PublicKey{}, 507 | PEMKeys: []string{}, 508 | Host: "host", 509 | CACert: "", 510 | TokenReviewerJWT: jwtGoodDataToken, 511 | DisableISSValidation: true, 512 | DisableLocalCAJwt: true, 513 | }, 514 | }, 515 | "disable local default, no CA or JWT": { 516 | config: map[string]interface{}{ 517 | "kubernetes_host": "host", 518 | "disable_local_ca_jwt": true, 519 | }, 520 | setupInClusterFiles: true, 521 | expected: &kubeConfig{ 522 | PublicKeys: []crypto.PublicKey{}, 523 | PEMKeys: []string{}, 524 | Host: "host", 525 | CACert: "", 526 | TokenReviewerJWT: "", 527 | DisableISSValidation: true, 528 | DisableLocalCAJwt: true, 529 | }, 530 | }, 531 | } 532 | 533 | for name, tc := range testCases { 534 | t.Run(name, func(t *testing.T) { 535 | b, storage := getBackend(t) 536 | 537 | if tc.setupInClusterFiles { 538 | cleanup := setupLocalFiles(t, b) 539 | defer cleanup() 540 | } 541 | 542 | req := &logical.Request{ 543 | Operation: logical.UpdateOperation, 544 | Path: configPath, 545 | Storage: storage, 546 | Data: tc.config, 547 | } 548 | 549 | resp, err := b.HandleRequest(context.Background(), req) 550 | if err != nil || (resp != nil && resp.IsError()) { 551 | t.Fatalf("err:%s resp:%#v\n", err, resp) 552 | } 553 | 554 | conf, err := b.(*kubeAuthBackend).loadConfig(context.Background(), storage) 555 | if err != nil { 556 | t.Fatal(err) 557 | } 558 | 559 | if !reflect.DeepEqual(tc.expected, conf) { 560 | t.Fatalf("expected did not match actual: expected %#v\n got %#v\n", tc.expected, conf) 561 | } 562 | }) 563 | } 564 | } 565 | 566 | func TestConfig_LocalJWTRenewal(t *testing.T) { 567 | b, storage := getBackend(t) 568 | 569 | cleanup := setupLocalFiles(t, b) 570 | defer cleanup() 571 | 572 | // Create temp file that will be used as token. 573 | f, err := os.CreateTemp("", "renewed-token") 574 | if err != nil { 575 | t.Error(err) 576 | } 577 | f.Close() 578 | defer os.Remove(f.Name()) 579 | 580 | currentTime := time.Now() 581 | 582 | b.(*kubeAuthBackend).localSATokenReader = newCachingFileReader(f.Name(), jwtReloadPeriod, func() time.Time { 583 | return currentTime 584 | }) 585 | 586 | token1 := "before-renewal" 587 | token2 := "after-renewal" 588 | 589 | // Write initial token to the temp file. 590 | err = os.WriteFile(f.Name(), []byte(token1), 0o644) 591 | if err != nil { 592 | t.Error(err) 593 | } 594 | 595 | data := map[string]interface{}{ 596 | "kubernetes_host": "host", 597 | } 598 | req := &logical.Request{ 599 | Operation: logical.UpdateOperation, 600 | Path: configPath, 601 | Storage: storage, 602 | Data: data, 603 | } 604 | 605 | resp, err := b.HandleRequest(context.Background(), req) 606 | if err != nil || (resp != nil && resp.IsError()) { 607 | t.Fatalf("err:%s resp:%#v\n", err, resp) 608 | } 609 | 610 | // Loading the config will load the initial token file from disk. 611 | conf, err := b.(*kubeAuthBackend).loadConfig(context.Background(), storage) 612 | if err != nil || (resp != nil && resp.IsError()) { 613 | t.Fatalf("err:%s resp:%#v\n", err, resp) 614 | } 615 | 616 | // Check that we loaded the initial token. 617 | if conf.TokenReviewerJWT != token1 { 618 | t.Fatalf("got unexpected JWT: expected %#v\n got %#v\n", token1, conf.TokenReviewerJWT) 619 | } 620 | 621 | // Write new value to the token file to simulate renewal. 622 | err = os.WriteFile(f.Name(), []byte(token2), 0o644) 623 | if err != nil { 624 | t.Error(err) 625 | } 626 | 627 | // Load again to check we still got the old cached token from memory. 628 | conf, err = b.(*kubeAuthBackend).loadConfig(context.Background(), storage) 629 | if err != nil || (resp != nil && resp.IsError()) { 630 | t.Fatalf("err:%s resp:%#v\n", err, resp) 631 | } 632 | 633 | if conf.TokenReviewerJWT != token1 { 634 | t.Fatalf("got unexpected JWT: expected %#v\n got %#v\n", token1, conf.TokenReviewerJWT) 635 | } 636 | 637 | // Advance simulated time for cache to expire 638 | currentTime = currentTime.Add(1 * time.Minute) 639 | 640 | // Load again and check we the new renewed token from disk. 641 | conf, err = b.(*kubeAuthBackend).loadConfig(context.Background(), storage) 642 | if err != nil || (resp != nil && resp.IsError()) { 643 | t.Fatalf("err:%s resp:%#v\n", err, resp) 644 | } 645 | 646 | if conf.TokenReviewerJWT != token2 { 647 | t.Fatalf("got unexpected JWT: expected %#v\n got %#v\n", token2, conf.TokenReviewerJWT) 648 | } 649 | } 650 | -------------------------------------------------------------------------------- /path_role_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package kubeauth 5 | 6 | import ( 7 | "context" 8 | "encoding/json" 9 | "errors" 10 | "fmt" 11 | "testing" 12 | "time" 13 | 14 | "github.com/go-test/deep" 15 | log "github.com/hashicorp/go-hclog" 16 | "github.com/hashicorp/vault/sdk/helper/logging" 17 | "github.com/hashicorp/vault/sdk/helper/tokenutil" 18 | "github.com/hashicorp/vault/sdk/logical" 19 | ) 20 | 21 | const ( 22 | validJSONSelector = `{ 23 | "matchLabels": { 24 | "stage": "prod", 25 | "app": "vault" 26 | } 27 | }` 28 | 29 | invalidJSONSelector = `{ 30 | "matchLabels": 31 | "stage" 32 | }` 33 | 34 | validYAMLSelector = `matchLabels: 35 | stage: prod 36 | app: vault 37 | ` 38 | invalidYAMLSelector = `matchLabels: 39 | - stage: prod 40 | - app: vault 41 | ` 42 | ) 43 | 44 | func getBackend(t *testing.T) (logical.Backend, logical.Storage) { 45 | defaultLeaseTTLVal := time.Hour * 12 46 | maxLeaseTTLVal := time.Hour * 24 47 | b := Backend() 48 | if err := b.validateHTTPClientInit(); err != nil { 49 | t.Fatalf("unable to create backend: %v", err) 50 | } 51 | 52 | config := &logical.BackendConfig{ 53 | Logger: logging.NewVaultLogger(log.Trace), 54 | 55 | System: &logical.StaticSystemView{ 56 | DefaultLeaseTTLVal: defaultLeaseTTLVal, 57 | MaxLeaseTTLVal: maxLeaseTTLVal, 58 | }, 59 | StorageView: &logical.InmemStorage{}, 60 | } 61 | if err := b.Setup(context.Background(), config); err != nil { 62 | t.Fatalf("unable to setup backend: %v", err) 63 | } 64 | 65 | return b, config.StorageView 66 | } 67 | 68 | func TestPath_Create(t *testing.T) { 69 | testCases := map[string]struct { 70 | data map[string]interface{} 71 | expected *roleStorageEntry 72 | wantErr error 73 | }{ 74 | "default": { 75 | data: map[string]interface{}{ 76 | "bound_service_account_names": "name", 77 | "bound_service_account_namespaces": "namespace", 78 | "policies": "test", 79 | "period": "3s", 80 | "ttl": "1s", 81 | "num_uses": 12, 82 | "max_ttl": "5s", 83 | "alias_name_source": aliasNameSourceDefault, 84 | }, 85 | expected: &roleStorageEntry{ 86 | TokenParams: tokenutil.TokenParams{ 87 | TokenPolicies: []string{"test"}, 88 | TokenPeriod: 3 * time.Second, 89 | TokenTTL: 1 * time.Second, 90 | TokenMaxTTL: 5 * time.Second, 91 | TokenNumUses: 12, 92 | TokenBoundCIDRs: nil, 93 | }, 94 | Policies: []string{"test"}, 95 | Period: 3 * time.Second, 96 | ServiceAccountNames: []string{"name"}, 97 | ServiceAccountNamespaces: []string{"namespace"}, 98 | ServiceAccountNamespaceSelector: "", 99 | TTL: 1 * time.Second, 100 | MaxTTL: 5 * time.Second, 101 | NumUses: 12, 102 | BoundCIDRs: nil, 103 | AliasNameSource: aliasNameSourceDefault, 104 | }, 105 | }, 106 | "alias_name_source_serviceaccount_name": { 107 | data: map[string]interface{}{ 108 | "bound_service_account_names": "name", 109 | "bound_service_account_namespaces": "namespace", 110 | "policies": "test", 111 | "period": "3s", 112 | "ttl": "1s", 113 | "num_uses": 12, 114 | "max_ttl": "5s", 115 | "alias_name_source": aliasNameSourceSAName, 116 | }, 117 | expected: &roleStorageEntry{ 118 | TokenParams: tokenutil.TokenParams{ 119 | TokenPolicies: []string{"test"}, 120 | TokenPeriod: 3 * time.Second, 121 | TokenTTL: 1 * time.Second, 122 | TokenMaxTTL: 5 * time.Second, 123 | TokenNumUses: 12, 124 | TokenBoundCIDRs: nil, 125 | }, 126 | Policies: []string{"test"}, 127 | Period: 3 * time.Second, 128 | ServiceAccountNames: []string{"name"}, 129 | ServiceAccountNamespaces: []string{"namespace"}, 130 | ServiceAccountNamespaceSelector: "", 131 | TTL: 1 * time.Second, 132 | MaxTTL: 5 * time.Second, 133 | NumUses: 12, 134 | BoundCIDRs: nil, 135 | AliasNameSource: aliasNameSourceSAName, 136 | }, 137 | }, 138 | "namespace_selector": { 139 | data: map[string]interface{}{ 140 | "bound_service_account_names": "name", 141 | "bound_service_account_namespace_selector": validJSONSelector, 142 | "policies": "test", 143 | "period": "3s", 144 | "ttl": "1s", 145 | "num_uses": 12, 146 | "max_ttl": "5s", 147 | "alias_name_source": aliasNameSourceDefault, 148 | }, 149 | expected: &roleStorageEntry{ 150 | TokenParams: tokenutil.TokenParams{ 151 | TokenPolicies: []string{"test"}, 152 | TokenPeriod: 3 * time.Second, 153 | TokenTTL: 1 * time.Second, 154 | TokenMaxTTL: 5 * time.Second, 155 | TokenNumUses: 12, 156 | TokenBoundCIDRs: nil, 157 | }, 158 | Policies: []string{"test"}, 159 | Period: 3 * time.Second, 160 | ServiceAccountNames: []string{"name"}, 161 | ServiceAccountNamespaces: []string(nil), 162 | ServiceAccountNamespaceSelector: validJSONSelector, 163 | TTL: 1 * time.Second, 164 | MaxTTL: 5 * time.Second, 165 | NumUses: 12, 166 | BoundCIDRs: nil, 167 | AliasNameSource: aliasNameSourceDefault, 168 | }, 169 | }, 170 | "namespace_selector_with_namespaces": { 171 | data: map[string]interface{}{ 172 | "bound_service_account_names": "name", 173 | "bound_service_account_namespaces": "namespace1,namespace2", 174 | "bound_service_account_namespace_selector": validYAMLSelector, 175 | "policies": "test", 176 | "period": "3s", 177 | "ttl": "1s", 178 | "num_uses": 12, 179 | "max_ttl": "5s", 180 | "alias_name_source": aliasNameSourceDefault, 181 | }, 182 | expected: &roleStorageEntry{ 183 | TokenParams: tokenutil.TokenParams{ 184 | TokenPolicies: []string{"test"}, 185 | TokenPeriod: 3 * time.Second, 186 | TokenTTL: 1 * time.Second, 187 | TokenMaxTTL: 5 * time.Second, 188 | TokenNumUses: 12, 189 | TokenBoundCIDRs: nil, 190 | }, 191 | Policies: []string{"test"}, 192 | Period: 3 * time.Second, 193 | ServiceAccountNames: []string{"name"}, 194 | ServiceAccountNamespaces: []string{"namespace1", "namespace2"}, 195 | ServiceAccountNamespaceSelector: validYAMLSelector, 196 | TTL: 1 * time.Second, 197 | MaxTTL: 5 * time.Second, 198 | NumUses: 12, 199 | BoundCIDRs: nil, 200 | AliasNameSource: aliasNameSourceDefault, 201 | }, 202 | }, 203 | "invalid_alias_name_source": { 204 | data: map[string]interface{}{ 205 | "bound_service_account_names": "name", 206 | "bound_service_account_namespaces": "namespace", 207 | "policies": "test", 208 | "period": "3s", 209 | "ttl": "1s", 210 | "num_uses": 12, 211 | "max_ttl": "5s", 212 | "alias_name_source": "_invalid_", 213 | }, 214 | wantErr: errInvalidAliasNameSource, 215 | }, 216 | "invalid_namespace_label_selector_in_json": { 217 | data: map[string]interface{}{ 218 | "bound_service_account_names": "name", 219 | "bound_service_account_namespace_selector": invalidJSONSelector, 220 | "policies": "test", 221 | "period": "3s", 222 | "ttl": "1s", 223 | "num_uses": 12, 224 | "max_ttl": "5s", 225 | }, 226 | wantErr: errors.New(`invalid "bound_service_account_namespace_selector" configured`), 227 | }, 228 | "invalid_namespace_label_selector_in_yaml": { 229 | data: map[string]interface{}{ 230 | "bound_service_account_names": "name", 231 | "bound_service_account_namespace_selector": invalidYAMLSelector, 232 | "policies": "test", 233 | "period": "3s", 234 | "ttl": "1s", 235 | "num_uses": 12, 236 | "max_ttl": "5s", 237 | }, 238 | wantErr: errors.New(`invalid "bound_service_account_namespace_selector" configured`), 239 | }, 240 | "no_service_account_names": { 241 | data: map[string]interface{}{ 242 | "policies": "test", 243 | }, 244 | wantErr: errors.New(`"bound_service_account_names" can not be empty`), 245 | }, 246 | "no_service_account_namespaces": { 247 | data: map[string]interface{}{ 248 | "bound_service_account_names": "name", 249 | "policies": "test", 250 | }, 251 | wantErr: errors.New(`"bound_service_account_namespaces" can not be empty if "bound_service_account_namespace_selector" is not set`), 252 | }, 253 | "mixed_splat_values_names": { 254 | data: map[string]interface{}{ 255 | "bound_service_account_names": "*, test", 256 | "bound_service_account_namespaces": "*", 257 | "policies": "test", 258 | }, 259 | wantErr: errors.New(`can not mix "*" with values`), 260 | }, 261 | "mixed_splat_values_namespaces": { 262 | data: map[string]interface{}{ 263 | "bound_service_account_names": "*, test", 264 | "bound_service_account_namespaces": "*", 265 | "policies": "test", 266 | }, 267 | wantErr: errors.New(`can not mix "*" with values`), 268 | }, 269 | } 270 | 271 | for name, tc := range testCases { 272 | t.Run(name, func(t *testing.T) { 273 | b, storage := getBackend(t) 274 | path := fmt.Sprintf("role/%s", name) 275 | req := &logical.Request{ 276 | Operation: logical.CreateOperation, 277 | Path: path, 278 | Storage: storage, 279 | Data: tc.data, 280 | } 281 | 282 | resp, err := b.HandleRequest(context.Background(), req) 283 | 284 | if tc.wantErr != nil { 285 | var actual error 286 | if err != nil { 287 | actual = err 288 | } else if resp != nil && resp.IsError() { 289 | actual = resp.Error() 290 | } else { 291 | t.Fatalf("expected error") 292 | } 293 | 294 | if tc.wantErr.Error() != actual.Error() { 295 | t.Fatalf("expected err %q, actual %q", tc.wantErr, actual) 296 | } 297 | } else { 298 | if tc.wantErr == nil && (err != nil || (resp != nil && resp.IsError())) { 299 | t.Fatalf("err:%s resp:%#v\n", err, resp) 300 | } 301 | 302 | actual, err := b.(*kubeAuthBackend).role(context.Background(), storage, name) 303 | if err != nil { 304 | t.Fatal(err) 305 | } 306 | 307 | if diff := deep.Equal(tc.expected, actual); diff != nil { 308 | t.Fatal(diff) 309 | } 310 | } 311 | }) 312 | } 313 | } 314 | 315 | func TestPath_Read(t *testing.T) { 316 | b, storage := getBackend(t) 317 | 318 | configData := map[string]interface{}{ 319 | "bound_service_account_names": "name", 320 | "bound_service_account_namespaces": "namespace", 321 | "bound_service_account_namespace_selector": validJSONSelector, 322 | "policies": "test", 323 | "period": "3s", 324 | "ttl": "1s", 325 | "num_uses": 12, 326 | "max_ttl": "5s", 327 | } 328 | 329 | expected := map[string]interface{}{ 330 | "bound_service_account_names": []string{"name"}, 331 | "bound_service_account_namespaces": []string{"namespace"}, 332 | "bound_service_account_namespace_selector": validJSONSelector, 333 | "token_policies": []string{"test"}, 334 | "policies": []string{"test"}, 335 | "token_period": int64(3), 336 | "period": int64(3), 337 | "token_ttl": int64(1), 338 | "ttl": int64(1), 339 | "token_num_uses": 12, 340 | "num_uses": 12, 341 | "token_max_ttl": int64(5), 342 | "max_ttl": int64(5), 343 | "token_bound_cidrs": []string{}, 344 | "token_type": logical.TokenTypeDefault.String(), 345 | "token_explicit_max_ttl": int64(0), 346 | "token_no_default_policy": false, 347 | "alias_name_source": aliasNameSourceDefault, 348 | } 349 | 350 | req := &logical.Request{ 351 | Operation: logical.CreateOperation, 352 | Path: "role/plugin-test", 353 | Storage: storage, 354 | Data: configData, 355 | } 356 | 357 | resp, err := b.HandleRequest(context.Background(), req) 358 | if err != nil || (resp != nil && resp.IsError()) { 359 | t.Fatalf("err:%s resp:%#v\n", err, resp) 360 | } 361 | 362 | req = &logical.Request{ 363 | Operation: logical.ReadOperation, 364 | Path: "role/plugin-test", 365 | Storage: storage, 366 | Data: configData, 367 | } 368 | 369 | resp, err = b.HandleRequest(context.Background(), req) 370 | if err != nil || (resp != nil && resp.IsError()) { 371 | t.Fatalf("err:%s resp:%#v\n", err, resp) 372 | } 373 | 374 | if diff := deep.Equal(expected, resp.Data); diff != nil { 375 | t.Fatal(diff) 376 | } 377 | } 378 | 379 | func TestPath_Delete(t *testing.T) { 380 | b, storage := getBackend(t) 381 | 382 | configData := map[string]interface{}{ 383 | "bound_service_account_names": "name", 384 | "bound_service_account_namespaces": "namespace", 385 | "policies": "test", 386 | "period": "3s", 387 | "ttl": "1s", 388 | "num_uses": 12, 389 | "max_ttl": "5s", 390 | } 391 | 392 | req := &logical.Request{ 393 | Operation: logical.CreateOperation, 394 | Path: "role/plugin-test", 395 | Storage: storage, 396 | Data: configData, 397 | } 398 | 399 | resp, err := b.HandleRequest(context.Background(), req) 400 | if err != nil || (resp != nil && resp.IsError()) { 401 | t.Fatalf("err:%s resp:%#v\n", err, resp) 402 | } 403 | 404 | req = &logical.Request{ 405 | Operation: logical.DeleteOperation, 406 | Path: "role/plugin-test", 407 | Storage: storage, 408 | Data: nil, 409 | } 410 | 411 | resp, err = b.HandleRequest(context.Background(), req) 412 | if err != nil || (resp != nil && resp.IsError()) { 413 | t.Fatalf("err:%s resp:%#v\n", err, resp) 414 | } 415 | 416 | if resp != nil { 417 | t.Fatalf("Unexpected resp data: expected nil got %#v\n", resp.Data) 418 | } 419 | 420 | req = &logical.Request{ 421 | Operation: logical.ReadOperation, 422 | Path: "role/plugin-test", 423 | Storage: storage, 424 | Data: nil, 425 | } 426 | 427 | resp, err = b.HandleRequest(context.Background(), req) 428 | if err != nil || (resp != nil && resp.IsError()) { 429 | t.Fatalf("err:%s resp:%#v\n", err, resp) 430 | } 431 | 432 | if resp != nil { 433 | t.Fatalf("Unexpected resp data: expected nil got %#v\n", resp.Data) 434 | } 435 | } 436 | 437 | func TestPath_Update(t *testing.T) { 438 | testCases := map[string]struct { 439 | storageData map[string]interface{} 440 | requestData map[string]interface{} 441 | expected *roleStorageEntry 442 | wantErr error 443 | }{ 444 | "default": { 445 | storageData: map[string]interface{}{ 446 | "bound_service_account_names": []string{"name"}, 447 | "bound_service_account_namespaces": []string{"namespace"}, 448 | "policies": []string{"test"}, 449 | "period": 1 * time.Second, 450 | "ttl": 1 * time.Second, 451 | "num_uses": 12, 452 | "max_ttl": 5 * time.Second, 453 | "alias_name_source": aliasNameSourceDefault, 454 | }, 455 | requestData: map[string]interface{}{ 456 | "alias_name_source": aliasNameSourceDefault, 457 | "policies": []string{"bar", "foo"}, 458 | "period": "3s", 459 | }, 460 | expected: &roleStorageEntry{ 461 | TokenParams: tokenutil.TokenParams{ 462 | TokenPolicies: []string{"bar", "foo"}, 463 | TokenPeriod: 3 * time.Second, 464 | TokenTTL: 1 * time.Second, 465 | TokenMaxTTL: 5 * time.Second, 466 | TokenNumUses: 12, 467 | TokenBoundCIDRs: nil, 468 | }, 469 | Policies: []string{"bar", "foo"}, 470 | Period: 3 * time.Second, 471 | ServiceAccountNames: []string{"name"}, 472 | ServiceAccountNamespaces: []string{"namespace"}, 473 | TTL: 1 * time.Second, 474 | MaxTTL: 5 * time.Second, 475 | NumUses: 12, 476 | BoundCIDRs: nil, 477 | AliasNameSource: aliasNameSourceDefault, 478 | }, 479 | wantErr: nil, 480 | }, 481 | "migrate-alias-name-source": { 482 | storageData: map[string]interface{}{ 483 | "bound_service_account_names": []string{"name"}, 484 | "bound_service_account_namespaces": []string{"namespace"}, 485 | "policies": []string{"test"}, 486 | "period": 1 * time.Second, 487 | "ttl": 1 * time.Second, 488 | "num_uses": 12, 489 | "max_ttl": 5 * time.Second, 490 | }, 491 | requestData: map[string]interface{}{ 492 | "alias_name_source": aliasNameSourceUnset, 493 | }, 494 | expected: &roleStorageEntry{ 495 | TokenParams: tokenutil.TokenParams{ 496 | TokenPolicies: []string{"test"}, 497 | TokenPeriod: 1 * time.Second, 498 | TokenTTL: 1 * time.Second, 499 | TokenMaxTTL: 5 * time.Second, 500 | TokenNumUses: 12, 501 | TokenBoundCIDRs: nil, 502 | }, 503 | Policies: []string{"test"}, 504 | Period: 1 * time.Second, 505 | ServiceAccountNames: []string{"name"}, 506 | ServiceAccountNamespaces: []string{"namespace"}, 507 | TTL: 1 * time.Second, 508 | MaxTTL: 5 * time.Second, 509 | NumUses: 12, 510 | BoundCIDRs: nil, 511 | AliasNameSource: aliasNameSourceDefault, 512 | }, 513 | wantErr: nil, 514 | }, 515 | "invalid-alias-name-source": { 516 | storageData: map[string]interface{}{ 517 | "bound_service_account_names": []string{"name"}, 518 | "bound_service_account_namespaces": []string{"namespace"}, 519 | "alias_name_source": aliasNameSourceDefault, 520 | }, 521 | requestData: map[string]interface{}{ 522 | "alias_name_source": "_invalid_", 523 | }, 524 | wantErr: errInvalidAliasNameSource, 525 | }, 526 | "invalid-alias-name-source-in-storage": { 527 | storageData: map[string]interface{}{ 528 | "bound_service_account_names": []string{"name"}, 529 | "bound_service_account_namespaces": []string{"namespace"}, 530 | "alias_name_source": "_invalid_", 531 | }, 532 | requestData: map[string]interface{}{}, 533 | wantErr: errInvalidAliasNameSource, 534 | }, 535 | "invalid-alias-name-source-migration": { 536 | storageData: map[string]interface{}{ 537 | "bound_service_account_names": []string{"name"}, 538 | "bound_service_account_namespaces": []string{"namespace"}, 539 | "alias_name_source": aliasNameSourceUnset, 540 | }, 541 | requestData: map[string]interface{}{ 542 | "alias_name_source": "_invalid_", 543 | }, 544 | wantErr: errInvalidAliasNameSource, 545 | }, 546 | } 547 | 548 | for name, tc := range testCases { 549 | t.Run(name, func(t *testing.T) { 550 | b, storage := getBackend(t) 551 | path := fmt.Sprintf("role/%s", name) 552 | 553 | data, err := json.Marshal(tc.storageData) 554 | if err != nil { 555 | t.Fatal(err) 556 | } 557 | 558 | entry := &logical.StorageEntry{ 559 | Key: path, 560 | Value: data, 561 | SealWrap: false, 562 | } 563 | if err := storage.Put(context.Background(), entry); err != nil { 564 | t.Fatal(err) 565 | } 566 | 567 | req := &logical.Request{ 568 | Operation: logical.UpdateOperation, 569 | Path: path, 570 | Storage: storage, 571 | Data: tc.requestData, 572 | } 573 | 574 | resp, err := b.HandleRequest(context.Background(), req) 575 | 576 | if tc.wantErr != nil { 577 | var actual error 578 | if err != nil { 579 | actual = err 580 | } else if resp != nil && resp.IsError() { 581 | actual = resp.Error() 582 | } else { 583 | t.Fatalf("expected error") 584 | } 585 | 586 | if tc.wantErr.Error() != actual.Error() { 587 | t.Fatalf("expected err %q, actual %q", tc.wantErr, actual) 588 | } 589 | } else { 590 | if err != nil || (resp != nil && resp.IsError()) { 591 | t.Fatalf("err:%s resp:%#v\n", err, resp) 592 | } 593 | 594 | actual, err := b.(*kubeAuthBackend).role(context.Background(), storage, name) 595 | if err != nil { 596 | t.Fatal(err) 597 | } 598 | 599 | if diff := deep.Equal(tc.expected, actual); diff != nil { 600 | t.Fatal(diff) 601 | } 602 | } 603 | }) 604 | } 605 | } 606 | --------------------------------------------------------------------------------