diff --git a/builtin/logical/kubernetes/.github/PULL_REQUEST_TEMPLATE.md b/builtin/logical/kubernetes/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..7113d165a9 --- /dev/null +++ b/builtin/logical/kubernetes/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,24 @@ +# Overview + +A high level description of the contribution, including: +Who the change affects or is for (stakeholders)? +What is the change? +Why is the change needed? +How does this change affect the user experience (if at all)? + +# Design of Change + +How was this change implemented? + +# Related Issues/Pull Requests + +[ ] [Issue #1234](https://github.com/hashicorp/vault/issues/1234) +[ ] [PR #1234](https://github.com/hashicorp/vault/pr/1234) + +# Contributor Checklist + +[ ] Add relevant docs to upstream Vault repository, or sufficient reasoning why docs won’t be added yet +[My Docs PR Link](link) +[Example](https://github.com/hashicorp/vault/commit/2715f5cec982aabc7b7a6ae878c547f6f475bba6) +[ ] Add output for any tests not ran in CI to the PR description (eg, acceptance tests) +[ ] Backwards compatible diff --git a/builtin/logical/kubernetes/.github/workflows/bulk-dep-upgrades.yaml b/builtin/logical/kubernetes/.github/workflows/bulk-dep-upgrades.yaml new file mode 100644 index 0000000000..1e7714debf --- /dev/null +++ b/builtin/logical/kubernetes/.github/workflows/bulk-dep-upgrades.yaml @@ -0,0 +1,17 @@ +name: Upgrade dependencies +on: + workflow_dispatch: + schedule: + # Runs 12:00AM on the first of every month + - cron: '0 0 1 * *' +jobs: + upgrade: + # using `main` as the ref will keep your workflow up-to-date + uses: hashicorp/vault-workflows-common/.github/workflows/bulk-dependency-updates.yaml@main + secrets: + VAULT_ECO_GITHUB_TOKEN: ${{ secrets.VAULT_ECO_GITHUB_TOKEN }} + with: + # either hashicorp/vault-ecosystem-applications or hashicorp/vault-ecosystem-foundations + reviewer-team: hashicorp/vault-ecosystem-foundations + repository: ${{ github.repository }} + run-id: ${{ github.run_id }} diff --git a/builtin/logical/kubernetes/.github/workflows/jira.yaml b/builtin/logical/kubernetes/.github/workflows/jira.yaml new file mode 100644 index 0000000000..0f73ec380c --- /dev/null +++ b/builtin/logical/kubernetes/.github/workflows/jira.yaml @@ -0,0 +1,17 @@ +name: Jira Sync +on: + issues: + types: [opened, closed, deleted, reopened] + pull_request_target: + types: [opened, closed, reopened] + issue_comment: # Also triggers when commenting on a PR from the conversation view + types: [created] +jobs: + sync: + uses: hashicorp/vault-workflows-common/.github/workflows/jira.yaml@main + secrets: + JIRA_SYNC_BASE_URL: ${{ secrets.JIRA_SYNC_BASE_URL }} + JIRA_SYNC_USER_EMAIL: ${{ secrets.JIRA_SYNC_USER_EMAIL }} + JIRA_SYNC_API_TOKEN: ${{ secrets.JIRA_SYNC_API_TOKEN }} + with: + teams-array: '["ecosystem", "foundations-eco"]' diff --git a/builtin/logical/kubernetes/.github/workflows/tests.yaml b/builtin/logical/kubernetes/.github/workflows/tests.yaml new file mode 100644 index 0000000000..cc83381925 --- /dev/null +++ b/builtin/logical/kubernetes/.github/workflows/tests.yaml @@ -0,0 +1,54 @@ +name: Tests + +on: [push, workflow_dispatch] + +jobs: + fmtcheck: + runs-on: ubuntu-latest + env: + GOFUMPT_VERSION: 0.3.1 + steps: + - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + - uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 + with: + go-version-file: .go-version + - run: | + go install "mvdan.cc/gofumpt@v${GOFUMPT_VERSION}" + make fmtcheck + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + - uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 + with: + go-version-file: .go-version + - run: make test + + integrationTest: + runs-on: ubuntu-latest + needs: [fmtcheck, test] + strategy: + fail-fast: false + matrix: + kind-k8s-version: [1.24.15, 1.25.11, 1.26.6, 1.27.3, 1.28.0] + enterprise: ["", "-ent"] + name: Integration test ${{ matrix.enterprise }} kind ${{ matrix.kind-k8s-version }} + steps: + - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + - name: Create K8s Kind Cluster + uses: helm/kind-action@dda0770415bac9fc20092cacbc54aa298604d140 # v1.8.0 + with: + version: v0.20.0 + cluster_name: vault-plugin-secrets-kubernetes + config: integrationtest/kind/config.yaml + node_image: kindest/node:v${{ matrix.kind-k8s-version }} + - uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 + with: + go-version-file: .go-version + - env: + VAULT_LICENSE_CI: ${{ secrets.VAULT_LICENSE_CI }} + run: make setup-integration-test${{ matrix.enterprise }} + - env: + INTEGRATION_TESTS: true + run: make integration-test TESTARGS="-v" diff --git a/builtin/logical/kubernetes/.gitignore b/builtin/logical/kubernetes/.gitignore new file mode 100644 index 0000000000..1f25dfaa73 --- /dev/null +++ b/builtin/logical/kubernetes/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +.idea +.vscode +pkg/* +bin/* diff --git a/builtin/logical/kubernetes/.go-version b/builtin/logical/kubernetes/.go-version new file mode 100644 index 0000000000..bae5c7f667 --- /dev/null +++ b/builtin/logical/kubernetes/.go-version @@ -0,0 +1 @@ +1.21.3 diff --git a/builtin/logical/kubernetes/CHANGELOG.md b/builtin/logical/kubernetes/CHANGELOG.md new file mode 100644 index 0000000000..c027b3717f --- /dev/null +++ b/builtin/logical/kubernetes/CHANGELOG.md @@ -0,0 +1,114 @@ +## Unreleased + +### Changes + +* Building with go 1.21.3 +* Testing with k8s 1.24-1.28 +* Dependency updates + * golang.org/x/crypto v0.13.0 -> v0.14.0 + * golang.org/x/net v0.15.0 -> v0.17.0 + * golang.org/x/sys v0.12.0 -> v0.13.0 + * golang.org/x/term v0.12.0 -> v0.13.0 + * github.com/docker/docker v24.0.5 -> v24.0.7 + * github.com/hashicorp/vault/sdk v0.10.0 -> v0.10.2 + * k8s.io/api v0.28.1 -> v0.28.3 + * k8s.io/apimachinery v0.28.1 -> v0.28.3 + * k8s.io/client-go v0.28.1 -> v0.28.3 + * github.com/go-jose/go-jose/v3 v3.0.0 -> v3.0.1 + +## 0.6.0 (September 6th, 2023) + +### Features: + +* update dependencies [GH-35](https://github.com/hashicorp/vault-plugin-secrets-kubernetes/pull/35) + * github.com/hashicorp/vault/api v1.10.0 + * github.com/hashicorp/vault/sdk v0.10.0 + * github.com/stretchr/testify v1.8.4 + * k8s.io/api v0.28.1 + * k8s.io/apimachinery v0.28.1 + * k8s.io/client-go v0.28.1 + * golang.org/x/net v0.15.0 + +### Changes + +* Testing with K8s versions 1.23-1.27 +* Building with Go 1.20.5 + +## 0.5.0 (May 25, 2023) + +### Features: + +* allow omitting `kubernetes_namespace` on token create for single namespace Vault roles [GH-27](https://github.com/hashicorp/vault-plugin-secrets-kubernetes/pull/27) +* update dependencies [GH-196](https://github.com/hashicorp/vault-plugin-secrets-kubernetes/pull/30) + * github.com/hashicorp/vault/api v1.9.1 + * github.com/stretchr/testify v1.8.3 + * k8s.io/api v0.27.2 + * k8s.io/apimachinery v0.27.2 + * k8s.io/client-go v0.27.2 + +## 0.4.0 (March 30, 2023) + +### Features: + +* add `audiences` option to set audiences for the k8s token created from the TokenRequest API, and add `token_default_audiences` +option to set the default audiences on role write [GH-24](https://github.com/hashicorp/vault-plugin-secrets-kubernetes/pull/24) + +### Changes: + +* enable plugin multiplexing [GH-23](https://github.com/hashicorp/vault-plugin-secrets-kubernetes/pull/23) +* update dependencies + * `github.com/hashicorp/vault/api` v1.9.0 + * `github.com/hashicorp/vault/sdk` v0.8.1 + * `github.com/hashicorp/go-hclog` v1.3.1 -> v1.5.0 + * `github.com/stretchr/testify` v1.8.1 -> v1.8.2 + * `k8s.io/api` v0.25.3 -> v0.26.3 + * `k8s.io/apimachinery` v0.25.3 -> v0.26.3 + * `k8s.io/client-go` v0.25.3 -> v0.26.3 + +## 0.3.0 (February 9, 2023) + +* Add `/check` endpoint to determine if environment variables are set [GH-18](https://github.com/hashicorp/vault-plugin-secrets-kubernetes/pull/18) + +### Changes + +* Update to Go 1.19 [GH-15](https://github.com/hashicorp/vault-plugin-secrets-kubernetes/pull/15) +* Update dependencies [GH-15](https://github.com/hashicorp/vault-plugin-secrets-kubernetes/pull/15): +| MODULE | VERSION | NEW VERSION | DIRECT | VALID TIMESTAMPS | +|---------------------------------|---------|-------------|--------|------------------| +| github.com/cenkalti/backoff/v3 | v3.0.0 | v3.2.2 | true | true | +| github.com/hashicorp/go-hclog | v0.16.2 | v1.3.1 | true | true | +| github.com/hashicorp/go-version | v1.2.0 | v1.6.0 | true | true | +| github.com/hashicorp/vault/api | v1.7.2 | v1.8.2 | true | true | +| github.com/hashicorp/vault/sdk | v0.5.3 | v0.6.1 | true | true | +| github.com/stretchr/testify | v1.8.0 | v1.8.1 | true | true | +| gopkg.in/square/go-jose.v2 | v2.5.1 | v2.6.0 | true | true | +| k8s.io/api | v0.22.2 | v0.25.3 | true | true | +| k8s.io/apimachinery | v0.22.2 | v0.25.3 | true | true | +| k8s.io/client-go | v0.22.2 | v0.25.3 | true | true | + +## 0.2.0 (September 15, 2022) + +### Changes + +* Test against k8s versions 1.22-25, vault-helm 0.22.0, and Vault 1.11.3 [[GH-14](https://github.com/hashicorp/vault-plugin-secrets-kubernetes/pull/14)] +* Use go 1.19.1 [[GH-14](https://github.com/hashicorp/vault-plugin-secrets-kubernetes/pull/14)] + +### Improvements + +* Test against Vault Enterprise [[GH-11](https://github.com/hashicorp/vault-plugin-secrets-kubernetes/pull/11)] +* Role namespace configuration possible via LabelSelector [[GH-10](https://github.com/hashicorp/vault-plugin-secrets-kubernetes/pull/10)] +* Update golang dependencies to avoid CVEs [[GH-14](https://github.com/hashicorp/vault-plugin-secrets-kubernetes/pull/14)] + * golang.org/x/crypto@v0.0.0-20220314234659-1baeb1ce4c0b + * golang.org/x/net@v0.0.0-20220906165146-f3363e06e74c + * golang.org/x/sys@v0.0.0-20220728004956-3c1f35247d10 + * github.com/stretchr/testify@v1.8.0 + +## 0.1.1 (May 26th, 2022) + +### Changes + +* Split `additional_metadata` into `extra_annotations` and `extra_labels` parameters [[GH-7](https://github.com/hashicorp/vault-plugin-secrets-kubernetes/pull/7)] + +## 0.1.0 (May 20th, 2022) + +Initial implementation [[GH-2](https://github.com/hashicorp/vault-plugin-secrets-kubernetes/pull/2)][[GH-3](https://github.com/hashicorp/vault-plugin-secrets-kubernetes/pull/3)][[GH-4](https://github.com/hashicorp/vault-plugin-secrets-kubernetes/pull/4)] diff --git a/builtin/logical/kubernetes/LICENSE b/builtin/logical/kubernetes/LICENSE new file mode 100644 index 0000000000..e75685c670 --- /dev/null +++ b/builtin/logical/kubernetes/LICENSE @@ -0,0 +1,365 @@ +Copyright (c) 2022 HashiCorp, Inc. + +Mozilla Public License, version 2.0 + +1. Definitions + +1.1. "Contributor" + + means each individual or legal entity that creates, contributes to the + creation of, or owns Covered Software. + +1.2. "Contributor Version" + + means the combination of the Contributions of others (if any) used by a + Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + + means Source Code Form to which the initial Contributor has attached the + notice in Exhibit A, the Executable Form of such Source Code Form, and + Modifications of such Source Code Form, in each case including portions + thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + a. that the initial Contributor has attached the notice described in + Exhibit B to the Covered Software; or + + b. that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the terms of + a Secondary License. + +1.6. "Executable Form" + + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + + means a work that combines Covered Software with other material, in a + separate file or files, that is not Covered Software. + +1.8. "License" + + means this document. + +1.9. "Licensable" + + means having the right to grant, to the maximum extent possible, whether + at the time of the initial grant or subsequently, any and all of the + rights conveyed by this License. + +1.10. "Modifications" + + means any of the following: + + a. any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered Software; or + + b. any new file in Source Code Form that contains any Covered Software. + +1.11. "Patent Claims" of a Contributor + + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the License, + by the making, using, selling, offering for sale, having made, import, + or transfer of either its Contributions or its Contributor Version. + +1.12. "Secondary License" + + means either the GNU General Public License, Version 2.0, the GNU Lesser + General Public License, Version 2.1, the GNU Affero General Public + License, Version 3.0, or any later versions of those licenses. + +1.13. "Source Code Form" + + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that controls, is + controlled by, or is under common control with You. For purposes of this + definition, "control" means (a) the power, direct or indirect, to cause + the direction or management of such entity, whether by contract or + otherwise, or (b) ownership of more than fifty percent (50%) of the + outstanding shares or beneficial ownership of such entity. + + +2. License Grants and Conditions + +2.1. Grants + + Each Contributor hereby grants You a world-wide, royalty-free, + non-exclusive license: + + a. under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + + b. under Patent Claims of such Contributor to make, use, sell, offer for + sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + + The licenses granted in Section 2.1 with respect to any Contribution + become effective for each Contribution on the date the Contributor first + distributes such Contribution. + +2.3. Limitations on Grant Scope + + The licenses granted in this Section 2 are the only rights granted under + this License. No additional rights or licenses will be implied from the + distribution or licensing of Covered Software under this License. + Notwithstanding Section 2.1(b) above, no patent license is granted by a + Contributor: + + a. for any code that a Contributor has removed from Covered Software; or + + b. for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + + c. under Patent Claims infringed by Covered Software in the absence of + its Contributions. + + This License does not grant any rights in the trademarks, service marks, + or logos of any Contributor (except as may be necessary to comply with + the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + + No Contributor makes additional grants as a result of Your choice to + distribute the Covered Software under a subsequent version of this + License (see Section 10.2) or under the terms of a Secondary License (if + permitted under the terms of Section 3.3). + +2.5. Representation + + Each Contributor represents that the Contributor believes its + Contributions are its original creation(s) or it has sufficient rights to + grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + + This License is not intended to limit any rights You have under + applicable copyright doctrines of fair use, fair dealing, or other + equivalents. + +2.7. Conditions + + Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in + Section 2.1. + + +3. Responsibilities + +3.1. Distribution of Source Form + + All distribution of Covered Software in Source Code Form, including any + Modifications that You create or to which You contribute, must be under + the terms of this License. You must inform recipients that the Source + Code Form of the Covered Software is governed by the terms of this + License, and how they can obtain a copy of this License. You may not + attempt to alter or restrict the recipients' rights in the Source Code + Form. + +3.2. Distribution of Executable Form + + If You distribute Covered Software in Executable Form then: + + a. such Covered Software must also be made available in Source Code Form, + as described in Section 3.1, and You must inform recipients of the + Executable Form how they can obtain a copy of such Source Code Form by + reasonable means in a timely manner, at a charge no more than the cost + of distribution to the recipient; and + + b. You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter the + recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + + You may create and distribute a Larger Work under terms of Your choice, + provided that You also comply with the requirements of this License for + the Covered Software. If the Larger Work is a combination of Covered + Software with a work governed by one or more Secondary Licenses, and the + Covered Software is not Incompatible With Secondary Licenses, this + License permits You to additionally distribute such Covered Software + under the terms of such Secondary License(s), so that the recipient of + the Larger Work may, at their option, further distribute the Covered + Software under the terms of either this License or such Secondary + License(s). + +3.4. Notices + + You may not remove or alter the substance of any license notices + (including copyright notices, patent notices, disclaimers of warranty, or + limitations of liability) contained within the Source Code Form of the + Covered Software, except that You may alter any license notices to the + extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + + You may choose to offer, and to charge a fee for, warranty, support, + indemnity or liability obligations to one or more recipients of Covered + Software. However, You may do so only on Your own behalf, and not on + behalf of any Contributor. You must make it absolutely clear that any + such warranty, support, indemnity, or liability obligation is offered by + You alone, and You hereby agree to indemnify every Contributor for any + liability incurred by such Contributor as a result of warranty, support, + indemnity or liability terms You offer. You may include additional + disclaimers of warranty and limitations of liability specific to any + jurisdiction. + +4. Inability to Comply Due to Statute or Regulation + + If it is impossible for You to comply with any of the terms of this License + with respect to some or all of the Covered Software due to statute, + judicial order, or regulation then You must: (a) comply with the terms of + this License to the maximum extent possible; and (b) describe the + limitations and the code they affect. Such description must be placed in a + text file included with all distributions of the Covered Software under + this License. Except to the extent prohibited by statute or regulation, + such description must be sufficiently detailed for a recipient of ordinary + skill to be able to understand it. + +5. Termination + +5.1. The rights granted under this License will terminate automatically if You + fail to comply with any of its terms. However, if You become compliant, + then the rights granted under this License from a particular Contributor + are reinstated (a) provisionally, unless and until such Contributor + explicitly and finally terminates Your grants, and (b) on an ongoing + basis, if such Contributor fails to notify You of the non-compliance by + some reasonable means prior to 60 days after You have come back into + compliance. Moreover, Your grants from a particular Contributor are + reinstated on an ongoing basis if such Contributor notifies You of the + non-compliance by some reasonable means, this is the first time You have + received notice of non-compliance with this License from such + Contributor, and You become compliant prior to 30 days after Your receipt + of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent + infringement claim (excluding declaratory judgment actions, + counter-claims, and cross-claims) alleging that a Contributor Version + directly or indirectly infringes any patent, then the rights granted to + You by any and all Contributors for the Covered Software under Section + 2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user + license agreements (excluding distributors and resellers) which have been + validly granted by You or Your distributors under this License prior to + termination shall survive termination. + +6. Disclaimer of Warranty + + Covered Software is provided under this License on an "as is" basis, + without warranty of any kind, either expressed, implied, or statutory, + including, without limitation, warranties that the Covered Software is free + of defects, merchantable, fit for a particular purpose or non-infringing. + The entire risk as to the quality and performance of the Covered Software + is with You. Should any Covered Software prove defective in any respect, + You (not any Contributor) assume the cost of any necessary servicing, + repair, or correction. This disclaimer of warranty constitutes an essential + part of this License. No use of any Covered Software is authorized under + this License except under this disclaimer. + +7. Limitation of Liability + + Under no circumstances and under no legal theory, whether tort (including + negligence), contract, or otherwise, shall any Contributor, or anyone who + distributes Covered Software as permitted above, be liable to You for any + direct, indirect, special, incidental, or consequential damages of any + character including, without limitation, damages for lost profits, loss of + goodwill, work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses, even if such party shall have been + informed of the possibility of such damages. This limitation of liability + shall not apply to liability for death or personal injury resulting from + such party's negligence to the extent applicable law prohibits such + limitation. Some jurisdictions do not allow the exclusion or limitation of + incidental or consequential damages, so this exclusion and limitation may + not apply to You. + +8. Litigation + + Any litigation relating to this License may be brought only in the courts + of a jurisdiction where the defendant maintains its principal place of + business and such litigation shall be governed by laws of that + jurisdiction, without reference to its conflict-of-law provisions. Nothing + in this Section shall prevent a party's ability to bring cross-claims or + counter-claims. + +9. Miscellaneous + + This License represents the complete agreement concerning the subject + matter hereof. If any provision of this License is held to be + unenforceable, such provision shall be reformed only to the extent + necessary to make it enforceable. Any law or regulation which provides that + the language of a contract shall be construed against the drafter shall not + be used to construe this License against a Contributor. + + +10. Versions of the License + +10.1. New Versions + + Mozilla Foundation is the license steward. Except as provided in Section + 10.3, no one other than the license steward has the right to modify or + publish new versions of this License. Each version will be given a + distinguishing version number. + +10.2. Effect of New Versions + + You may distribute the Covered Software under the terms of the version + of the License under which You originally received the Covered Software, + or under the terms of any subsequent version published by the license + steward. + +10.3. Modified Versions + + If you create software not governed by this License, and you want to + create a new license for such software, you may create and use a + modified version of this License if you rename the license and remove + any references to the name of the license steward (except to note that + such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary + Licenses If You choose to distribute Source Code Form that is + Incompatible With Secondary Licenses under the terms of this version of + the License, the notice described in Exhibit B of this License must be + attached. + +Exhibit A - Source Code Form License Notice + + This Source Code Form is subject to the + terms of the Mozilla Public License, v. + 2.0. If a copy of the MPL was not + distributed with this file, You can + obtain one at + http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular file, +then You may include the notice in a location (such as a LICENSE file in a +relevant directory) where a recipient would be likely to look for such a +notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice + + This Source Code Form is "Incompatible + With Secondary Licenses", as defined by + the Mozilla Public License, v. 2.0. + diff --git a/builtin/logical/kubernetes/Makefile b/builtin/logical/kubernetes/Makefile new file mode 100644 index 0000000000..db967a6f21 --- /dev/null +++ b/builtin/logical/kubernetes/Makefile @@ -0,0 +1,112 @@ +# kind cluster name +KIND_CLUSTER_NAME?=vault-plugin-secrets-kubernetes + +# kind k8s version +KIND_K8S_VERSION?=v1.26.2 + +PKG=github.com/hashicorp/vault-plugin-secrets-kubernetes +LDFLAGS?="-X '$(PKG).WALRollbackMinAge=10s'" + +RUNNER_TEMP ?= $(TMPDIR) + +.PHONY: default +default: dev + +# dev target sets WALRollbackMinAge to 10s instead of the default 10 minutes to speed up integration tests +.PHONY: dev +dev: + CGO_ENABLED=0 go build -ldflags $(LDFLAGS) -o bin/vault-plugin-secrets-kubernetes cmd/vault-plugin-secrets-kubernetes/main.go + +.PHONY: test +test: fmtcheck + CGO_ENABLED=0 go test ./... $(TESTARGS) -timeout=20m + +.PHONY: integration-test +integration-test: + INTEGRATION_TESTS=true KIND_CLUSTER_NAME=$(KIND_CLUSTER_NAME) CGO_ENABLED=0 go test github.com/hashicorp/vault-plugin-secrets-kubernetes/integrationtest/... $(TESTARGS) -count=1 -timeout=40m + +.PHONY: fmtcheck +fmtcheck: + @sh -c "'$(CURDIR)/scripts/gofmtcheck.sh'" + +.PHONY: fmt +fmt: + gofumpt -l -w . + +.PHONY: setup-kind +# create a kind cluster for running the acceptance tests locally +setup-kind: + kind get clusters | grep --silent "^${KIND_CLUSTER_NAME}$$" || \ + kind create cluster \ + --image kindest/node:${KIND_K8S_VERSION} \ + --name ${KIND_CLUSTER_NAME} \ + --config $(CURDIR)/integrationtest/kind/config.yaml + kubectl config use-context kind-${KIND_CLUSTER_NAME} + +.PHONY: delete-kind +# delete the kind cluster +delete-kind: + kind delete cluster --name ${KIND_CLUSTER_NAME} || true + +.PHONY: vault-image +vault-image: + GOOS=linux make dev + docker build -f integrationtest/vault/Dockerfile bin/ --tag=hashicorp/vault:dev + +.PHONY: vault-image-ent +vault-image-ent: + GOOS=linux make dev + docker build -f integrationtest/vault/Dockerfile --target enterprise bin/ --tag=hashicorp/vault:dev + +# Create Vault inside the cluster with a locally-built version of kubernetes secrets. +.PHONY: setup-integration-test-common +setup-integration-test-common: SET_LICENSE=$(if $(VAULT_LICENSE_CI),--set server.enterpriseLicense.secretName=vault-license) +setup-integration-test-common: teardown-integration-test + kind --name ${KIND_CLUSTER_NAME} load docker-image hashicorp/vault:dev + kubectl create namespace test + kubectl label namespaces test target=integration-test other=label + + # don't log the license + printenv VAULT_LICENSE_CI > $(RUNNER_TEMP)/vault-license.txt || true + if [ -s $(RUNNER_TEMP)/vault-license.txt ]; then \ + kubectl -n test create secret generic vault-license --from-file license=$(RUNNER_TEMP)/vault-license.txt; \ + rm -rf $(RUNNER_TEMP)/vault-license.txt; \ + fi + + helm install vault vault --repo https://helm.releases.hashicorp.com --version=0.24.1 \ + --wait --timeout=5m \ + --namespace=test \ + --set server.logLevel=debug \ + --set server.dev.enabled=true \ + --set server.image.tag=dev \ + --set server.image.pullPolicy=Never \ + --set injector.enabled=false \ + $(SET_LICENSE) \ + --set server.extraArgs="-dev-plugin-dir=/vault/plugin_directory" + kubectl patch --namespace=test statefulset vault --patch-file integrationtest/vault/hostPortPatch.yaml + kubectl apply --namespace=test -f integrationtest/vault/testRoles.yaml + kubectl apply --namespace=test -f integrationtest/vault/testServiceAccounts.yaml + kubectl apply --namespace=test -f integrationtest/vault/testBindings.yaml + + kubectl delete --namespace=test pod vault-0 + kubectl wait --namespace=test --for=condition=Ready --timeout=5m pod -l app.kubernetes.io/name=vault + +.PHONY: setup-integration-test +setup-integration-test: vault-image setup-integration-test-common + +.PHONY: setup-integration-test-ent +setup-integration-test-ent: check-license vault-image-ent setup-integration-test-common + +.PHONY: check-license +check-license: + (printenv VAULT_LICENSE_CI > /dev/null) || (echo "VAULT_LICENSE_CI must be set"; exit 1) + +.PHONY: teardown-integration-test +teardown-integration-test: + helm uninstall vault --namespace=test || true + kubectl delete --ignore-not-found namespace test + # kubectl delete --ignore-not-found clusterrolebinding vault-crb + # kubectl delete --ignore-not-found clusterrole k8s-clusterrole + kubectl delete --ignore-not-found --namespace=test -f integrationtest/vault/testBindings.yaml + kubectl delete --ignore-not-found --namespace=test -f integrationtest/vault/testServiceAccounts.yaml + kubectl delete --ignore-not-found --namespace=test -f integrationtest/vault/testRoles.yaml diff --git a/builtin/logical/kubernetes/README.md b/builtin/logical/kubernetes/README.md new file mode 100644 index 0000000000..9965ea03af --- /dev/null +++ b/builtin/logical/kubernetes/README.md @@ -0,0 +1,132 @@ +# Vault Plugin: Kubernetes Secrets Backend + +This is a standalone backend plugin for use with [Hashicorp Vault](https://www.github.com/hashicorp/vault). +This plugin generates Kubernetes Service Accounts. + +**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). + +## Quick Links + +- Vault Website: [https://www.vaultproject.io] +- Kubernetes Secrets Docs: [https://www.vaultproject.io/docs/secrets/kubernetes.html] +- Main Project Github: [https://www.github.com/hashicorp/vault] + +## Getting Started + +This is a [Vault plugin](https://www.vaultproject.io/docs/plugins/plugin-architecture#plugin-catalogs) +and is meant to work with Vault. This guide assumes you have already installed Vault +and have a basic understanding of how Vault works. + +Otherwise, first read this guide on how to [get started with Vault](https://www.vaultproject.io/intro/getting-started/install.html). + +To learn specifically about how plugins work, see documentation on [Vault plugins](https://www.vaultproject.io/docs/plugins/plugin-architecture#plugin-catalog). + +## Security Model + +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. + +We expect Kubernetes to support less sensitive mechanisms in the future, and the Vault integration will be updated to use those mechanisms when available. + +## Usage + +Please see [documentation for the plugin](https://www.vaultproject.io/docs/secrets/kubernetes) +on the Vault website. + +This plugin is currently built into Vault and by default is accessed +at `secrets/kubernetes`. To enable this in a running Vault server: + +```sh +$ vault secrets enable kubernetes +Successfully enabled 'kubernetes' at 'kubernetes'! +``` + +To see all the supported paths, see the [Kubernetes secrets API docs](https://www.vaultproject.io/api-docs/secrets/kubernetes). + +## Developing + +If you wish to work on this plugin, you'll first need +[Go](https://www.golang.org) installed on your machine. + +To compile a development version of this plugin, run `make` or `make dev`. +This will put the plugin binary in the `bin` and `$GOPATH/bin` folders. `dev` +mode will only generate the binary for your platform and is faster: + +```sh +make +make dev +``` + +Put the plugin binary into a location of your choice. This directory +will be specified as the [`plugin_directory`](https://www.vaultproject.io/docs/configuration#plugin_directory) +in the Vault config used to start the server. + +```hcl +... +plugin_directory = "path/to/plugin/directory" +... +``` + +Start a Vault server with this config file: + +```sh +$ vault server -config=path/to/config.hcl ... +... +``` + +Once the server is started, register the plugin in the Vault server's [plugin catalog](https://www.vaultproject.io/docs/plugins/plugin-architecture#plugin-catalog): + +```sh +$ vault plugin register \ + -sha256= \ + -command="vault-plugin-secrets-kubernetes" \ + secret kubernetes +... +Success! Data written to: sys/plugins/catalog/kubernetes +``` + +Note you should generate a new sha256 checksum if you have made changes +to the plugin. Example using openssl: + +```sh +openssl dgst -sha256 $GOPATH/vault-plugin-secrets-kubernetes +... +SHA256(.../go/bin/vault-plugin-secrets-kubernetes)= 896c13c0f5305daed381952a128322e02bc28a57d0c862a78cbc2ea66e8c6fa1 +``` + +Enable the secrets plugin backend using the Kubernetes secrets plugin: + +```sh +$ vault secrets enable kubernetes +... + +Successfully enabled 'plugin' at 'kubernetes'! +``` + +### Tests + +If you are developing this plugin and want to verify it is still +functioning (and you haven't broken anything else), we recommend +running the tests. + +To run the tests, invoke `make test`: + +```sh +make test +``` + +You can also specify a `TESTARGS` variable to filter tests like so: + +```sh +make test TESTARGS='--run=TestConfig' +``` + +To run integration tests, you'll need [`kind`](https://kind.sigs.k8s.io/) installed. + +```sh +# Create the Kubernetes cluster for testing in +make setup-kind +# Build the plugin and register it with a Vault instance running in the cluster +make setup-integration-test +# Run the integration tests against Vault inside the cluster +make integration-test +``` diff --git a/builtin/logical/kubernetes/backend.go b/builtin/logical/kubernetes/backend.go new file mode 100644 index 0000000000..f95a326640 --- /dev/null +++ b/builtin/logical/kubernetes/backend.go @@ -0,0 +1,134 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package kubesecrets + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/hashicorp/go-secure-stdlib/fileutil" + "github.com/openbao/openbao/sdk/framework" + "github.com/openbao/openbao/sdk/logical" +) + +var ( + // jwtReloadPeriod is the time period how often the in-memory copy of local + // service account token can be used, before reading it again from disk. + // + // The value is selected according to recommendation in Kubernetes 1.21 changelog: + // "Clients should reload the token from disk periodically (once per minute + // is recommended) to ensure they continue to use a valid token." + jwtReloadPeriod = 1 * time.Minute + + // caReloadPeriod is the time period how often the in-memory copy of local + // CA cert can be used, before reading it again from disk. + caReloadPeriod = 1 * time.Hour + + // operationPrefixKubernetes is used as a prefix for OpenAPI operation id's. + operationPrefixKubernetes = "kubernetes" + + WALRollbackMinAge = "10m" +) + +// backend wraps the backend framework and adds a map for storing key value pairs +type backend struct { + *framework.Backend + lock sync.Mutex + client *client + + // localSATokenReader caches the service account token in memory. + // It periodically reloads the token to support token rotation/renewal. + // Local token is used when running in a pod with following configuration + // - token_reviewer_jwt is not set + // - disable_local_ca_jwt is false + localSATokenReader *fileutil.CachingFileReader + + // localCACertReader contains the local CA certificate. Local CA certificate is + // used when running in a pod with following configuration + // - kubernetes_ca_cert is not set + // - disable_local_ca_jwt is false + localCACertReader *fileutil.CachingFileReader +} + +var _ logical.Factory = Factory + +// Factory configures and returns Mock backends +func Factory(ctx context.Context, conf *logical.BackendConfig) (logical.Backend, error) { + b, err := newBackend() + if err != nil { + return nil, err + } + + if conf == nil { + return nil, fmt.Errorf("configuration passed into backend is nil") + } + + if err := b.Setup(ctx, conf); err != nil { + return nil, err + } + + return b, nil +} + +func newBackend() (*backend, error) { + b := &backend{ + localSATokenReader: fileutil.NewCachingFileReader(localJWTPath, jwtReloadPeriod), + localCACertReader: fileutil.NewCachingFileReader(localCACertPath, caReloadPeriod), + } + + walRollbackMinAge, err := time.ParseDuration(WALRollbackMinAge) + if err != nil { + return nil, err + } + + b.Backend = &framework.Backend{ + BackendType: logical.TypeLogical, + Help: strings.TrimSpace(backendHelp), + Invalidate: b.invalidate, + Paths: framework.PathAppend( + []*framework.Path{ + b.pathConfig(), + b.pathCredentials(), + b.pathCheck(), + }, + b.pathRoles(), + ), + PathsSpecial: &logical.Paths{ + LocalStorage: []string{ + framework.WALPrefix, + }, + SealWrapStorage: []string{ + "config", + }, + }, + Secrets: []*framework.Secret{ + b.kubeServiceAccount(), + }, + WALRollback: b.walRollback, + WALRollbackMinAge: walRollbackMinAge, + } + + return b, nil +} + +// This resets anything that needs to be rebuilt after a change. In our case, +// the k8s client if the config is changed. +func (b *backend) invalidate(_ context.Context, key string) { + if key == "config" { + b.reset() + } +} + +func (b *backend) reset() { + b.lock.Lock() + defer b.lock.Unlock() + b.client = nil +} + +const backendHelp = ` +The Kubernetes Secret Engine generates Kubernetes service account tokens with associated roles and role bindings. +` diff --git a/builtin/logical/kubernetes/backend_test.go b/builtin/logical/kubernetes/backend_test.go new file mode 100644 index 0000000000..23af89e286 --- /dev/null +++ b/builtin/logical/kubernetes/backend_test.go @@ -0,0 +1,37 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package kubesecrets + +import ( + "context" + "testing" + "time" + + "github.com/hashicorp/go-hclog" + "github.com/openbao/openbao/sdk/helper/logging" + "github.com/openbao/openbao/sdk/logical" +) + +var ( + defaultLeaseTTLVal = time.Hour * 12 + maxLeaseTTLVal = time.Hour * 24 +) + +func getTestBackend(t *testing.T) (*backend, logical.Storage) { + t.Helper() + + config := logical.TestBackendConfig() + config.StorageView = new(logical.InmemStorage) + config.Logger = logging.NewVaultLogger(hclog.Trace) + config.System = &logical.StaticSystemView{ + DefaultLeaseTTLVal: defaultLeaseTTLVal, + MaxLeaseTTLVal: maxLeaseTTLVal, + } + + b, err := Factory(context.Background(), config) + if err != nil { + t.Fatal(err) + } + return b.(*backend), config.StorageView +} diff --git a/builtin/logical/kubernetes/client.go b/builtin/logical/kubernetes/client.go new file mode 100644 index 0000000000..9b50dde5ff --- /dev/null +++ b/builtin/logical/kubernetes/client.go @@ -0,0 +1,272 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package kubesecrets + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + authenticationv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + v1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + k8s_errors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8s_yaml "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +var standardLabels = map[string]string{ + "app.kubernetes.io/managed-by": "HashiCorp-Vault", + "app.kubernetes.io/created-by": "vault-plugin-secrets-kubernetes", +} + +type client struct { + k8s kubernetes.Interface +} + +func newClient(config *kubeConfig) (*client, error) { + if config == nil { + return nil, errors.New("client configuration was nil") + } + + clientConfig := rest.Config{ + Host: config.Host, + BearerToken: config.ServiceAccountJwt, + } + if config.CACert != "" { + clientConfig.TLSClientConfig.CAData = []byte(config.CACert) + } + k8sClient, err := kubernetes.NewForConfig(&clientConfig) + if err != nil { + return nil, err + } + return &client{k8sClient}, nil +} + +func (c *client) createToken(ctx context.Context, namespace, name string, ttl time.Duration, audiences []string) (*authenticationv1.TokenRequestStatus, error) { + intTTL := int64(ttl.Seconds()) + resp, err := c.k8s.CoreV1().ServiceAccounts(namespace).CreateToken(ctx, name, &authenticationv1.TokenRequest{ + Spec: authenticationv1.TokenRequestSpec{ + ExpirationSeconds: &intTTL, + Audiences: audiences, + }, + }, metav1.CreateOptions{}) + if err != nil { + return nil, err + } + + c.k8s.CoreV1().ServiceAccounts(namespace) + return &resp.Status, nil +} + +func (c *client) createServiceAccount(ctx context.Context, namespace, name string, vaultRole *roleEntry, ownerRef metav1.OwnerReference) (*v1.ServiceAccount, error) { + // Set standardLabels last so that users can't override them + labels := combineMaps(vaultRole.ExtraLabels, standardLabels) + serviceAccountConfig := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: labels, + Annotations: vaultRole.ExtraAnnotations, + OwnerReferences: []metav1.OwnerReference{ownerRef}, + }, + } + return c.k8s.CoreV1().ServiceAccounts(namespace).Create(ctx, serviceAccountConfig, metav1.CreateOptions{}) +} + +func (c *client) deleteServiceAccount(ctx context.Context, namespace, name string) error { + err := c.k8s.CoreV1().ServiceAccounts(namespace).Delete(ctx, name, metav1.DeleteOptions{}) + if err != nil && !k8s_errors.IsNotFound(err) { + return err + } + return nil +} + +func (c *client) createRole(ctx context.Context, namespace, name string, vaultRole *roleEntry) (metav1.OwnerReference, error) { + thisOwnerRef := metav1.OwnerReference{ + APIVersion: "rbac.authorization.k8s.io/v1", + Name: name, + } + roleRules, err := makeRules(vaultRole.RoleRules) + if err != nil { + return thisOwnerRef, err + } + // Set standardLabels last so that users can't override them + labels := combineMaps(vaultRole.ExtraLabels, standardLabels) + objectMeta := metav1.ObjectMeta{ + Name: name, + Labels: labels, + Annotations: vaultRole.ExtraAnnotations, + } + + switch vaultRole.K8sRoleType { + case "Role": + objectMeta.Namespace = namespace + roleConfig := &rbacv1.Role{ + ObjectMeta: objectMeta, + Rules: roleRules, + } + resp, err := c.k8s.RbacV1().Roles(namespace).Create(ctx, roleConfig, metav1.CreateOptions{}) + if resp != nil { + thisOwnerRef.Kind = "Role" + thisOwnerRef.UID = resp.UID + } + return thisOwnerRef, err + + case "ClusterRole": + roleConfig := &rbacv1.ClusterRole{ + ObjectMeta: objectMeta, + Rules: roleRules, + } + resp, err := c.k8s.RbacV1().ClusterRoles().Create(ctx, roleConfig, metav1.CreateOptions{}) + if resp != nil { + thisOwnerRef.Kind = "ClusterRole" + thisOwnerRef.UID = resp.UID + } + return thisOwnerRef, err + + default: + return thisOwnerRef, fmt.Errorf("unknown role type '%s'", vaultRole.K8sRoleType) + } +} + +func (c *client) deleteRole(ctx context.Context, namespace, name, roleType string) error { + var err error + switch roleType { + case "Role": + err = c.k8s.RbacV1().Roles(namespace).Delete(ctx, name, metav1.DeleteOptions{}) + case "ClusterRole": + err = c.k8s.RbacV1().ClusterRoles().Delete(ctx, name, metav1.DeleteOptions{}) + default: + return fmt.Errorf("unsupported role type '%s'", roleType) + } + if err != nil && !k8s_errors.IsNotFound(err) { + return err + } + return nil +} + +func (c *client) createRoleBinding(ctx context.Context, namespace, name, k8sRoleName string, isClusterRoleBinding bool, vaultRole *roleEntry, ownerRef *metav1.OwnerReference) (metav1.OwnerReference, error) { + thisOwnerRef := metav1.OwnerReference{ + APIVersion: "rbac.authorization.k8s.io/v1", + Name: name, + } + // Set standardLabels last so that users can't override them + labels := combineMaps(vaultRole.ExtraLabels, standardLabels) + objectMeta := metav1.ObjectMeta{ + Name: name, + Labels: labels, + Annotations: vaultRole.ExtraAnnotations, + } + if ownerRef != nil { + objectMeta.OwnerReferences = []metav1.OwnerReference{*ownerRef} + } + subjects := []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: name, + Namespace: namespace, + }, + } + roleRef := rbacv1.RoleRef{ + Kind: vaultRole.K8sRoleType, + Name: k8sRoleName, + } + + if isClusterRoleBinding { + roleConfig := &rbacv1.ClusterRoleBinding{ + ObjectMeta: objectMeta, + Subjects: subjects, + RoleRef: roleRef, + } + resp, err := c.k8s.RbacV1().ClusterRoleBindings().Create(ctx, roleConfig, metav1.CreateOptions{}) + if resp != nil { + thisOwnerRef.Kind = "ClusterRoleBinding" + thisOwnerRef.UID = resp.UID + } + return thisOwnerRef, err + } + + objectMeta.Namespace = namespace + roleConfig := &rbacv1.RoleBinding{ + ObjectMeta: objectMeta, + Subjects: subjects, + RoleRef: roleRef, + } + resp, err := c.k8s.RbacV1().RoleBindings(namespace).Create(ctx, roleConfig, metav1.CreateOptions{}) + if resp != nil { + thisOwnerRef.Kind = "RoleBinding" + thisOwnerRef.UID = resp.UID + } + return thisOwnerRef, err +} + +func (c *client) deleteRoleBinding(ctx context.Context, namespace, name string, isClusterRoleBinding bool) error { + var err error + if isClusterRoleBinding { + err = c.k8s.RbacV1().ClusterRoleBindings().Delete(ctx, name, metav1.DeleteOptions{}) + } else { + err = c.k8s.RbacV1().RoleBindings(namespace).Delete(ctx, name, metav1.DeleteOptions{}) + } + if err != nil && !k8s_errors.IsNotFound(err) { + return err + } + return nil +} + +func (c *client) getNamespaceLabelSet(ctx context.Context, namespace string) (map[string]string, error) { + ns, err := c.k8s.CoreV1().Namespaces().Get(ctx, namespace, metav1.GetOptions{}) + if err != nil { + return map[string]string{}, err + } + return ns.Labels, nil +} + +func makeRules(rules string) ([]rbacv1.PolicyRule, error) { + policyRules := struct { + Rules []rbacv1.PolicyRule `json:"rules"` + }{} + decoder := k8s_yaml.NewYAMLOrJSONDecoder(strings.NewReader(rules), len(rules)) + err := decoder.Decode(&policyRules) + if err != nil { + return nil, err + } + return policyRules.Rules, nil +} + +func makeLabelSelector(selector string) (metav1.LabelSelector, error) { + labelSelector := metav1.LabelSelector{} + decoder := k8s_yaml.NewYAMLOrJSONDecoder(strings.NewReader(selector), len(selector)) + err := decoder.Decode(&labelSelector) + if err != nil { + return labelSelector, err + } + return labelSelector, nil +} + +func makeRoleType(roleType string) string { + switch strings.ToLower(roleType) { + case "role": + return "Role" + case "clusterrole": + return "ClusterRole" + default: + return roleType + } +} + +func combineMaps(maps ...map[string]string) map[string]string { + newMap := make(map[string]string) + for _, m := range maps { + for k, v := range m { + newMap[k] = v + } + } + return newMap +} diff --git a/builtin/logical/kubernetes/client_test.go b/builtin/logical/kubernetes/client_test.go new file mode 100644 index 0000000000..d8ec648d20 --- /dev/null +++ b/builtin/logical/kubernetes/client_test.go @@ -0,0 +1,64 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package kubesecrets + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + rbacv1 "k8s.io/api/rbac/v1" +) + +func Test_makeRules(t *testing.T) { + testCases := map[string]struct { + rules string + expected []rbacv1.PolicyRule + wantErr error + }{ + "good YAML": { + rules: goodYAMLRules, + expected: []rbacv1.PolicyRule{ + { + APIGroups: []string{"admissionregistration.k8s.io"}, + Resources: []string{"mutatingwebhookconfigurations"}, + Verbs: []string{"get", "list", "watch", "patch"}, + }, + }, + wantErr: nil, + }, + "good JSON": { + rules: goodJSONRules, + expected: []rbacv1.PolicyRule{ + { + APIGroups: []string{"admissionregistration.k8s.io"}, + Resources: []string{"mutatingwebhookconfigurations"}, + Verbs: []string{"get", "list", "watch", "patch"}, + }, + }, + wantErr: nil, + }, + "bad YAML": { + rules: badYAMLRules, + expected: nil, + wantErr: fmt.Errorf("error converting YAML to JSON: yaml: line 3: found character that cannot start any token"), + }, + "bad JSON": { + rules: badJSONRules, + expected: nil, + wantErr: fmt.Errorf("error converting YAML to JSON: yaml: line 4: did not find expected ',' or '}'"), + }, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + result, err := makeRules(tc.rules) + if tc.wantErr != nil { + assert.EqualError(t, err, tc.wantErr.Error()) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/builtin/logical/kubernetes/cmd/kubernetes/main.go b/builtin/logical/kubernetes/cmd/kubernetes/main.go new file mode 100644 index 0000000000..e3fe262ca9 --- /dev/null +++ b/builtin/logical/kubernetes/cmd/kubernetes/main.go @@ -0,0 +1,35 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package main + +import ( + "os" + + "github.com/hashicorp/go-hclog" + "github.com/openbao/openbao/api" + kubesecrets "github.com/openbao/openbao/builtin/logical/kubernetes" + "github.com/openbao/openbao/sdk/plugin" +) + +func main() { + apiClientMeta := &api.PluginAPIClientMeta{} + flags := apiClientMeta.FlagSet() + flags.Parse(os.Args[1:]) + + tlsConfig := apiClientMeta.GetTLSConfig() + tlsProviderFunc := api.VaultPluginTLSProvider(tlsConfig) + + err := plugin.ServeMultiplex(&plugin.ServeOpts{ + BackendFactoryFunc: kubesecrets.Factory, + // set the TLSProviderFunc so that the plugin maintains backwards + // compatibility with Vault versions that don’t support plugin AutoMTLS + TLSProviderFunc: tlsProviderFunc, + }) + if err != nil { + logger := hclog.New(&hclog.LoggerOptions{}) + + logger.Error("plugin shutting down", "error", err) + os.Exit(1) + } +} diff --git a/builtin/logical/kubernetes/integrationtest/creds_integration_test.go b/builtin/logical/kubernetes/integrationtest/creds_integration_test.go new file mode 100644 index 0000000000..c6774f318b --- /dev/null +++ b/builtin/logical/kubernetes/integrationtest/creds_integration_test.go @@ -0,0 +1,561 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package integrationtest + +import ( + "fmt" + "testing" + + "github.com/openbao/openbao/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Test token ttl handling and defaults +func TestCreds_ttl(t *testing.T) { + // Pick up VAULT_ADDR and VAULT_TOKEN from env vars + client, err := api.NewClient(nil) + if err != nil { + t.Fatal(err) + } + + path, umount := mountHelper(t, client) + defer umount() + client, delNamespace := namespaceHelper(t, client) + defer delNamespace() + + // create default config + _, err = client.Logical().Write(path+"/config", map[string]interface{}{}) + require.NoError(t, err) + + type testCase struct { + roleConfig map[string]interface{} + credsConfig map[string]interface{} + expectedTTLSec int + } + + tests := map[string]testCase{ + "both set": { + roleConfig: map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"*"}, + "service_account_name": "sample-app", + "token_default_ttl": "4h", + "token_max_ttl": "24h", + }, + credsConfig: map[string]interface{}{ + "kubernetes_namespace": "test", + "ttl": "2h", + }, + expectedTTLSec: 7200, + }, + "default to token_default_ttl": { + roleConfig: map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"*"}, + "service_account_name": "sample-app", + "token_default_ttl": "4h", + "token_max_ttl": "24h", + }, + credsConfig: map[string]interface{}{ + "kubernetes_namespace": "test", + }, + expectedTTLSec: 14400, + }, + "capped to token_max_ttl from system default": { + roleConfig: map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"*"}, + "service_account_name": "sample-app", + "token_max_ttl": "24h", + }, + credsConfig: map[string]interface{}{ + "kubernetes_namespace": "test", + }, + expectedTTLSec: 86400, + }, + "default to system ttl": { + roleConfig: map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"*"}, + "service_account_name": "sample-app", + }, + credsConfig: map[string]interface{}{ + "kubernetes_namespace": "test", + }, + expectedTTLSec: 2764800, + }, + "token_default_ttl higher than the system max ttl": { + roleConfig: map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"*"}, + "service_account_name": "sample-app", + "token_default_ttl": "2764801", + }, + credsConfig: map[string]interface{}{ + "kubernetes_namespace": "test", + }, + expectedTTLSec: 2764800, + }, + "token_max_ttl higher than the system max ttl": { + roleConfig: map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"*"}, + "service_account_name": "sample-app", + "token_max_ttl": "3700000", + }, + credsConfig: map[string]interface{}{ + "kubernetes_namespace": "test", + "ttl": "2764801", + }, + expectedTTLSec: 2764800, + }, + } + i := 0 + for n, tc := range tests { + t.Run(n, func(t *testing.T) { + roleName := fmt.Sprintf("testrole-%d", i) + _, err = client.Logical().Write(path+"/roles/"+roleName, tc.roleConfig) + assert.NoError(t, err) + + creds, err := client.Logical().Write(path+"/creds/"+roleName, tc.credsConfig) + assert.NoError(t, err) + require.NotNil(t, creds) + assert.Equal(t, tc.expectedTTLSec, creds.LeaseDuration) + + // check k8s token expiry + testK8sTokenTTL(t, tc.expectedTTLSec, creds.Data["service_account_token"].(string)) + }) + i = i + 1 + } +} + +// Test token audiences handling and defaults +func TestCreds_audiences(t *testing.T) { + // Pick up VAULT_ADDR and VAULT_TOKEN from env vars + client, err := api.NewClient(nil) + if err != nil { + t.Fatal(err) + } + + path, umount := mountHelper(t, client) + defer umount() + client, delNamespace := namespaceHelper(t, client) + defer delNamespace() + + // create default config + _, err = client.Logical().Write(path+"/config", map[string]interface{}{}) + require.NoError(t, err) + + type testCase struct { + roleConfig map[string]interface{} + credsConfig map[string]interface{} + expectedAudiences []interface{} + } + + tests := map[string]testCase{ + "both set": { + roleConfig: map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"*"}, + "service_account_name": "sample-app", + "token_default_audiences": []string{"foo", "bar"}, + }, + credsConfig: map[string]interface{}{ + "kubernetes_namespace": "test", + "audiences": "baz,qux", + }, + expectedAudiences: []interface{}{"baz", "qux"}, + }, + "default to token_default_audiences": { + roleConfig: map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"*"}, + "service_account_name": "sample-app", + "token_default_audiences": []string{"foo", "bar"}, + }, + credsConfig: map[string]interface{}{ + "kubernetes_namespace": "test", + }, + expectedAudiences: []interface{}{"foo", "bar"}, + }, + "default to audiences of k8s cluster default if both not set": { + roleConfig: map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"*"}, + "service_account_name": "sample-app", + }, + credsConfig: map[string]interface{}{ + "kubernetes_namespace": "test", + }, + expectedAudiences: []interface{}{"https://kubernetes.default.svc.cluster.local"}, + }, + } + i := 0 + for n, tc := range tests { + t.Run(n, func(t *testing.T) { + roleName := fmt.Sprintf("testrole-%d", i) + _, err = client.Logical().Write(path+"/roles/"+roleName, tc.roleConfig) + assert.NoError(t, err) + + creds, err := client.Logical().Write(path+"/creds/"+roleName, tc.credsConfig) + assert.NoError(t, err) + require.NotNil(t, creds) + + testK8sTokenAudiences(t, tc.expectedAudiences, creds.Data["service_account_token"].(string)) + }) + i = i + 1 + } +} + +func TestCreds_service_account_name(t *testing.T) { + // Pick up VAULT_ADDR and VAULT_TOKEN from env vars + client, err := api.NewClient(nil) + if err != nil { + t.Fatal(err) + } + + path, umount := mountHelper(t, client) + defer umount() + client, delNamespace := namespaceHelper(t, client) + defer delNamespace() + + // create default config + _, err = client.Logical().Write(path+"/config", map[string]interface{}{}) + require.NoError(t, err) + + _, err = client.Logical().Write(path+"/roles/testrole", map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"*"}, + "service_account_name": "sample-app", + "token_default_ttl": "1h", + "token_max_ttl": "24h", + }) + assert.NoError(t, err) + + roleResponse, err := client.Logical().Read(path + "/roles/testrole") + assert.NoError(t, err) + assert.Equal(t, map[string]interface{}{ + "allowed_kubernetes_namespaces": []interface{}{"*"}, + "allowed_kubernetes_namespace_selector": "", + "extra_labels": nil, + "extra_annotations": nil, + "generated_role_rules": "", + "kubernetes_role_name": "", + "kubernetes_role_type": "Role", + "name": "testrole", + "name_template": "", + "service_account_name": "sample-app", + "token_max_ttl": oneDay, + "token_default_ttl": oneHour, + "token_default_audiences": nil, + }, roleResponse.Data) + + result1, err := client.Logical().Write(path+"/creds/testrole", map[string]interface{}{ + "kubernetes_namespace": "test", + "ttl": "2h", + }) + assert.NoError(t, err) + verifyCredsResponse(t, result1, "test", "sample-app", 7200) + + testRoleBindingToken(t, result1) + + // Clean up lease and delete role + leases, err := client.Logical().List("sys/leases/lookup/" + path + "/creds/testrole/") + assert.NoError(t, err) + assert.Len(t, leases.Data["keys"], 1) + + err = client.Sys().RevokePrefix(path + "/creds/testrole") + assert.NoError(t, err) + + noLeases, err := client.Logical().List("sys/leases/lookup/" + path + "/creds/testrole/") + assert.NoError(t, err) + assert.Empty(t, noLeases) + + _, err = client.Logical().Delete(path + "/roles/testrole") + assert.NoError(t, err) + + result, err := client.Logical().Read(path + "/roles/testrole") + assert.NoError(t, err) + assert.Nil(t, result) +} + +func TestCreds_kubernetes_role_name(t *testing.T) { + // Pick up VAULT_ADDR and VAULT_TOKEN from env vars + client, err := api.NewClient(nil) + if err != nil { + t.Fatal(err) + } + + path, umount := mountHelper(t, client) + defer umount() + client, delNamespace := namespaceHelper(t, client) + defer delNamespace() + + // create default config + _, err = client.Logical().Write(path+"/config", map[string]interface{}{}) + require.NoError(t, err) + + t.Run("Role type", func(t *testing.T) { + extraLabels := map[string]string{ + "environment": "testing", + } + extraAnnotations := map[string]string{ + "tested": "today", + } + roleConfig := map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"test"}, + "extra_annotations": extraAnnotations, + "extra_labels": extraLabels, + "kubernetes_role_name": "test-role-list-pods", + "kubernetes_role_type": "role", + "token_default_ttl": "1h", + "token_max_ttl": "24h", + "name_template": `{{ printf "v-custom-name-%s" (random 24) | truncate 62 | lowercase }}`, + } + expectedRoleResponse := map[string]interface{}{ + "allowed_kubernetes_namespaces": []interface{}{"test"}, + "allowed_kubernetes_namespace_selector": "", + "extra_annotations": asMapInterface(extraAnnotations), + "extra_labels": asMapInterface(extraLabels), + "generated_role_rules": "", + "kubernetes_role_name": "test-role-list-pods", + "kubernetes_role_type": "Role", + "name": "testrole", + "name_template": `{{ printf "v-custom-name-%s" (random 24) | truncate 62 | lowercase }}`, + "service_account_name": "", + "token_max_ttl": oneDay, + "token_default_ttl": oneHour, + "token_default_audiences": nil, + } + testRoleType(t, client, path, roleConfig, expectedRoleResponse) + }) + + t.Run("ClusterRole type", func(t *testing.T) { + extraLabels := map[string]string{ + "environment": "staging", + } + extraAnnotations := map[string]string{ + "tested": "tomorrow", + } + roleConfig := map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"random"}, + "allowed_kubernetes_namespace_selector": `{"matchExpressions": [{"key": "target", "operator": "In", "values": ["integration-test"]}, {"key": "nonexistantlabel", "operator": "DoesNotExist", "values": []}]}`, + "extra_annotations": extraAnnotations, + "extra_labels": extraLabels, + "kubernetes_role_name": "test-cluster-role-list-pods", + "kubernetes_role_type": "Clusterrole", + "token_default_ttl": "1h", + "token_max_ttl": "24h", + } + expectedRoleResponse := map[string]interface{}{ + "allowed_kubernetes_namespaces": []interface{}{"random"}, + "allowed_kubernetes_namespace_selector": `{"matchExpressions": [{"key": "target", "operator": "In", "values": ["integration-test"]}, {"key": "nonexistantlabel", "operator": "DoesNotExist", "values": []}]}`, + "extra_annotations": asMapInterface(extraAnnotations), + "extra_labels": asMapInterface(extraLabels), + "generated_role_rules": "", + "kubernetes_role_name": "test-cluster-role-list-pods", + "kubernetes_role_type": "ClusterRole", + "name": "clusterrole", + "name_template": "", + "service_account_name": "", + "token_max_ttl": oneDay, + "token_default_ttl": oneHour, + "token_default_audiences": nil, + } + testClusterRoleType(t, client, path, roleConfig, expectedRoleResponse) + }) +} + +func TestCreds_generated_role_rules(t *testing.T) { + // Pick up VAULT_ADDR and VAULT_TOKEN from env vars + client, err := api.NewClient(nil) + if err != nil { + t.Fatal(err) + } + + path, umount := mountHelper(t, client) + defer umount() + client, delNamespace := namespaceHelper(t, client) + defer delNamespace() + + // create default config + _, err = client.Logical().Write(path+"/config", map[string]interface{}{}) + require.NoError(t, err) + + roleRulesYAML := `rules: +- apiGroups: [""] + resources: ["pods"] + verbs: ["list"]` + + roleRulesJSON := `"rules": [ + { + "apiGroups": [ + "" + ], + "resources": [ + "pods" + ], + "verbs": [ + "list" + ] + } +]` + + t.Run("Role type", func(t *testing.T) { + extraLabels := map[string]string{ + "environment": "testing", + } + extraAnnotations := map[string]string{ + "tested": "today", + } + roleConfig := map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"test"}, + "extra_annotations": extraAnnotations, + "extra_labels": extraLabels, + "generated_role_rules": roleRulesYAML, + "kubernetes_role_type": "RolE", + "token_default_ttl": "1h", + "token_max_ttl": "24h", + } + expectedRoleResponse := map[string]interface{}{ + "allowed_kubernetes_namespaces": []interface{}{"test"}, + "allowed_kubernetes_namespace_selector": "", + "extra_annotations": asMapInterface(extraAnnotations), + "extra_labels": asMapInterface(extraLabels), + "generated_role_rules": roleRulesYAML, + "kubernetes_role_name": "", + "kubernetes_role_type": "Role", + "name": "testrole", + "name_template": "", + "service_account_name": "", + "token_max_ttl": oneDay, + "token_default_ttl": oneHour, + "token_default_audiences": nil, + } + testRoleType(t, client, path, roleConfig, expectedRoleResponse) + }) + + t.Run("ClusterRole type", func(t *testing.T) { + extraLabels := map[string]string{ + "environment": "staging", + "asdf": "123", + } + extraAnnotations := map[string]string{ + "tested": "tomorrow", + "checked": "again", + } + roleConfig := map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"test"}, + "extra_annotations": extraAnnotations, + "extra_labels": extraLabels, + "generated_role_rules": roleRulesJSON, + "kubernetes_role_type": "clusterRole", + "token_default_ttl": "1h", + "token_max_ttl": "24h", + } + expectedRoleResponse := map[string]interface{}{ + "allowed_kubernetes_namespaces": []interface{}{"test"}, + "allowed_kubernetes_namespace_selector": "", + "extra_annotations": asMapInterface(extraAnnotations), + "extra_labels": asMapInterface(extraLabels), + "generated_role_rules": roleRulesJSON, + "kubernetes_role_name": "", + "kubernetes_role_type": "ClusterRole", + "name": "clusterrole", + "name_template": "", + "service_account_name": "", + "token_max_ttl": oneDay, + "token_default_ttl": oneHour, + "token_default_audiences": nil, + } + testClusterRoleType(t, client, path, roleConfig, expectedRoleResponse) + }) +} + +// Test kubernetes_namespace handling +func TestCreds_kubernetes_namespace(t *testing.T) { + // Pick up VAULT_ADDR and VAULT_TOKEN from env vars + client, err := api.NewClient(nil) + if err != nil { + t.Fatal(err) + } + + path, umount := mountHelper(t, client) + defer umount() + client, delNamespace := namespaceHelper(t, client) + defer delNamespace() + + // create default config + _, err = client.Logical().Write(path+"/config", map[string]interface{}{}) + require.NoError(t, err) + + type testCase struct { + roleConfig map[string]interface{} + credsConfig map[string]interface{} + expectedCredsCreateErrIsNil bool + } + + tests := map[string]testCase{ + "allowed_kubernetes_namespaces to * and kubernetes_namespace to test": { + roleConfig: map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"*"}, + "service_account_name": "sample-app", + }, + credsConfig: map[string]interface{}{ + "kubernetes_namespace": "test", + }, + expectedCredsCreateErrIsNil: true, + }, + "allowed_kubernetes_namespaces to a single namespace, allowed_kubernetes_namespace_selector to empty," + + " and kubernetes_namespace omitted": { + roleConfig: map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"test"}, + "service_account_name": "sample-app", + }, + credsConfig: nil, + expectedCredsCreateErrIsNil: true, + }, + "allowed_kubernetes_namespaces to * and kubernetes_namespace omitted": { + roleConfig: map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"*"}, + "service_account_name": "sample-app", + }, + credsConfig: nil, + expectedCredsCreateErrIsNil: false, + }, + "allowed_kubernetes_namespaces to a single namespace, allowed_kubernetes_namespace_selector to nonempty," + + " and kubernetes_namespace omitted": { + roleConfig: map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"test"}, + "allowed_kubernetes_namespace_selector": `{"matchExpressions": [{"key": "target", "operator": "In", "values": ["integration-test"]}, {"key": "nonexistantlabel", "operator": "DoesNotExist", "values": []}]}`, + "service_account_name": "sample-app", + }, + credsConfig: nil, + expectedCredsCreateErrIsNil: false, + }, + "allowed_kubernetes_namespaces to empty, allowed_kubernetes_namespace_selector to nonempty," + + "kubernetes_namespace omitted": { + roleConfig: map[string]interface{}{ + "allowed_kubernetes_namespace_selector": `{"matchExpressions": [{"key": "target", "operator": "In", "values": ["integration-test"]}, {"key": "nonexistantlabel", "operator": "DoesNotExist", "values": []}]}`, + "service_account_name": "sample-app", + }, + credsConfig: nil, + expectedCredsCreateErrIsNil: false, + }, + "allowed_kubernetes_namespaces to more than one specified, kubernetes_namespace omitted": { + roleConfig: map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"test", "foo"}, + "service_account_name": "sample-app", + }, + credsConfig: nil, + expectedCredsCreateErrIsNil: false, + }, + } + i := 0 + for n, tc := range tests { + t.Run(n, func(t *testing.T) { + roleName := fmt.Sprintf("testrole-%d", i) + _, err = client.Logical().Write(path+"/roles/"+roleName, tc.roleConfig) + require.NoError(t, err) + + creds, err := client.Logical().Write(path+"/creds/"+roleName, tc.credsConfig) + assert.Equal(t, tc.expectedCredsCreateErrIsNil, err == nil) + if tc.expectedCredsCreateErrIsNil { + require.NotNil(t, creds) + } + }) + i = i + 1 + } +} diff --git a/builtin/logical/kubernetes/integrationtest/helpers.go b/builtin/logical/kubernetes/integrationtest/helpers.go new file mode 100644 index 0000000000..422c8a52a5 --- /dev/null +++ b/builtin/logical/kubernetes/integrationtest/helpers.go @@ -0,0 +1,453 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package integrationtest + +import ( + "context" + "fmt" + "math/rand" + "os" + "strings" + "testing" + "time" + + "github.com/openbao/openbao/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + josejwt "gopkg.in/square/go-jose.v2/jwt" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8s_yaml "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +var standardLabels = map[string]string{ + "app.kubernetes.io/managed-by": "HashiCorp-Vault", + "app.kubernetes.io/created-by": "vault-plugin-secrets-kubernetes", +} + +func randomWithPrefix(name string) string { + return fmt.Sprintf("%s-%d", name, rand.New(rand.NewSource(time.Now().UnixNano())).Int()) +} + +func newK8sClient(t *testing.T, token string) kubernetes.Interface { + t.Helper() + config := rest.Config{ + Host: os.Getenv("KUBE_HOST"), + BearerToken: token, + } + config.TLSClientConfig.CAData = append(config.TLSClientConfig.CAData, []byte(os.Getenv("KUBERNETES_CA"))...) + + client, err := kubernetes.NewForConfig(&config) + if err != nil { + t.Fatalf("error creating k8s client: %s", err) + } + return client +} + +// Verify a creds response with a generated service account +func verifyCredsResponseGenerated(t *testing.T, result *api.Secret, namespace string, leaseDuration int, name string) { + t.Helper() + assert.Equal(t, leaseDuration, result.LeaseDuration) + assert.Equal(t, false, result.Renewable) + assert.Contains(t, result.Data["service_account_name"], name) + assert.Equal(t, namespace, result.Data["service_account_namespace"]) +} + +// Verify a creds response with an existing service account +func verifyCredsResponse(t *testing.T, result *api.Secret, namespace, serviceAccount string, leaseDuration int) { + t.Helper() + assert.Equal(t, leaseDuration, result.LeaseDuration) + assert.Equal(t, false, result.Renewable) + assert.Equal(t, serviceAccount, result.Data["service_account_name"]) + assert.Equal(t, namespace, result.Data["service_account_namespace"]) +} + +// If it's a token that's bound to a Role, test listing pods in the response's +// namespace, and other namespaces should be denied +func testRoleBindingToken(t *testing.T, credsResponse *api.Secret) { + t.Helper() + token := credsResponse.Data["service_account_token"].(string) + namespace := credsResponse.Data["service_account_namespace"].(string) + serviceAccountName := credsResponse.Data["service_account_name"].(string) + canListPods, err := tryListPods(t, namespace, token, 1) + assert.NoError(t, err) + assert.True(t, canListPods) + + canListPods, err = tryListPods(t, "default", token, 0) + assert.Errorf(t, err, `pods is forbidden: User "system:serviceaccount:test:%s" cannot list resource "pods" in API group "" in the namespace "default"`, serviceAccountName) + assert.False(t, canListPods) +} + +func testTokenRevoked(t *testing.T, credsResponse *api.Secret) { + t.Helper() + token := credsResponse.Data["service_account_token"].(string) + namespace := credsResponse.Data["service_account_namespace"].(string) + serviceAccountName := credsResponse.Data["service_account_name"].(string) + + listPods, err := tryListPods(t, namespace, token, 1) + assert.Errorf(t, err, `pods is forbidden: User "system:serviceaccount:test:%s" cannot list resource "pods" in API group "" in the namespace "%s"`, serviceAccountName, namespace) + assert.False(t, listPods) +} + +// For a token bound to a ClusterRole, test listing pods in the response's +// namespace, and other resource types should be denied +func testClusterRoleBindingToken(t *testing.T, credsResponse *api.Secret) { + t.Helper() + token := credsResponse.Data["service_account_token"].(string) + namespace := credsResponse.Data["service_account_namespace"].(string) + serviceAccountName := credsResponse.Data["service_account_name"].(string) + canListPods, err := tryListPods(t, namespace, token, 1) + assert.NoError(t, err) + assert.True(t, canListPods) + + canListPods, err = tryListPods(t, "default", token, 0) + assert.NoError(t, err) + + canListDeployments, err := tryListDeployments(t, "default", token) + assert.Errorf(t, err, `pods is forbidden: User "system:serviceaccount:test:%s" cannot list resource "pods" in API group "" in the namespace "default"`, serviceAccountName) + assert.False(t, canListDeployments) +} + +func verifyRole(t *testing.T, roleConfig map[string]interface{}, credsResponse *api.Secret) { + t.Helper() + + // All the created kubernetes objects have the same name, so the + // service_account_name that is return from creds/ is the same as the Role + // or ClusterRole + roleName := credsResponse.Data["service_account_name"].(string) + roleType := strings.ToLower(roleConfig["kubernetes_role_type"].(string)) + + expectedLabels := makeExpectedLabels(t, roleConfig["extra_labels"].(map[string]interface{})) + expectedAnnotations := asMapString(roleConfig["extra_annotations"].(map[string]interface{})) + expectedRules := makeRules(t, roleConfig["generated_role_rules"].(string)) + + returnedLabels := map[string]string{} + returnedAnnotations := map[string]string{} + returnedRules := []rbacv1.PolicyRule{} + + k8sClient := newK8sClient(t, os.Getenv("SUPER_JWT")) + if roleType == "role" { + role, err := k8sClient.RbacV1().Roles("test").Get(context.Background(), roleName, metav1. + GetOptions{}) + require.NoError(t, err) + returnedLabels = role.Labels + returnedAnnotations = role.Annotations + returnedRules = role.Rules + } else { + clusterRole, err := k8sClient.RbacV1().ClusterRoles().Get(context.Background(), roleName, metav1.GetOptions{}) + require.NoError(t, err) + returnedLabels = clusterRole.Labels + returnedAnnotations = clusterRole.Annotations + returnedRules = clusterRole.Rules + } + assert.Equal(t, expectedLabels, returnedLabels) + assert.Equal(t, expectedAnnotations, returnedAnnotations) + assert.Equal(t, expectedRules, returnedRules) +} + +func verifyBinding(t *testing.T, roleConfig map[string]interface{}, credsResponse *api.Secret, isClusterBinding bool) { + t.Helper() + + // All the created kubernetes objects have the same name, so the + // service_account_name that is return from creds/ is the same as the Role + // or ClusterRole + objName := credsResponse.Data["service_account_name"].(string) + + expectedLabels := makeExpectedLabels(t, roleConfig["extra_labels"].(map[string]interface{})) + expectedAnnotations := asMapString(roleConfig["extra_annotations"].(map[string]interface{})) + expectedSubjects := []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: objName, + Namespace: "test", + }, + } + + returnedLabels := map[string]string{} + returnedAnnotations := map[string]string{} + returnedSubjects := []rbacv1.Subject{} + + k8sClient := newK8sClient(t, os.Getenv("SUPER_JWT")) + if isClusterBinding { + clusterBinding, err := k8sClient.RbacV1().ClusterRoleBindings().Get(context.Background(), objName, metav1.GetOptions{}) + require.NoError(t, err) + returnedLabels = clusterBinding.Labels + returnedAnnotations = clusterBinding.Annotations + returnedSubjects = clusterBinding.Subjects + } else { + binding, err := k8sClient.RbacV1().RoleBindings("test").Get(context.Background(), objName, metav1.GetOptions{}) + require.NoError(t, err) + returnedLabels = binding.Labels + returnedAnnotations = binding.Annotations + returnedSubjects = binding.Subjects + } + assert.Equal(t, expectedLabels, returnedLabels) + assert.Equal(t, expectedAnnotations, returnedAnnotations) + assert.Equal(t, expectedSubjects, returnedSubjects) +} + +func verifyServiceAccount(t *testing.T, roleConfig map[string]interface{}, credsResponse *api.Secret) { + t.Helper() + + // All the created kubernetes objects have the same name, so the + // service_account_name that is return from creds/ is the same as the Role + // or ClusterRole + objName := credsResponse.Data["service_account_name"].(string) + + expectedLabels := makeExpectedLabels(t, roleConfig["extra_labels"].(map[string]interface{})) + expectedAnnotations := asMapString(roleConfig["extra_annotations"].(map[string]interface{})) + + k8sClient := newK8sClient(t, os.Getenv("SUPER_JWT")) + acct, err := k8sClient.CoreV1().ServiceAccounts("test").Get(context.Background(), objName, metav1.GetOptions{}) + require.NoError(t, err) + returnedLabels := acct.Labels + returnedAnnotations := acct.Annotations + + assert.Equal(t, expectedLabels, returnedLabels) + assert.Equal(t, expectedAnnotations, returnedAnnotations) +} + +func tryListPods(t *testing.T, namespace, token string, count int) (bool, error) { + k8sClient := newK8sClient(t, token) + podsList, err := k8sClient.CoreV1(). + Pods(namespace). + List(context.Background(), metav1.ListOptions{}) + if err != nil { + return false, err + } + if len(podsList.Items) != count { + return false, fmt.Errorf("expected %d pod(s) in list, not %d", count, len(podsList.Items)) + } + + return true, nil +} + +func tryListDeployments(t *testing.T, namespace, token string) (bool, error) { + k8sClient := newK8sClient(t, token) + podsList, err := k8sClient.AppsV1(). + Deployments(namespace). + List(context.Background(), metav1.ListOptions{}) + if err != nil { + return false, err + } + if len(podsList.Items) != 1 { + return false, fmt.Errorf("expected one pod in list, not %d", len(podsList.Items)) + } + + return true, nil +} + +func testRoleType(t *testing.T, client *api.Client, mountPath string, roleConfig, expectedRoleResponse map[string]interface{}) { + t.Helper() + + _, err := client.Logical().Write(mountPath+"/roles/testrole", roleConfig) + require.NoError(t, err) + + roleResult, err := client.Logical().Read(mountPath + "/roles/testrole") + assert.NoError(t, err) + assert.Equal(t, expectedRoleResponse, roleResult.Data) + + result1, err := client.Logical().Write(mountPath+"/creds/testrole", map[string]interface{}{ + "kubernetes_namespace": "test", + "cluster_role_binding": false, + "ttl": "2h", + }) + require.NoError(t, err) + require.NotNil(t, result1) + + expectedName := "v-token-" + if nt, ok := roleConfig["name_template"]; ok && nt != "" { + expectedName = "v-custom-name-" + } + verifyCredsResponseGenerated(t, result1, "test", 7200, expectedName) + + // Check the k8s objects that should've been created + if grr, ok := roleConfig["generated_role_rules"]; ok && grr.(string) != "" { + verifyRole(t, expectedRoleResponse, result1) + } + verifyBinding(t, expectedRoleResponse, result1, false) + verifyServiceAccount(t, expectedRoleResponse, result1) + + // Try using the generated token. Listing pods should be allowed in the + // 'test' namespace, but nowhere else. + testRoleBindingToken(t, result1) + + leases, err := client.Logical().List("sys/leases/lookup/" + mountPath + "/creds/testrole/") + assert.NoError(t, err) + assert.Len(t, leases.Data["keys"], 1) + + // Clean up the lease + err = client.Sys().RevokePrefix(mountPath + "/creds/testrole") + assert.NoError(t, err) + + noLeases, err := client.Logical().List("sys/leases/lookup/" + mountPath + "/creds/testrole/") + assert.NoError(t, err) + assert.Empty(t, noLeases) + + testTokenRevoked(t, result1) + + // Test ClusterRoleBinding + // This should fail since k8s doesn't allow a ClusterRoleBinding with a Role + result2, err := client.Logical().Write(mountPath+"/creds/testrole", map[string]interface{}{ + "kubernetes_namespace": "test", + "cluster_role_binding": true, + "ttl": "2h", + }) + assert.Error(t, err, "a ClusterRoleBinding cannot ref a Role") + assert.Nil(t, result2) + + // Finally, delete the role + _, err = client.Logical().Delete(mountPath + "/roles/testrole") + assert.NoError(t, err) + + result, err := client.Logical().Read(mountPath + "/roles/testrole") + assert.NoError(t, err) + assert.Nil(t, result) +} + +func testClusterRoleType(t *testing.T, client *api.Client, mountPath string, roleConfig, expectedRoleResponse map[string]interface{}) { + t.Helper() + + _, err := client.Logical().Write(mountPath+"/roles/clusterrole", roleConfig) + require.NoError(t, err) + + roleResult, err := client.Logical().Read(mountPath + "/roles/clusterrole") + assert.NoError(t, err) + assert.Equal(t, expectedRoleResponse, roleResult.Data) + + // Generate creds with a RoleBinding + result1, err := client.Logical().Write(mountPath+"/creds/clusterrole", map[string]interface{}{ + "kubernetes_namespace": "test", + "cluster_role_binding": false, + "ttl": "2h", + }) + assert.NoError(t, err) + verifyCredsResponseGenerated(t, result1, "test", 7200, "v-token-") + + if grr, ok := roleConfig["generated_role_rules"]; ok && grr.(string) != "" { + verifyRole(t, expectedRoleResponse, result1) + } + verifyBinding(t, expectedRoleResponse, result1, false) + verifyServiceAccount(t, expectedRoleResponse, result1) + + // Try using the generated token. Listing pods should be allowed in the + // 'test' namespace, but nowhere else. + testRoleBindingToken(t, result1) + + // Generate creds with a ClusterRoleBinding + result2, err := client.Logical().Write(mountPath+"/creds/clusterrole", map[string]interface{}{ + "kubernetes_namespace": "test", + "cluster_role_binding": true, + "ttl": "2h", + }) + assert.NoError(t, err) + verifyCredsResponseGenerated(t, result2, "test", 7200, "v-token-") + + if grr, ok := roleConfig["generated_role_rules"]; ok && grr.(string) != "" { + verifyRole(t, expectedRoleResponse, result2) + } + verifyBinding(t, expectedRoleResponse, result2, true) + verifyServiceAccount(t, expectedRoleResponse, result2) + + // Try the generated token, listing pods should work in any namespace, + // but listing deployments should be denied + testClusterRoleBindingToken(t, result2) + + leases, err := client.Logical().List("sys/leases/lookup/" + mountPath + "/creds/clusterrole/") + assert.NoError(t, err) + assert.Len(t, leases.Data["keys"], 2) + + // Clean up leases and delete the role + err = client.Sys().RevokePrefix(mountPath + "/creds/clusterrole") + assert.NoError(t, err) + + noLeases, err := client.Logical().List("sys/leases/lookup/" + mountPath + "/creds/clusterrole/") + assert.NoError(t, err) + assert.Empty(t, noLeases) + + testTokenRevoked(t, result1) + testTokenRevoked(t, result2) + + _, err = client.Logical().Delete(mountPath + "/roles/clusterrole") + assert.NoError(t, err) + + result, err := client.Logical().Read(mountPath + "/roles/clusterrole") + assert.NoError(t, err) + assert.Nil(t, result) +} + +func testK8sTokenTTL(t *testing.T, expectedSec int, token string) { + parsed, err := josejwt.ParseSigned(token) + require.NoError(t, err) + claims := map[string]interface{}{} + err = parsed.UnsafeClaimsWithoutVerification(&claims) + require.NoError(t, err) + iat := claims["iat"].(float64) + exp := claims["exp"].(float64) + assert.Equal(t, expectedSec, int(exp-iat)) +} + +func testK8sTokenAudiences(t *testing.T, expectedAudiences []interface{}, token string) { + parsed, err := josejwt.ParseSigned(token) + require.NoError(t, err) + claims := map[string]interface{}{} + err = parsed.UnsafeClaimsWithoutVerification(&claims) + require.NoError(t, err) + aud := claims["aud"].([]interface{}) + assert.ElementsMatch(t, expectedAudiences, aud) +} + +func combineMaps(maps ...map[string]string) map[string]string { + newMap := make(map[string]string) + for _, m := range maps { + for k, v := range m { + newMap[k] = v + } + } + return newMap +} + +func makeRules(t *testing.T, rules string) []rbacv1.PolicyRule { + t.Helper() + + policyRules := struct { + Rules []rbacv1.PolicyRule `json:"rules"` + }{} + decoder := k8s_yaml.NewYAMLOrJSONDecoder(strings.NewReader(rules), len(rules)) + err := decoder.Decode(&policyRules) + require.NoError(t, err) + return policyRules.Rules +} + +func makeExpectedLabels(t *testing.T, extraLabels map[string]interface{}) map[string]string { + t.Helper() + + expectedLabels := map[string]string{} + if extraLabels != nil { + expectedLabels = combineMaps(asMapString(extraLabels), standardLabels) + } else { + expectedLabels = standardLabels + } + return expectedLabels +} + +func asMapInterface(m map[string]string) map[string]interface{} { + result := map[string]interface{}{} + for k, v := range m { + result[k] = v + } + + return result +} + +func asMapString(m map[string]interface{}) map[string]string { + result := map[string]string{} + for k, v := range m { + result[k] = v.(string) + } + + return result +} diff --git a/builtin/logical/kubernetes/integrationtest/integration_test.go b/builtin/logical/kubernetes/integrationtest/integration_test.go new file mode 100644 index 0000000000..c7018e65f0 --- /dev/null +++ b/builtin/logical/kubernetes/integrationtest/integration_test.go @@ -0,0 +1,397 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package integrationtest + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "strings" + "testing" + + "github.com/hashicorp/go-version" + "github.com/openbao/openbao/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Set the environment variable INTEGRATION_TESTS to any non-empty value to run +// the tests in this package. The test assumes it has available: +// - kubectl +// - A Kubernetes cluster in which: +// - it can use the `test` namespace +// - Vault is deployed and accessible +// +// See `make setup-integration-test` for manual testing. +func TestMain(m *testing.M) { + if os.Getenv("INTEGRATION_TESTS") != "" { + checkKubectlVersion() + os.Setenv("VAULT_ADDR", "http://127.0.0.1:38300") + os.Setenv("VAULT_TOKEN", "root") + os.Setenv("KUBERNETES_CA", getK8sCA()) + os.Setenv("KUBE_HOST", getKubeHost(os.Getenv("KIND_CLUSTER_NAME"))) + os.Setenv("SUPER_JWT", getSuperJWT()) + os.Setenv("BROKEN_JWT", getBrokenJWT()) + os.Exit(m.Run()) + } +} + +type kubectlVersion struct { + ClientVersion struct { + GitVersion string `json:"gitVersion"` + } `json:"clientVersion"` +} + +// kubectl create token requires kubectl >= v1.24.0 +func checkKubectlVersion() { + versionJSON := runCmd("kubectl version --client --output=json") + var versionInfo kubectlVersion + + if err := json.Unmarshal([]byte(versionJSON), &versionInfo); err != nil { + panic(err) + } + + v := version.Must(version.NewSemver(versionInfo.ClientVersion.GitVersion)) + if v.LessThan(version.Must(version.NewSemver("v1.24.0"))) { + panic("integration tests require kubectl version >= v1.24.0, but found: " + versionInfo.ClientVersion.GitVersion) + } +} + +func TestMount(t *testing.T) { + // Pick up VAULT_ADDR and VAULT_TOKEN from env vars + client, err := api.NewClient(nil) + if err != nil { + t.Fatal(err) + } + + _, umount := mountHelper(t, client) + defer umount() +} + +func TestCheckViability(t *testing.T) { + client, err := api.NewClient(nil) + if err != nil { + t.Fatal(err) + } + + path, umount := mountHelper(t, client) + defer umount() + client, delNamespace := namespaceHelper(t, client) + defer delNamespace() + + // check + resp, err := client.Logical().ReadRaw(path + "/check") + assert.NoError(t, err) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) +} + +func TestConfig(t *testing.T) { + // Pick up VAULT_ADDR and VAULT_TOKEN from env vars + client, err := api.NewClient(nil) + if err != nil { + t.Fatal(err) + } + + path, umount := mountHelper(t, client) + defer umount() + client, delNamespace := namespaceHelper(t, client) + defer delNamespace() + + // create + _, err = client.Logical().Write(path+"/config", map[string]interface{}{ + "disable_local_ca_jwt": true, + "kubernetes_ca_cert": "cert", + "kubernetes_host": "host", + "service_account_jwt": "jwt", + }) + assert.NoError(t, err) + + result, err := client.Logical().Read(path + "/config") + assert.NoError(t, err) + assert.Equal(t, map[string]interface{}{ + "disable_local_ca_jwt": true, + "kubernetes_ca_cert": "cert", + "kubernetes_host": "host", + }, result.Data) + + // update + _, err = client.Logical().Write(path+"/config", map[string]interface{}{ + "kubernetes_host": "another-host", + }) + assert.NoError(t, err) + + result, err = client.Logical().Read(path + "/config") + assert.NoError(t, err) + assert.Equal(t, map[string]interface{}{ + "disable_local_ca_jwt": true, + "kubernetes_ca_cert": "cert", + "kubernetes_host": "another-host", + }, result.Data) + + // delete + _, err = client.Logical().Delete(path + "/config") + assert.NoError(t, err) + + result, err = client.Logical().Read(path + "/config") + assert.NoError(t, err) + assert.Nil(t, result) +} + +func TestRole(t *testing.T) { + // Pick up VAULT_ADDR and VAULT_TOKEN from env vars + client, err := api.NewClient(nil) + if err != nil { + t.Fatal(err) + } + + path, umount := mountHelper(t, client) + defer umount() + client, delNamespace := namespaceHelper(t, client) + defer delNamespace() + + // create default config + _, err = client.Logical().Write(path+"/config", map[string]interface{}{}) + require.NoError(t, err) + + _, err = client.Logical().Write(path+"/roles/testrole", map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"*"}, + "generated_role_rules": sampleRules, + "token_default_ttl": "1h", + "token_max_ttl": "24h", + "token_default_audiences": []string{"foobar"}, + }) + assert.NoError(t, err) + + result, err := client.Logical().Read(path + "/roles/testrole") + assert.NoError(t, err) + assert.Equal(t, map[string]interface{}{ + "allowed_kubernetes_namespaces": []interface{}{"*"}, + "allowed_kubernetes_namespace_selector": "", + "extra_annotations": nil, + "extra_labels": nil, + "generated_role_rules": sampleRules, + "kubernetes_role_name": "", + "kubernetes_role_type": "Role", + "name": "testrole", + "name_template": "", + "service_account_name": "", + "token_max_ttl": oneDay, + "token_default_ttl": oneHour, + "token_default_audiences": []interface{}{"foobar"}, + }, result.Data) + + // update + _, err = client.Logical().Write(path+"/roles/testrole", map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"app1", "app2"}, + "extra_annotations": sampleExtraAnnotations, + "extra_labels": sampleExtraLabels, + "token_default_ttl": "30m", + "token_default_audiences": []string{"bar"}, + }) + + result, err = client.Logical().Read(path + "/roles/testrole") + assert.NoError(t, err) + assert.Equal(t, map[string]interface{}{ + "allowed_kubernetes_namespaces": []interface{}{"app1", "app2"}, + "allowed_kubernetes_namespace_selector": "", + "extra_annotations": asMapInterface(sampleExtraAnnotations), + "extra_labels": asMapInterface(sampleExtraLabels), + "generated_role_rules": sampleRules, + "kubernetes_role_name": "", + "kubernetes_role_type": "Role", + "name": "testrole", + "name_template": "", + "service_account_name": "", + "token_max_ttl": oneDay, + "token_default_ttl": thirtyMinutes, + "token_default_audiences": []interface{}{"bar"}, + }, result.Data) + + // update again + _, err = client.Logical().Write(path+"/roles/testrole", map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{}, + "allowed_kubernetes_namespace_selector": sampleSelector, + }) + + result, err = client.Logical().Read(path + "/roles/testrole") + assert.NoError(t, err) + assert.Equal(t, map[string]interface{}{ + "allowed_kubernetes_namespaces": []interface{}{}, + "allowed_kubernetes_namespace_selector": sampleSelector, + "extra_annotations": asMapInterface(sampleExtraAnnotations), + "extra_labels": asMapInterface(sampleExtraLabels), + "generated_role_rules": sampleRules, + "kubernetes_role_name": "", + "kubernetes_role_type": "Role", + "name": "testrole", + "name_template": "", + "service_account_name": "", + "token_max_ttl": oneDay, + "token_default_ttl": thirtyMinutes, + "token_default_audiences": []interface{}{"bar"}, + }, result.Data) + + result, err = client.Logical().List(path + "/roles") + assert.NoError(t, err) + assert.Equal(t, map[string]interface{}{"keys": []interface{}{"testrole"}}, result.Data) + + _, err = client.Logical().Delete(path + "/roles/testrole") + assert.NoError(t, err) + + result, err = client.Logical().Read(path + "/roles/testrole") + assert.NoError(t, err) + assert.Nil(t, result) +} + +func isEnterprise(client *api.Client) bool { + req := client.NewRequest("GET", "/v1/sys/license/status") + resp, err := client.RawRequest(req) + if err != nil { + return false + } + return resp.StatusCode == 200 +} + +func createNamespace(client *api.Client, namespace string) error { + req := client.NewRequest("PUT", "/v1/sys/namespaces/"+namespace) + resp, err := client.RawRequest(req) + if err != nil { + return err + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("error creating namespace. Server returned status %d %s", resp.StatusCode, resp.Status) + } + return nil +} + +func deleteNamespace(client *api.Client, namespace string) error { + req := client.NewRequest("DELETE", "/v1/sys/namespaces/"+namespace) + resp, err := client.RawRequest(req) + if err != nil { + return err + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("error creating namespace. Server returned status %d %s", resp.StatusCode, resp.Status) + } + return nil +} + +// mountHelper creates the kubernetes mount. +func mountHelper(t *testing.T, client *api.Client) (string, func()) { + t.Helper() + + path := randomWithPrefix("kubernetes") + fullPath := fmt.Sprintf("sys/mounts/%s", path) + _, err := client.Logical().Write(fullPath, map[string]interface{}{ + "type": "kubernetes-dev", + }) + if err != nil { + t.Fatal(err) + } + + return path, func() { + _, err = client.Logical().Delete(fullPath) + if err != nil { + t.Fatal(err) + } + } +} + +// namespaceHelper creates a Vault Enterprise namespace and returns a client with the namespace changed to it. +func namespaceHelper(t *testing.T, client *api.Client) (*api.Client, func()) { + t.Helper() + + var err error + namespace := "" + newClient := client + + if isEnterprise(client) { + namespace := randomWithPrefix("somenamespace") + if err != nil { + t.Fatal(err) + } + err = createNamespace(client, namespace) + if err != nil { + t.Fatal(err) + } + newClient, err := client.Clone() + if err != nil { + t.Fatal(err) + } + newClient.SetNamespace(namespace) + } + + return newClient, func() { + if namespace != "" { + err = deleteNamespace(client, namespace) + if err != nil { + t.Fatal(err) + } + } + } +} + +const ( + sampleRules = `rules: +- apiGroups: [""] + resources: ["pods"] + verbs: ["get", "watch", "list"] +` + + sampleSelector = `matchLabels: + target: integration-test +` +) + +var ( + sampleExtraLabels = map[string]string{ + "key1": "value1", + "key2": "value2", + } + sampleExtraAnnotations = map[string]string{ + "key3": "value3", + "key4": "value4", + } +) + +const ( + thirtyMinutes json.Number = "1800" + oneHour json.Number = "3600" + oneDay json.Number = "86400" +) + +func runCmd(command string) string { + parts := strings.Split(command, " ") + fmt.Println(parts) + cmd := exec.Command(parts[0], parts[1:]...) + out := &bytes.Buffer{} + cmd.Stdout = out + cmd.Stderr = out + if err := cmd.Run(); err != nil { + panic(fmt.Sprintf("Got unexpected output: %s, err = %s", out.String(), err)) + } + return out.String() +} + +func getSuperJWT() string { + return runCmd("kubectl --namespace=test create token super-jwt") +} + +func getBrokenJWT() string { + return runCmd("kubectl --namespace=test create token broken-jwt") +} + +func getK8sCA() string { + return runCmd("kubectl exec --namespace=test vault-0 -- cat /var/run/secrets/kubernetes.io/serviceaccount/ca.crt") +} + +func getKubeHost(clusterName string) string { + cmd := fmt.Sprintf(`kubectl config view --raw --minify --flatten --output=jsonpath={.clusters[?(@.name=="kind-%s")].cluster.server}`, clusterName) + return runCmd(cmd) +} diff --git a/builtin/logical/kubernetes/integrationtest/kind/config.yaml b/builtin/logical/kubernetes/integrationtest/kind/config.yaml new file mode 100644 index 0000000000..18b71200da --- /dev/null +++ b/builtin/logical/kubernetes/integrationtest/kind/config.yaml @@ -0,0 +1,12 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +nodes: +- role: control-plane + extraPortMappings: + - containerPort: 8200 + hostPort: 38300 + listenAddress: "127.0.0.1" + protocol: TCP diff --git a/builtin/logical/kubernetes/integrationtest/vault/Dockerfile b/builtin/logical/kubernetes/integrationtest/vault/Dockerfile new file mode 100644 index 0000000000..0c361286d3 --- /dev/null +++ b/builtin/logical/kubernetes/integrationtest/vault/Dockerfile @@ -0,0 +1,14 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +FROM docker.mirror.hashicorp.services/hashicorp/vault-enterprise:1.15.0-ent as enterprise + +# Don't use `kubernetes` as plugin name to ensure we don't silently fall back to +# the built-in kubernetes secrets plugin if something goes wrong. +COPY --chown=vault:vault vault-plugin-secrets-kubernetes /vault/plugin_directory/kubernetes-dev + +FROM docker.mirror.hashicorp.services/hashicorp/vault:1.15.0 + +# Don't use `kubernetes` as plugin name to ensure we don't silently fall back to +# the built-in kubernetes secrets plugin if something goes wrong. +COPY --chown=vault:vault vault-plugin-secrets-kubernetes /vault/plugin_directory/kubernetes-dev diff --git a/builtin/logical/kubernetes/integrationtest/vault/hostPortPatch.yaml b/builtin/logical/kubernetes/integrationtest/vault/hostPortPatch.yaml new file mode 100644 index 0000000000..3fa137bfba --- /dev/null +++ b/builtin/logical/kubernetes/integrationtest/vault/hostPortPatch.yaml @@ -0,0 +1,16 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +# This patch adds a hostPort to Vault so that kind can forward traffic all +# the way from the host machine to the Vault container. The helm chart doesn't +# support this directly, and doesn't have a compelling reason to either. +# See Makefile and the setup-integration-test target for usage. +spec: + template: + spec: + containers: + - name: vault + ports: + - name: http + hostPort: 8200 + containerPort: 8200 diff --git a/builtin/logical/kubernetes/integrationtest/vault/testBindings.yaml b/builtin/logical/kubernetes/integrationtest/vault/testBindings.yaml new file mode 100644 index 0000000000..c0f4b643b9 --- /dev/null +++ b/builtin/logical/kubernetes/integrationtest/vault/testBindings.yaml @@ -0,0 +1,84 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: k8s-secrets-abilities-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: k8s-secrets-abilities +subjects: +- kind: ServiceAccount + name: test-token-create + namespace: test +- kind: ServiceAccount + name: vault + namespace: test +- kind: ServiceAccount + name: super-jwt + namespace: test +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: test-clusterrole-abilities +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: test-cluster-role-list-pods +subjects: +- kind: ServiceAccount + name: test-token-create + namespace: test +- kind: ServiceAccount + name: vault + namespace: test +- kind: ServiceAccount + name: broken-jwt + namespace: test +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: test-capabilities +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: test-capabilities +subjects: +- kind: ServiceAccount + name: super-jwt + namespace: test +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: test-role-abilities + namespace: test +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: test-role-list-pods +subjects: +- kind: ServiceAccount + name: sample-app + namespace: test +- kind: ServiceAccount + name: broken-jwt + namespace: test +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: k8s-secrets-abilities-binding-broken +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: k8s-secrets-abilities-broken +subjects: +- kind: ServiceAccount + name: broken-jwt + namespace: test diff --git a/builtin/logical/kubernetes/integrationtest/vault/testRoles.yaml b/builtin/logical/kubernetes/integrationtest/vault/testRoles.yaml new file mode 100644 index 0000000000..0999790bd2 --- /dev/null +++ b/builtin/logical/kubernetes/integrationtest/vault/testRoles.yaml @@ -0,0 +1,108 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: k8s-secrets-abilities +rules: +- apiGroups: + - "" + resources: + - serviceaccounts/token + verbs: + - create +- apiGroups: [""] + resources: + - namespaces + verbs: + - get +- apiGroups: [""] + resources: + - serviceaccounts + verbs: + - create + - delete +- apiGroups: + - rbac.authorization.k8s.io + resources: + - rolebindings + - roles + - clusterrolebindings + - clusterroles + verbs: + - create + - delete +--- +## This cluster role is for testing the WAL + ownerRef rollback of orphaned k8s +## objects created during a creds/ call (it's missing serviceaccounts +## privileges) +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: k8s-secrets-abilities-broken +rules: +- apiGroups: + - "" + resources: + - serviceaccounts/token + verbs: + - create +- apiGroups: + - "" + resources: + - namespaces + verbs: + - get +- apiGroups: + - rbac.authorization.k8s.io + resources: + - rolebindings + - roles + - clusterrolebindings + - clusterroles + verbs: + - create + - delete +--- +## This cluster role is to allow tests to inspect k8s objects +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: test-capabilities +rules: +- apiGroups: [""] + resources: + - serviceaccounts + verbs: + - get + - list +- apiGroups: + - rbac.authorization.k8s.io + resources: + - rolebindings + - roles + - clusterrolebindings + - clusterroles + verbs: + - get + - list +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: test-role-list-pods + namespace: test +rules: +- apiGroups: [""] + resources: ["pods"] + verbs: ["list"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: test-cluster-role-list-pods +rules: +- apiGroups: [""] + resources: ["pods"] + verbs: ["list"] diff --git a/builtin/logical/kubernetes/integrationtest/vault/testServiceAccounts.yaml b/builtin/logical/kubernetes/integrationtest/vault/testServiceAccounts.yaml new file mode 100644 index 0000000000..37e869df16 --- /dev/null +++ b/builtin/logical/kubernetes/integrationtest/vault/testServiceAccounts.yaml @@ -0,0 +1,26 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +apiVersion: v1 +kind: ServiceAccount +metadata: + name: test-token-create + namespace: test +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: sample-app + namespace: test +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: super-jwt + namespace: test +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: broken-jwt + namespace: test diff --git a/builtin/logical/kubernetes/integrationtest/wal_rollback_test.go b/builtin/logical/kubernetes/integrationtest/wal_rollback_test.go new file mode 100644 index 0000000000..52417e100f --- /dev/null +++ b/builtin/logical/kubernetes/integrationtest/wal_rollback_test.go @@ -0,0 +1,298 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package integrationtest + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/cenkalti/backoff/v3" + "github.com/openbao/openbao/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/kubernetes" +) + +func TestCreds_wal_rollback(t *testing.T) { + if _, ok := os.LookupEnv("SKIP_WAL_TEST"); ok { + t.Skip("Skipping WAL rollback test") + } + + // Pick up VAULT_ADDR and VAULT_TOKEN from env vars + baseClient, err := api.NewClient(nil) + if err != nil { + t.Fatal(err) + } + + client, delNamespace := namespaceHelper(t, baseClient) + defer delNamespace() + + t.Run("generated_role_rules", func(t *testing.T) { + t.Parallel() + mountPath, umount := mountHelper(t, client) + defer umount() + + // create default config + _, err = client.Logical().Write(mountPath+"/config", map[string]interface{}{ + "service_account_jwt": os.Getenv("BROKEN_JWT"), + }) + require.NoError(t, err) + + roleRulesYAML := `rules: +- apiGroups: [""] + resources: ["pods"] + verbs: ["list"]` + + extraLabels := map[string]string{ + "environment": "testing", + "test": "wal_rollback", + "type": "role", + } + extraAnnotations := map[string]string{ + "tested": "today", + } + roleConfig := map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"test"}, + "extra_annotations": extraAnnotations, + "extra_labels": extraLabels, + "generated_role_rules": roleRulesYAML, + "kubernetes_role_type": "RolE", + "token_default_ttl": "1h", + "token_max_ttl": "24h", + "token_default_audiences": []string{"foobar"}, + } + expectedRoleResponse := map[string]interface{}{ + "allowed_kubernetes_namespaces": []interface{}{"test"}, + "allowed_kubernetes_namespace_selector": "", + "extra_annotations": asMapInterface(extraAnnotations), + "extra_labels": asMapInterface(extraLabels), + "generated_role_rules": roleRulesYAML, + "kubernetes_role_name": "", + "kubernetes_role_type": "Role", + "name": "walrole", + "name_template": "", + "service_account_name": "", + "token_max_ttl": oneDay, + "token_default_ttl": oneHour, + "token_default_audiences": []interface{}{"foobar"}, + } + + _, err := client.Logical().Write(mountPath+"/roles/walrole", roleConfig) + require.NoError(t, err) + + roleResult, err := client.Logical().Read(mountPath + "/roles/walrole") + assert.NoError(t, err) + assert.Equal(t, expectedRoleResponse, roleResult.Data) + + // This will fail because it can't create a ServiceAccount. Wait for the + // WALRollbackMinAge, then verify that the objects aren't around by + // using the additional metadata.labels that were passed in. + credsResponse, err := client.Logical().Write(mountPath+"/creds/walrole", map[string]interface{}{ + "kubernetes_namespace": "test", + "cluster_role_binding": false, + "ttl": "2h", + }) + assert.Error(t, err) + assert.Nil(t, credsResponse) + assert.Contains(t, err.Error(), `User "system:serviceaccount:test:broken-jwt" cannot create resource "serviceaccounts" in API group "" in the namespace "test"`) + + t.Log("Checking for hanging k8s objects") + checkObjects(t, roleConfig, false, true, 10*time.Second) + + // The backend's WAL min age is 10 seconds for tests. After that the k8s + // objects should be cleaned up. + t.Log("Checking hanging objects have been cleaned up") + checkObjects(t, roleConfig, false, false, 3*time.Minute) + }) + + t.Run("kubernetes_role_name", func(t *testing.T) { + t.Parallel() + mountPath, umount := mountHelper(t, client) + defer umount() + + // create default config + _, err = client.Logical().Write(mountPath+"/config", map[string]interface{}{ + "service_account_jwt": os.Getenv("BROKEN_JWT"), + }) + require.NoError(t, err) + + extraLabels := map[string]string{ + "environment": "staging", + "test": "wal_rollback", + "type": "clusterrolebinding", + } + extraAnnotations := map[string]string{ + "tested": "tomorrow", + "checked": "again", + } + roleConfig := map[string]interface{}{ + "allowed_kubernetes_namespace_selector": `{"matchExpressions": [{"key": "target", "operator": "In", "values": ["integration-test"]}, {"key": "nonexistantlabel", "operator": "DoesNotExist", "values": []}]}`, + "extra_annotations": extraAnnotations, + "extra_labels": extraLabels, + "kubernetes_role_name": "test-cluster-role-list-pods", + "kubernetes_role_type": "ClusterRole", + "token_default_ttl": "1h", + "token_max_ttl": "24h", + "token_default_audiences": []string{"foobar"}, + } + expectedRoleResponse := map[string]interface{}{ + "allowed_kubernetes_namespaces": interface{}(nil), + "allowed_kubernetes_namespace_selector": `{"matchExpressions": [{"key": "target", "operator": "In", "values": ["integration-test"]}, {"key": "nonexistantlabel", "operator": "DoesNotExist", "values": []}]}`, + "extra_annotations": asMapInterface(extraAnnotations), + "extra_labels": asMapInterface(extraLabels), + "generated_role_rules": "", + "kubernetes_role_name": "test-cluster-role-list-pods", + "kubernetes_role_type": "ClusterRole", + "name": "walrolebinding", + "name_template": "", + "service_account_name": "", + "token_max_ttl": oneDay, + "token_default_ttl": oneHour, + "token_default_audiences": []interface{}{"foobar"}, + } + + _, err := client.Logical().Write(mountPath+"/roles/walrolebinding", roleConfig) + require.NoError(t, err) + + roleResult, err := client.Logical().Read(mountPath + "/roles/walrolebinding") + assert.NoError(t, err) + assert.Equal(t, expectedRoleResponse, roleResult.Data) + + // This will fail because it can't create a ServiceAccount. Wait for the + // WALRollbackMinAge, then verify that the objects aren't around by + // using the additional metadata.labels that were passed in. + credsResponse, err := client.Logical().Write(mountPath+"/creds/walrolebinding", map[string]interface{}{ + "kubernetes_namespace": "test", + "cluster_role_binding": true, + "ttl": "2h", + }) + assert.Error(t, err) + assert.Nil(t, credsResponse) + assert.Contains(t, err.Error(), `User "system:serviceaccount:test:broken-jwt" cannot create resource "serviceaccounts" in API group "" in the namespace "test"`) + + t.Log("Checking for hanging k8s objects") + checkObjects(t, roleConfig, true, true, 10*time.Second) + + // The backend's WAL min age is 10 seconds for tests. After that the k8s + // objects should be cleaned up. + t.Log("Checking hanging objects have been cleaned up") + checkObjects(t, roleConfig, true, false, 3*time.Minute) + }) +} + +func checkObjects(t *testing.T, roleConfig map[string]interface{}, isClusterBinding bool, shouldExist bool, maxWaitTime time.Duration) { + t.Helper() + + k8sClient := newK8sClient(t, os.Getenv("SUPER_JWT")) + roleType := strings.ToLower(roleConfig["kubernetes_role_type"].(string)) + existingRole := "" + if value, ok := roleConfig["kubernetes_role_name"]; ok { + existingRole = value.(string) + } + + // Query by labels since we may not know the name + l := makeExpectedLabels(t, asMapInterface(roleConfig["extra_labels"].(map[string]string))) + validatedSelector, err := labels.ValidatedSelectorFromSet(l) + require.NoError(t, err) + listOptions := metav1.ListOptions{ + LabelSelector: validatedSelector.String(), + } + + // Check the k8s objects that should have been created (all but the ServiceAccount) + operation := func() error { + if existingRole == "" { + exists, err := checkRoleExists(k8sClient, listOptions, roleType) + require.NoError(t, err) + if exists != shouldExist { + return fmt.Errorf("%s exists (%v) but should be (%v)", roleType, exists, shouldExist) + } + } + + exists, err := checkRoleBindingExists(k8sClient, listOptions, isClusterBinding) + require.NoError(t, err) + if exists != shouldExist { + return fmt.Errorf("binding (cluster %v) exists (%v) but should be (%v)", isClusterBinding, exists, shouldExist) + } + + exists, err = checkServiceAccountExists(k8sClient, listOptions) + require.NoError(t, err) + // No permission to create services accounts, so they should never get created + if exists { + return fmt.Errorf("service account exists (%v) but should be (false)", exists) + } + + return nil + } + bo := backoff.NewExponentialBackOff() + bo.MaxElapsedTime = maxWaitTime + // Don't actually back off, just keep retrying quickly to speed up the test. + bo.Multiplier = 1 + + err = backoff.Retry(operation, bo) + assert.NoError(t, err, "timed out waiting for objects to exist=%v", shouldExist) +} + +func checkRoleExists(k8sClient kubernetes.Interface, listOptions metav1.ListOptions, roleType string) (bool, error) { + switch roleType { + case "role": + roles, err := k8sClient.RbacV1().Roles("test").List(context.Background(), listOptions) + if err != nil { + return false, err + } + if roles == nil { + return false, fmt.Errorf("roles list response was nil") + } + return len(roles.Items) > 0, nil + case "clusterrole": + roles, err := k8sClient.RbacV1().ClusterRoles().List(context.Background(), listOptions) + if err != nil { + return false, err + } + if roles == nil { + return false, fmt.Errorf("cluster roles list response was nil") + } + return len(roles.Items) > 0, nil + } + + return false, fmt.Errorf("unknown roleType: %s", roleType) +} + +func checkRoleBindingExists(k8sClient kubernetes.Interface, listOptions metav1.ListOptions, isClusterBinding bool) (bool, error) { + if isClusterBinding { + clusterBindings, err := k8sClient.RbacV1().ClusterRoleBindings().List(context.Background(), listOptions) + if err != nil { + return false, err + } + if clusterBindings == nil { + return false, fmt.Errorf("cluster role bindings list response was nil") + } + return len(clusterBindings.Items) > 0, nil + } else { + bindings, err := k8sClient.RbacV1().RoleBindings("test").List(context.Background(), listOptions) + if err != nil { + return false, err + } + if bindings == nil { + return false, fmt.Errorf("role bindings list response was nil") + } + return len(bindings.Items) > 0, nil + } +} + +func checkServiceAccountExists(k8sClient kubernetes.Interface, listOptions metav1.ListOptions) (bool, error) { + acct, err := k8sClient.CoreV1().ServiceAccounts("test").List(context.Background(), listOptions) + if err != nil { + return false, err + } + if acct == nil { + return false, fmt.Errorf("service account list response was nil") + } + return len(acct.Items) > 0, nil +} diff --git a/builtin/logical/kubernetes/kube_service_account.go b/builtin/logical/kubernetes/kube_service_account.go new file mode 100644 index 0000000000..6be2bde76d --- /dev/null +++ b/builtin/logical/kubernetes/kube_service_account.go @@ -0,0 +1,71 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package kubesecrets + +import ( + "context" + "fmt" + + "github.com/hashicorp/go-multierror" + "github.com/openbao/openbao/sdk/framework" + "github.com/openbao/openbao/sdk/logical" +) + +func (b *backend) kubeServiceAccount() *framework.Secret { + return &framework.Secret{ + Type: kubeTokenType, + Fields: map[string]*framework.FieldSchema{ + "service_account_namespace": { + Type: framework.TypeString, + Description: "Kubernetes Namespace", + }, + "service_account_name": { + Type: framework.TypeString, + Description: "Kubernetes Service Account Name", + }, + "service_account_token": { + Type: framework.TypeString, + Description: "Kubernetes Service Account Token", + }, + }, + Revoke: b.kubeTokenRevoke, + } +} + +func (b *backend) kubeTokenRevoke(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + client, err := b.getClient(ctx, req.Storage) + if err != nil { + return nil, err + } + + namespace := req.Secret.InternalData["service_account_namespace"].(string) + isClusterRoleBinding := req.Secret.InternalData["cluster_role_binding"].(bool) + k8sServiceAccount := req.Secret.InternalData["created_service_account"].(string) + k8sRoleBinding := req.Secret.InternalData["created_role_binding"].(string) + k8sRole := req.Secret.InternalData["created_role"].(string) + k8sRoleType := req.Secret.InternalData["created_role_type"].(string) + + var errs *multierror.Error + if k8sRole != "" { + if err := client.deleteRole(ctx, namespace, k8sRole, k8sRoleType); err != nil { + errs = multierror.Append(fmt.Errorf("failed to delete %s '%s/%s': %s", k8sRoleType, namespace, k8sRole, err)) + } + } + if k8sRoleBinding != "" { + if err := client.deleteRoleBinding(ctx, namespace, k8sRoleBinding, isClusterRoleBinding); err != nil { + roleType := "RoleBinding" + if isClusterRoleBinding { + roleType = "ClusterRoleBinding" + } + errs = multierror.Append(errs, fmt.Errorf("failed to delete %s '%s/%s: %s", roleType, namespace, k8sRoleBinding, err)) + } + } + if k8sServiceAccount != "" { + if err := client.deleteServiceAccount(ctx, namespace, k8sServiceAccount); err != nil { + errs = multierror.Append(fmt.Errorf("failed to delete ServiceAccount '%s/%s': %s", namespace, k8sServiceAccount, err)) + } + } + + return nil, errs.ErrorOrNil() +} diff --git a/builtin/logical/kubernetes/path_check.go b/builtin/logical/kubernetes/path_check.go new file mode 100644 index 0000000000..b378f48cc2 --- /dev/null +++ b/builtin/logical/kubernetes/path_check.go @@ -0,0 +1,62 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package kubesecrets + +import ( + "context" + "fmt" + "net/http" + "os" + "strings" + + "github.com/openbao/openbao/sdk/framework" + "github.com/openbao/openbao/sdk/logical" +) + +const ( + checkPath = "check" + checkHelpSynopsis = `Checks the Kubernetes configuration is valid.` + checkHelpDescription = `Checks the Kubernetes configuration is valid, checking if required environment variables are set.` +) + +var envVarsToCheck = []string{k8sServiceHostEnv, k8sServicePortEnv} + +func (b *backend) pathCheck() *framework.Path { + return &framework.Path{ + Pattern: checkPath + "/?$", + DisplayAttrs: &framework.DisplayAttributes{ + OperationPrefix: operationPrefixKubernetes, + OperationVerb: "check", + OperationSuffix: "configuration", + }, + Operations: map[logical.Operation]framework.OperationHandler{ + logical.ReadOperation: &framework.PathOperation{ + Callback: b.pathCheckRead, + }, + }, + HelpSynopsis: checkHelpSynopsis, + HelpDescription: checkHelpDescription, + } +} + +func (b *backend) pathCheckRead(_ context.Context, _ *logical.Request, _ *framework.FieldData) (*logical.Response, error) { + var missing []string + for _, key := range envVarsToCheck { + val := os.Getenv(key) + if val == "" { + missing = append(missing, key) + } + } + + if len(missing) == 0 { + return &logical.Response{ + Data: map[string]interface{}{ + logical.HTTPStatusCode: http.StatusNoContent, + }, + }, nil + } + + missingText := strings.Join(missing, ", ") + return logical.ErrorResponse(fmt.Sprintf("Missing environment variables: %s", missingText)), nil +} diff --git a/builtin/logical/kubernetes/path_config.go b/builtin/logical/kubernetes/path_config.go new file mode 100644 index 0000000000..92b2273ef8 --- /dev/null +++ b/builtin/logical/kubernetes/path_config.go @@ -0,0 +1,251 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package kubesecrets + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/openbao/openbao/sdk/framework" + "github.com/openbao/openbao/sdk/logical" +) + +const ( + configPath = "config" + localCACertPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" + localJWTPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" + k8sServiceHostEnv = "KUBERNETES_SERVICE_HOST" + k8sServicePortEnv = "KUBERNETES_SERVICE_PORT_HTTPS" +) + +// kubeConfig contains the public key certificate used to verify the signature +// on the service account JWTs +type kubeConfig struct { + // Host is the url string for the kubernetes API + Host string `json:"kubernetes_host"` + + // CACert is the CA Cert to use to call into the kubernetes API + CACert string `json:"kubernetes_ca_cert"` + + // ServiceAccountJwt is the bearer token to use when authenticating to the + // kubernetes API + ServiceAccountJwt string `json:"service_account_jwt"` + + // DisableLocalJWT is an optional parameter to disable defaulting to using + // the local CA cert and service account jwt when running in a Kubernetes + // pod + DisableLocalCAJwt bool `json:"disable_local_ca_jwt"` +} + +func (b *backend) pathConfig() *framework.Path { + return &framework.Path{ + Pattern: configPath, + DisplayAttrs: &framework.DisplayAttributes{ + OperationPrefix: operationPrefixKubernetes, + }, + Fields: map[string]*framework.FieldSchema{ + "disable_local_ca_jwt": { + Type: framework.TypeBool, + Description: "Disable defaulting to the local CA certificate and service account JWT when running in a Kubernetes pod.", + Default: false, + DisplayAttrs: &framework.DisplayAttributes{ + Name: "Disable use of local CA and service account JWT", + }, + }, + "kubernetes_ca_cert": { + Type: framework.TypeString, + Description: "PEM encoded CA certificate to use to verify the Kubernetes API server certificate. Defaults to the local pod's CA if found.", + DisplayAttrs: &framework.DisplayAttributes{ + Name: "Kubernetes CA Certificate", + }, + }, + "kubernetes_host": { + Type: framework.TypeString, + Description: "Kubernetes API URL to connect to. Defaults to https://$KUBERNETES_SERVICE_HOST:KUBERNETES_SERVICE_PORT if those environment variables are set.", + DisplayAttrs: &framework.DisplayAttributes{ + Name: "Kubernetes API URL", + }, + }, + "service_account_jwt": { + Type: framework.TypeString, + Description: "The JSON web token of the service account used by the secret engine to manage Kubernetes credentials. Defaults to the local pod's JWT if found.", + DisplayAttrs: &framework.DisplayAttributes{ + Name: "Kubernetes API JWT", + }, + }, + }, + Operations: map[logical.Operation]framework.OperationHandler{ + logical.UpdateOperation: &framework.PathOperation{ + Callback: b.pathConfigWrite, + DisplayAttrs: &framework.DisplayAttributes{ + OperationVerb: "configure", + }, + }, + logical.ReadOperation: &framework.PathOperation{ + Callback: b.pathConfigRead, + DisplayAttrs: &framework.DisplayAttributes{ + OperationSuffix: "configuration", + }, + }, + logical.DeleteOperation: &framework.PathOperation{ + Callback: b.pathConfigDelete, + DisplayAttrs: &framework.DisplayAttributes{ + OperationSuffix: "configuration", + }, + }, + }, + HelpSynopsis: "Configure the Kubernetes secret engine plugin.", + HelpDescription: "This path configures the Kubernetes secret engine plugin. See the documentation for the " + + "plugin specified for a full list of accepted connection details.", + } +} + +// pathConfigWrite handles create and update commands to the config +func (b *backend) pathConfigRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + if config, err := getConfig(ctx, req.Storage); err != nil { + return nil, err + } else if config == nil { + return nil, nil + } else { + // Create a map of data to be returned. Note that these reflect just the + // values that the user set, not what the defaults will be if they + // aren't set (see configWithDynamicValues() for those defaults). And + // the service account jwt is omitted as sensitive data. + resp := &logical.Response{ + Data: map[string]interface{}{ + "disable_local_ca_jwt": config.DisableLocalCAJwt, + "kubernetes_ca_cert": config.CACert, + "kubernetes_host": config.Host, + }, + } + + return resp, nil + } +} + +func (b *backend) pathConfigWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + config, err := getConfig(ctx, req.Storage) + if err != nil { + return nil, err + } + if config == nil { + config = &kubeConfig{} + } + + if host, ok := data.GetOk("kubernetes_host"); ok { + config.Host = host.(string) + } else if _, err := getK8sURLFromEnv(); err != nil { + return nil, errors.New("kubernetes_host was unset and could not be determined from environment variables") + } + if disableLocalJWT, ok := data.GetOk("disable_local_ca_jwt"); ok { + config.DisableLocalCAJwt = disableLocalJWT.(bool) + } + if caCert, ok := data.GetOk("kubernetes_ca_cert"); ok { + config.CACert = caCert.(string) + } + if serviceAccountJWT, ok := data.GetOk("service_account_jwt"); ok { + config.ServiceAccountJwt = serviceAccountJWT.(string) + } + + entry, err := logical.StorageEntryJSON(configPath, config) + if err != nil { + return nil, err + } + + if err := req.Storage.Put(ctx, entry); err != nil { + return nil, err + } + + // reset the client so the next invocation will pick up the new configuration + b.reset() + + return nil, nil +} + +func (b *backend) pathConfigDelete(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + err := req.Storage.Delete(ctx, configPath) + + if err == nil { + b.reset() + } + + return nil, err +} + +// configWithDynamicValues fetches the kubeConfig from storage and sets any +// runtime defaults for host, local token, and local CA certificate. +func (b *backend) configWithDynamicValues(ctx context.Context, s logical.Storage) (*kubeConfig, error) { + config, err := getConfig(ctx, s) + if err != nil { + return nil, err + } + if config == nil { + return nil, errors.New("could not load backend configuration") + } + + // If host is blank, default to reading from env + if config.Host == "" { + config.Host, err = getK8sURLFromEnv() + if err != nil { + return nil, errors.New("kubernetes_host was unset and could not determine it from environment variables") + } + } + + // Nothing more to do if loading local CA cert and JWT token is disabled. + if config.DisableLocalCAJwt { + return config, nil + } + + // Read local JWT token unless it was not stored in config. + if config.ServiceAccountJwt == "" { + jwtBytes, err := b.localSATokenReader.ReadFile() + if err != nil { + // Ignore error: make best effort trying to load local JWT, + // otherwise the JWT submitted in login payload will be used. + b.Logger().Debug("failed to read local service account token, will use client token", "error", err) + } + config.ServiceAccountJwt = string(jwtBytes) + } + + // Read local CA cert unless it was stored in config. + if config.CACert == "" { + caBytes, err := b.localCACertReader.ReadFile() + if err != nil { + return nil, err + } + config.CACert = string(caBytes) + } + + return config, nil +} + +func getConfig(ctx context.Context, s logical.Storage) (*kubeConfig, error) { + entry, err := s.Get(ctx, configPath) + if err != nil { + return nil, err + } + + if entry == nil { + return nil, nil + } + + config := new(kubeConfig) + if err := entry.DecodeJSON(&config); err != nil { + return nil, fmt.Errorf("error reading root configuration: %w", err) + } + + // return the config, we are done + return config, nil +} + +func getK8sURLFromEnv() (string, error) { + host := os.Getenv(k8sServiceHostEnv) + port := os.Getenv(k8sServicePortEnv) + if host == "" || port == "" { + return "", fmt.Errorf("failed to find k8s API host variables %q and %q in env", k8sServiceHostEnv, k8sServicePortEnv) + } + return fmt.Sprintf("https://%s:%s", host, port), nil +} diff --git a/builtin/logical/kubernetes/path_config_test.go b/builtin/logical/kubernetes/path_config_test.go new file mode 100644 index 0000000000..cef7ae4dae --- /dev/null +++ b/builtin/logical/kubernetes/path_config_test.go @@ -0,0 +1,221 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package kubesecrets + +import ( + "context" + "io/ioutil" + "os" + "testing" + + "github.com/hashicorp/go-secure-stdlib/fileutil" + "github.com/openbao/openbao/sdk/logical" + "github.com/stretchr/testify/assert" +) + +const ( + testLocalCACert = "local ca cert" + testLocalJWT = "local jwt" + testCACert = "ca cert" +) + +func setupLocalFiles(t *testing.T, b logical.Backend) func() { + cert, err := ioutil.TempFile("", "ca.crt") + if err != nil { + t.Fatal(err) + } + cert.WriteString(testLocalCACert) + cert.Close() + + token, err := ioutil.TempFile("", "token") + if err != nil { + t.Fatal(err) + } + token.WriteString(testLocalJWT) + token.Close() + b.(*backend).localCACertReader = fileutil.NewCachingFileReader(cert.Name(), caReloadPeriod) + b.(*backend).localSATokenReader = fileutil.NewCachingFileReader(token.Name(), jwtReloadPeriod) + + return func() { + os.Remove(cert.Name()) + os.Remove(token.Name()) + } +} + +func setupK8sEnvVars() func() { + os.Setenv(k8sServiceHostEnv, "env-host") + os.Setenv(k8sServicePortEnv, "123") + + return func() { + defer os.Unsetenv(k8sServiceHostEnv) + defer os.Unsetenv(k8sServicePortEnv) + } +} + +func Test_configWithDynamicValues(t *testing.T) { + testCases := map[string]struct { + config map[string]interface{} + setupInClusterFiles bool + setupK8sEnvVars bool + expected *kubeConfig + }{ + "empty config uses env": { + config: map[string]interface{}{}, + setupInClusterFiles: true, + setupK8sEnvVars: true, + expected: &kubeConfig{ + Host: "https://env-host:123", + CACert: testLocalCACert, + ServiceAccountJwt: testLocalJWT, + DisableLocalCAJwt: false, + }, + }, + "no CA or JWT, default to local": { + config: map[string]interface{}{ + "kubernetes_host": "host", + }, + setupInClusterFiles: true, + expected: &kubeConfig{ + Host: "host", + CACert: testLocalCACert, + ServiceAccountJwt: testLocalJWT, + DisableLocalCAJwt: false, + }, + }, + "CA set, default to local JWT": { + config: map[string]interface{}{ + "kubernetes_host": "host", + "kubernetes_ca_cert": testCACert, + }, + setupInClusterFiles: true, + expected: &kubeConfig{ + Host: "host", + CACert: testCACert, + ServiceAccountJwt: testLocalJWT, + DisableLocalCAJwt: false, + }, + }, + "JWT set, default to local CA": { + config: map[string]interface{}{ + "kubernetes_host": "host", + "service_account_jwt": "jwt", + }, + setupInClusterFiles: true, + expected: &kubeConfig{ + Host: "host", + CACert: testLocalCACert, + ServiceAccountJwt: "jwt", + DisableLocalCAJwt: false, + }, + }, + "CA and disable local default": { + config: map[string]interface{}{ + "kubernetes_host": "host", + "kubernetes_ca_cert": testCACert, + "disable_local_ca_jwt": true, + }, + expected: &kubeConfig{ + Host: "host", + CACert: testCACert, + ServiceAccountJwt: "", + DisableLocalCAJwt: true, + }, + }, + "no CA and disable local default": { + config: map[string]interface{}{ + "kubernetes_host": "host", + "disable_local_ca_jwt": true, + }, + expected: &kubeConfig{ + Host: "host", + CACert: "", + ServiceAccountJwt: "", + DisableLocalCAJwt: true, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + b, storage := getTestBackend(t) + + if tc.setupInClusterFiles { + cleanup := setupLocalFiles(t, b) + defer cleanup() + } + if tc.setupK8sEnvVars { + cleanup := setupK8sEnvVars() + defer cleanup() + } + + req := &logical.Request{ + Operation: logical.UpdateOperation, + Path: configPath, + Storage: storage, + Data: tc.config, + } + resp, err := b.HandleRequest(context.Background(), req) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%s resp:%#v\n", err, resp) + } + + conf, err := b.configWithDynamicValues(context.Background(), storage) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, tc.expected, conf, "expected kubeconfig did not match the return from configWithDynamicValues()") + + req = &logical.Request{ + Operation: logical.ReadOperation, + Path: configPath, + Storage: storage, + Data: nil, + } + resp, err = b.HandleRequest(context.Background(), req) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%s resp:%#v\n", err, resp) + } + + // check that the config elements sent in are returned from read + for k, v := range tc.config { + if k == "service_account_jwt" { + continue + } + assert.Equal(t, v, resp.Data[k]) + } + // check that the other config elements returned are empty + for k, v := range resp.Data { + if _, ok := tc.config[k]; !ok { + assert.Empty(t, v) + } + } + assert.NotContains(t, resp.Data, "service_account_jwt") + }) + } +} + +func Test_getHostFromEnv(t *testing.T) { + t.Run("not set", func(t *testing.T) { + host, err := getK8sURLFromEnv() + assert.EqualError(t, err, `failed to find k8s API host variables "KUBERNETES_SERVICE_HOST" and "KUBERNETES_SERVICE_PORT_HTTPS" in env`) + assert.Empty(t, host) + }) + t.Run("both set", func(t *testing.T) { + os.Setenv(k8sServiceHostEnv, "some-host") + defer os.Unsetenv(k8sServiceHostEnv) + os.Setenv(k8sServicePortEnv, "123") + defer os.Unsetenv(k8sServicePortEnv) + host, err := getK8sURLFromEnv() + assert.NoError(t, err) + assert.Equal(t, "https://some-host:123", host) + }) + t.Run("one set", func(t *testing.T) { + os.Setenv(k8sServiceHostEnv, "some-host") + defer os.Unsetenv(k8sServiceHostEnv) + host, err := getK8sURLFromEnv() + assert.EqualError(t, err, `failed to find k8s API host variables "KUBERNETES_SERVICE_HOST" and "KUBERNETES_SERVICE_PORT_HTTPS" in env`) + assert.Empty(t, host) + }) +} diff --git a/builtin/logical/kubernetes/path_creds.go b/builtin/logical/kubernetes/path_creds.go new file mode 100644 index 0000000000..664c07760a --- /dev/null +++ b/builtin/logical/kubernetes/path_creds.go @@ -0,0 +1,465 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package kubesecrets + +import ( + "context" + "fmt" + "time" + + "github.com/mitchellh/mapstructure" + "github.com/openbao/openbao/sdk/framework" + "github.com/openbao/openbao/sdk/helper/strutil" + "github.com/openbao/openbao/sdk/helper/template" + "github.com/openbao/openbao/sdk/logical" + josejwt "gopkg.in/square/go-jose.v2/jwt" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" +) + +const ( + pathCreds = "creds/" + kubeTokenType = "kube_token" + + pathCredsHelpSyn = `Request Kubernetes service account credentials for a given Vault role.` + pathCredsHelpDesc = ` +This path creates dynamic Kubernetes service account credentials. +The associated Vault role can be configured to generate tokens for an +existing service account, create a new service account bound to an +existing Role/ClusterRole, or create a new service account and role +bindings. The service account token and any other objects created in +Kubernetes will be automatically deleted when the lease has expired. +` +) + +type credsRequest struct { + Namespace string `json:"kubernetes_namespace"` + ClusterRoleBinding bool `json:"cluster_role_binding"` + TTL time.Duration `json:"ttl"` + RoleName string `json:"role_name"` + Audiences []string `json:"audiences"` +} + +// The fields in nameMetadata are used for templated name generation +type nameMetadata struct { + DisplayName string + RoleName string +} + +func (b *backend) pathCredentials() *framework.Path { + forwardOperation := &framework.PathOperation{ + Callback: b.pathCredentialsRead, + ForwardPerformanceSecondary: true, + ForwardPerformanceStandby: true, + } + return &framework.Path{ + Pattern: pathCreds + framework.GenericNameRegex("name"), + DisplayAttrs: &framework.DisplayAttributes{ + OperationPrefix: operationPrefixKubernetes, + OperationVerb: "generate", + OperationSuffix: "credentials", + }, + Fields: map[string]*framework.FieldSchema{ + "name": { + Type: framework.TypeLowerCaseString, + Description: "Name of the Vault role", + Required: true, + }, + "kubernetes_namespace": { + Type: framework.TypeString, + Description: "The name of the Kubernetes namespace in which to generate the credentials", + Required: true, + }, + "cluster_role_binding": { + Type: framework.TypeBool, + Description: "If true, generate a ClusterRoleBinding to grant permissions across the whole cluster instead of within a namespace. Requires the Vault role to have kubernetes_role_type set to ClusterRole.", + }, + "ttl": { + Type: framework.TypeDurationSecond, + Description: "The TTL of the generated credentials", + }, + "audiences": { + Type: framework.TypeCommaStringSlice, + Description: "The intended audiences of the generated credentials", + }, + }, + + HelpSynopsis: pathCredsHelpSyn, + HelpDescription: pathCredsHelpDesc, + + Operations: map[logical.Operation]framework.OperationHandler{ + logical.UpdateOperation: forwardOperation, + }, + } +} + +func (b *backend) pathCredentialsRead(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + roleName := d.Get("name").(string) + + roleEntry, err := getRole(ctx, req.Storage, roleName) + if err != nil { + return nil, fmt.Errorf("error retrieving role: %w", err) + } + + if roleEntry == nil { + return logical.ErrorResponse(fmt.Sprintf("role '%s' does not exist", roleName)), nil + } + + request := &credsRequest{ + RoleName: roleName, + } + requestNamespace, ok := d.GetOk("kubernetes_namespace") + if ok { + request.Namespace = requestNamespace.(string) + } + + request.ClusterRoleBinding = d.Get("cluster_role_binding").(bool) + + ttlRaw, ok := d.GetOk("ttl") + if ok { + request.TTL = time.Duration(ttlRaw.(int)) * time.Second + } + + audiences, ok := d.Get("audiences").([]string) + if ok { + request.Audiences = audiences + } + + // Validate the request + isValidNs, err := b.isValidKubernetesNamespace(ctx, req, request, roleEntry) + if err != nil { + return nil, fmt.Errorf("error verifying namespace: %w", err) + } + if !isValidNs { + return logical.ErrorResponse(fmt.Sprintf("kubernetes_namespace '%s' is not present in role's allowed_kubernetes_namespaces or does not match role's label selector allowed_kubernetes_namespace_selector", request.Namespace)), nil + } + if request.ClusterRoleBinding && roleEntry.K8sRoleType == "Role" { + return logical.ErrorResponse("a ClusterRoleBinding cannot ref a Role"), nil + } + + return b.createCreds(ctx, req, roleEntry, request) +} + +func (b *backend) isValidKubernetesNamespace(ctx context.Context, req *logical.Request, request *credsRequest, role *roleEntry) (bool, error) { + if request.Namespace == "" { + if role.HasSingleK8sNamespace() { + // Assign the single namespace to the creds request namespace + request.Namespace = role.K8sNamespaces[0] + return true, nil + } + + return false, fmt.Errorf("'kubernetes_namespace' is required unless the Vault role has a single namespace specified") + } + + if strutil.StrListContains(role.K8sNamespaces, "*") || strutil.StrListContains(role.K8sNamespaces, request.Namespace) { + return true, nil + } + + if role.K8sNamespaceSelector == "" { + return false, nil + } + selector, err := makeLabelSelector(role.K8sNamespaceSelector) + if err != nil { + return false, err + } + + client, err := b.getClient(ctx, req.Storage) + if err != nil { + return false, err + } + nsLabels, err := client.getNamespaceLabelSet(ctx, request.Namespace) + if err != nil { + return false, err + } + labelSelector, err := metav1.LabelSelectorAsSelector(&selector) + if err != nil { + return false, err + } + return labelSelector.Matches(labels.Set(nsLabels)), nil +} + +func (b *backend) createCreds(ctx context.Context, req *logical.Request, role *roleEntry, reqPayload *credsRequest) (*logical.Response, error) { + client, err := b.getClient(ctx, req.Storage) + if err != nil { + return nil, err + } + nameTemplate := role.NameTemplate + if nameTemplate == "" { + nameTemplate = defaultNameTemplate + } + + up, err := template.NewTemplate(template.Template(nameTemplate)) + if err != nil { + return nil, fmt.Errorf("unable to initialize name template: %w", err) + } + um := nameMetadata{ + DisplayName: req.DisplayName, + RoleName: role.Name, + } + genName, err := up.Generate(um) + if err != nil { + return nil, fmt.Errorf("failed to generate name: %w", err) + } + + // Determine the TTL here, since it might come from the mount if nothing on + // the vault role or creds payload is specified, and we need to know it + // before creating K8s Token + theTTL := time.Duration(0) + switch { + case reqPayload.TTL > 0: + theTTL = reqPayload.TTL + case role.TokenDefaultTTL > 0: + theTTL = role.TokenDefaultTTL + default: + theTTL = b.System().DefaultLeaseTTL() + } + + var respWarning []string + // If the calculated TTL is greater than the role's max ttl, it'll be capped + // by the framework when returned. Catch it here so that the k8s token has + // the same capped TTL. + if role.TokenMaxTTL > 0 && theTTL > role.TokenMaxTTL { + respWarning = append(respWarning, fmt.Sprintf("ttl of %s is greater than the role's token_max_ttl of %s; capping accordingly", theTTL.String(), role.TokenMaxTTL.String())) + theTTL = role.TokenMaxTTL + } + // Similarly, if the calculated TTL is greater than the system's max lease + // ttl, cap accordingly here. + if theTTL > b.System().MaxLeaseTTL() { + respWarning = append(respWarning, fmt.Sprintf("ttl of %s is greater than Vault's max lease ttl %s; capping accordingly", theTTL.String(), b.System().MaxLeaseTTL().String())) + theTTL = b.System().MaxLeaseTTL() + } + + theAudiences := role.TokenDefaultAudiences + if len(reqPayload.Audiences) != 0 { + theAudiences = reqPayload.Audiences + } + + // These are created items to save internally and/or return to the caller + token := "" + serviceAccountName := "" + createdServiceAccountName := "" + createdK8sRoleBinding := "" + createdK8sRole := "" + + var walID string + + switch { + case role.ServiceAccountName != "": + // Create token for existing service account + status, err := client.createToken(ctx, reqPayload.Namespace, role.ServiceAccountName, theTTL, theAudiences) + if err != nil { + return nil, fmt.Errorf("failed to create a service account token for %s/%s: %s", reqPayload.Namespace, role.ServiceAccountName, err) + } + serviceAccountName = role.ServiceAccountName + token = status.Token + case role.K8sRoleName != "": + // Create rolebinding for existing role + // Create service account for existing role + // then token + // RoleBinding/ClusterRoleBinding will be the owning object + ownerRef := metav1.OwnerReference{} + walID, ownerRef, err = createRoleBindingWithWAL(ctx, client, req.Storage, reqPayload.Namespace, genName, role.K8sRoleName, reqPayload.ClusterRoleBinding, role) + if err != nil { + return nil, err + } + + err = createServiceAccount(ctx, client, reqPayload.Namespace, genName, role, ownerRef) + if err != nil { + return nil, err + } + + status, err := client.createToken(ctx, reqPayload.Namespace, genName, theTTL, theAudiences) + if err != nil { + return nil, fmt.Errorf("failed to create a service account token for %s/%s: %s", reqPayload.Namespace, genName, err) + } + token = status.Token + serviceAccountName = genName + createdServiceAccountName = genName + createdK8sRoleBinding = genName + case role.RoleRules != "": + // Create role, rolebinding, service account, token + // Role/ClusterRole will be the owning object + ownerRef := metav1.OwnerReference{} + walID, ownerRef, err = createRoleWithWAL(ctx, client, req.Storage, reqPayload.Namespace, genName, role) + if err != nil { + return nil, err + } + + err = createRoleBinding(ctx, client, reqPayload.Namespace, genName, genName, reqPayload.ClusterRoleBinding, role, ownerRef) + if err != nil { + return nil, err + } + + err = createServiceAccount(ctx, client, reqPayload.Namespace, genName, role, ownerRef) + if err != nil { + return nil, err + } + + status, err := client.createToken(ctx, reqPayload.Namespace, genName, theTTL, theAudiences) + if err != nil { + return nil, fmt.Errorf("failed to create a service account token for %s/%s: %s", reqPayload.Namespace, genName, err) + } + token = status.Token + createdK8sRole = genName + serviceAccountName = genName + createdServiceAccountName = genName + createdK8sRoleBinding = genName + + default: + return nil, fmt.Errorf("one of service_account_name, kubernetes_role_name, or generated_role_rules must be set") + } + + resp := b.Secret(kubeTokenType).Response(map[string]interface{}{ + "service_account_namespace": reqPayload.Namespace, + "service_account_name": serviceAccountName, + "service_account_token": token, + }, map[string]interface{}{ + // the internal data is whatever we need to cleanup on revoke + // (service_account_name, role, role_binding). + "role": reqPayload.RoleName, + "service_account_namespace": reqPayload.Namespace, + "cluster_role_binding": reqPayload.ClusterRoleBinding, + "created_service_account": createdServiceAccountName, + "created_role_binding": createdK8sRoleBinding, + "created_role": createdK8sRole, + "created_role_type": role.K8sRoleType, + }) + + resp.Secret.TTL = theTTL + if role.TokenMaxTTL > 0 { + resp.Secret.MaxTTL = role.TokenMaxTTL + } + + createdTokenTTL, err := getTokenTTL(token) + switch { + case err != nil: + return nil, fmt.Errorf("failed to read TTL of created Kubernetes token for %s/%s: %s", reqPayload.Namespace, genName, err) + case createdTokenTTL > theTTL: + respWarning = append(respWarning, fmt.Sprintf("the created Kubernetes service accout token TTL %v is greater than the Vault lease TTL %v", createdTokenTTL, theTTL)) + case createdTokenTTL < theTTL: + respWarning = append(respWarning, fmt.Sprintf("the created Kubernetes service accout token TTL %v is less than the Vault lease TTL %v; capping the lease TTL accordingly", createdTokenTTL, theTTL)) + resp.Secret.TTL = createdTokenTTL + } + + if len(respWarning) > 0 { + resp.Warnings = respWarning + } + + // Delete the WAL entry that was created, since all the k8s objects were + // created successfully (no need to rollback anymore) + if walID != "" { + if err := framework.DeleteWAL(ctx, req.Storage, walID); err != nil { + return nil, fmt.Errorf("error deleting WAL: %w", err) + } + } + + return resp, nil +} + +func (b *backend) getClient(ctx context.Context, s logical.Storage) (*client, error) { + b.lock.Lock() + defer b.lock.Unlock() + + client := b.client + if client != nil { + return client, nil + } + + config, err := b.configWithDynamicValues(ctx, s) + if err != nil { + return nil, err + } + + if b.client == nil && config == nil { + config = new(kubeConfig) + } + + b.client, err = newClient(config) + if err != nil { + return nil, err + } + + return b.client, nil +} + +// create service account +func createServiceAccount(ctx context.Context, client *client, namespace, name string, vaultRole *roleEntry, ownerRef metav1.OwnerReference) error { + _, err := client.createServiceAccount(ctx, namespace, name, vaultRole, ownerRef) + if err != nil { + return fmt.Errorf("failed to create service account '%s/%s': %s", namespace, name, err) + } + + return nil +} + +// create role binding and put a WAL entry +func createRoleBindingWithWAL(ctx context.Context, client *client, s logical.Storage, namespace, name, k8sRoleName string, isClusterRoleBinding bool, vaultRole *roleEntry) (string, metav1.OwnerReference, error) { + // Write a WAL entry in case the role binding create doesn't complete + walId, err := framework.PutWAL(ctx, s, walBindingKind, &walRoleBinding{ + Namespace: namespace, + Name: name, + IsCluster: isClusterRoleBinding, + Expiration: time.Now().Add(maxWALAge), + }) + if err != nil { + return "", metav1.OwnerReference{}, fmt.Errorf("error writing role binding WAL: %w", err) + } + + ownerRef, err := client.createRoleBinding(ctx, namespace, name, k8sRoleName, isClusterRoleBinding, vaultRole, nil) + if err != nil { + return "", ownerRef, fmt.Errorf("failed to create RoleBinding/ClusterRoleBinding '%s' for %s: %s", name, k8sRoleName, err) + } + + return walId, ownerRef, nil +} + +func createRoleBinding(ctx context.Context, client *client, namespace, name, k8sRoleName string, isClusterRoleBinding bool, vaultRole *roleEntry, ownerRef metav1.OwnerReference) error { + _, err := client.createRoleBinding(ctx, namespace, name, k8sRoleName, isClusterRoleBinding, vaultRole, &ownerRef) + if err != nil { + return fmt.Errorf("failed to create RoleBinding/ClusterRoleBinding '%s' for %s: %s", name, k8sRoleName, err) + } + return nil +} + +// create a role and put a WAL entry +func createRoleWithWAL(ctx context.Context, client *client, s logical.Storage, namespace, name string, vaultRole *roleEntry) (string, metav1.OwnerReference, error) { + // Write a WAL entry in case subsequent parts don't complete + walId, err := framework.PutWAL(ctx, s, walRoleKind, &walRole{ + Namespace: namespace, + Name: name, + RoleType: vaultRole.K8sRoleType, + Expiration: time.Now().Add(maxWALAge), + }) + if err != nil { + return "", metav1.OwnerReference{}, fmt.Errorf("error writing service account WAL: %w", err) + } + + ownerRef, err := client.createRole(ctx, namespace, name, vaultRole) + if err != nil { + return "", ownerRef, fmt.Errorf("failed to create Role/ClusterRole '%s/%s: %s", namespace, name, err) + } + + return walId, ownerRef, nil +} + +func getTokenTTL(token string) (time.Duration, error) { + parsed, err := josejwt.ParseSigned(token) + if err != nil { + return 0, err + } + claims := map[string]interface{}{} + err = parsed.UnsafeClaimsWithoutVerification(&claims) + if err != nil { + return 0, err + } + sa := struct { + Expiration int64 `mapstructure:"exp"` + IssuedAt int64 `mapstructure:"iat"` + }{} + err = mapstructure.Decode(claims, &sa) + if err != nil { + return 0, err + } + return time.Duration(sa.Expiration-sa.IssuedAt) * time.Second, nil +} diff --git a/builtin/logical/kubernetes/path_creds_test.go b/builtin/logical/kubernetes/path_creds_test.go new file mode 100644 index 0000000000..a08b8bb6f2 --- /dev/null +++ b/builtin/logical/kubernetes/path_creds_test.go @@ -0,0 +1,4 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package kubesecrets diff --git a/builtin/logical/kubernetes/path_roles.go b/builtin/logical/kubernetes/path_roles.go new file mode 100644 index 0000000000..6e866a25d3 --- /dev/null +++ b/builtin/logical/kubernetes/path_roles.go @@ -0,0 +1,374 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package kubesecrets + +import ( + "context" + "fmt" + "time" + + "github.com/hashicorp/go-secure-stdlib/strutil" + "github.com/mitchellh/mapstructure" + "github.com/openbao/openbao/sdk/framework" + "github.com/openbao/openbao/sdk/helper/template" + "github.com/openbao/openbao/sdk/logical" +) + +const ( + defaultRoleType = "Role" + rolesPath = "roles/" + defaultNameTemplate = `{{ printf "v-%s-%s-%s-%s" (.DisplayName | truncate 8) (.RoleName | truncate 8) (unix_time) (random 24) | truncate 62 | lowercase }}` +) + +type roleEntry struct { + Name string `json:"name" mapstructure:"name"` + K8sNamespaces []string `json:"allowed_kubernetes_namespaces" mapstructure:"allowed_kubernetes_namespaces"` + K8sNamespaceSelector string `json:"allowed_kubernetes_namespace_selector" mapstructure:"allowed_kubernetes_namespace_selector"` + TokenMaxTTL time.Duration `json:"token_max_ttl" mapstructure:"token_max_ttl"` + TokenDefaultTTL time.Duration `json:"token_default_ttl" mapstructure:"token_default_ttl"` + TokenDefaultAudiences []string `json:"token_default_audiences" mapstructure:"token_default_audiences"` + ServiceAccountName string `json:"service_account_name" mapstructure:"service_account_name"` + K8sRoleName string `json:"kubernetes_role_name" mapstructure:"kubernetes_role_name"` + K8sRoleType string `json:"kubernetes_role_type" mapstructure:"kubernetes_role_type"` + RoleRules string `json:"generated_role_rules" mapstructure:"generated_role_rules"` + NameTemplate string `json:"name_template" mapstructure:"name_template"` + ExtraLabels map[string]string `json:"extra_labels" mapstructure:"extra_labels"` + ExtraAnnotations map[string]string `json:"extra_annotations" mapstructure:"extra_annotations"` +} + +// HasSingleK8sNamespace returns true if the role has a single namespace specified +// and the label selector for Kubernetes namespaces is empty +func (r *roleEntry) HasSingleK8sNamespace() bool { + return r.K8sNamespaceSelector == "" && + len(r.K8sNamespaces) == 1 && r.K8sNamespaces[0] != "" && r.K8sNamespaces[0] != "*" +} + +func (r *roleEntry) toResponseData() (map[string]interface{}, error) { + respData := map[string]interface{}{} + if err := mapstructure.Decode(r, &respData); err != nil { + return nil, err + } + // Format the TTLs as seconds + respData["token_default_ttl"] = r.TokenDefaultTTL.Seconds() + respData["token_max_ttl"] = r.TokenMaxTTL.Seconds() + + return respData, nil +} + +func (b *backend) pathRoles() []*framework.Path { + return []*framework.Path{ + { + Pattern: rolesPath + framework.GenericNameRegex("name"), + DisplayAttrs: &framework.DisplayAttributes{ + OperationPrefix: operationPrefixKubernetes, + OperationSuffix: "role", + }, + Fields: map[string]*framework.FieldSchema{ + "name": { + Type: framework.TypeLowerCaseString, + Description: "Name of the role", + Required: true, + }, + "allowed_kubernetes_namespaces": { + Type: framework.TypeCommaStringSlice, + Description: `A list of the Kubernetes namespaces in which credentials can be generated. If set to "*" all namespaces are allowed.`, + Required: false, + }, + "allowed_kubernetes_namespace_selector": { + Type: framework.TypeString, + Description: `A label selector for Kubernetes namespaces in which credentials can be generated. Accepts either a JSON or YAML object. If set with allowed_kubernetes_namespaces, the conditions are conjuncted.`, + Required: false, + }, + "token_max_ttl": { + Type: framework.TypeDurationSecond, + Description: "The maximum ttl for generated Kubernetes service account tokens. If not set or set to 0, will use system default.", + Required: false, + }, + "token_default_ttl": { + Type: framework.TypeDurationSecond, + Description: "The default ttl for generated Kubernetes service account tokens. If not set or set to 0, will use system default.", + Required: false, + }, + "token_default_audiences": { + Type: framework.TypeCommaStringSlice, + Description: "The default audiences for generated Kubernetes service account tokens. If not set or set to \"\", will use k8s cluster default.", + Required: false, + }, + "service_account_name": { + Type: framework.TypeString, + Description: "The pre-existing service account to generate tokens for. Mutually exclusive with all role parameters. If set, only a Kubernetes service account token will be created.", + Required: false, + }, + "kubernetes_role_name": { + Type: framework.TypeString, + Description: "The pre-existing Role or ClusterRole to bind a generated service account to. If set, Kubernetes token, service account, and role binding objects will be created.", + Required: false, + }, + "kubernetes_role_type": { + Type: framework.TypeString, + Description: "Specifies whether the Kubernetes role is a Role or ClusterRole.", + Required: false, + Default: defaultRoleType, + }, + "generated_role_rules": { + Type: framework.TypeString, + Description: "The Role or ClusterRole rules to use when generating a role. Accepts either a JSON or YAML object. If set, the entire chain of Kubernetes objects will be generated.", + Required: false, + }, + "name_template": { + Type: framework.TypeString, + Description: "The name template to use when generating service accounts, roles and role bindings. If unset, a default template is used.", + Required: false, + }, + "extra_labels": { + Type: framework.TypeKVPairs, + Description: "Additional labels to apply to all generated Kubernetes objects.", + Required: false, + }, + "extra_annotations": { + Type: framework.TypeKVPairs, + Description: "Additional annotations to apply to all generated Kubernetes objects.", + Required: false, + }, + }, + ExistenceCheck: b.pathRoleExistenceCheck("name"), + Operations: map[logical.Operation]framework.OperationHandler{ + logical.ReadOperation: &framework.PathOperation{ + Callback: b.pathRolesRead, + }, + logical.CreateOperation: &framework.PathOperation{ + Callback: b.pathRolesWrite, + }, + logical.UpdateOperation: &framework.PathOperation{ + Callback: b.pathRolesWrite, + }, + logical.DeleteOperation: &framework.PathOperation{ + Callback: b.pathRolesDelete, + }, + }, + HelpSynopsis: rolesHelpSynopsis, + HelpDescription: rolesHelpDescription, + }, + { + Pattern: rolesPath + "?$", + DisplayAttrs: &framework.DisplayAttributes{ + OperationPrefix: operationPrefixKubernetes, + OperationSuffix: "roles", + }, + Operations: map[logical.Operation]framework.OperationHandler{ + logical.ListOperation: &framework.PathOperation{ + Callback: b.pathRolesList, + }, + }, + HelpSynopsis: pathRolesListHelpSynopsis, + HelpDescription: pathRolesListHelpDescription, + }, + } +} + +func (b *backend) pathRoleExistenceCheck(roleFieldName string) framework.ExistenceFunc { + return func(ctx context.Context, req *logical.Request, d *framework.FieldData) (bool, error) { + rName := d.Get(roleFieldName).(string) + r, err := getRole(ctx, req.Storage, rName) + if err != nil { + return false, err + } + return r != nil, nil + } +} + +func (b *backend) pathRolesRead(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + entry, err := getRole(ctx, req.Storage, d.Get("name").(string)) + if err != nil { + return nil, err + } + + if entry == nil { + return nil, nil + } + + respData, err := entry.toResponseData() + if err != nil { + return nil, err + } + return &logical.Response{ + Data: respData, + }, nil +} + +func (b *backend) pathRolesWrite(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + name := d.Get("name").(string) + if name == "" { + return logical.ErrorResponse("role name must be specified"), nil + } + + entry, err := getRole(ctx, req.Storage, name) + if err != nil { + return nil, err + } + + if entry == nil { + entry = &roleEntry{ + Name: name, + } + } + + if k8sNamespaces, ok := d.GetOk("allowed_kubernetes_namespaces"); ok { + // K8s namespaces need to be lowercase + entry.K8sNamespaces = strutil.RemoveDuplicates(k8sNamespaces.([]string), true) + } + if k8sNamespaceSelector, ok := d.GetOk("allowed_kubernetes_namespace_selector"); ok { + entry.K8sNamespaceSelector = k8sNamespaceSelector.(string) + } + if tokenMaxTTLRaw, ok := d.GetOk("token_max_ttl"); ok { + entry.TokenMaxTTL = time.Duration(tokenMaxTTLRaw.(int)) * time.Second + } + if tokenTTLRaw, ok := d.GetOk("token_default_ttl"); ok { + entry.TokenDefaultTTL = time.Duration(tokenTTLRaw.(int)) * time.Second + } + if tokenAudiencesRaw, ok := d.GetOk("token_default_audiences"); ok { + entry.TokenDefaultAudiences = strutil.RemoveDuplicates(tokenAudiencesRaw.([]string), false) + } + if svcAccount, ok := d.GetOk("service_account_name"); ok { + entry.ServiceAccountName = svcAccount.(string) + } + if k8sRoleName, ok := d.GetOk("kubernetes_role_name"); ok { + entry.K8sRoleName = k8sRoleName.(string) + } + + if k8sRoleType, ok := d.GetOk("kubernetes_role_type"); ok { + entry.K8sRoleType = k8sRoleType.(string) + } + if entry.K8sRoleType == "" { + entry.K8sRoleType = defaultRoleType + } + + if roleRules, ok := d.GetOk("generated_role_rules"); ok { + entry.RoleRules = roleRules.(string) + } + if nameTemplate, ok := d.GetOk("name_template"); ok { + entry.NameTemplate = nameTemplate.(string) + } + if extraLabels, ok := d.GetOk("extra_labels"); ok { + entry.ExtraLabels = extraLabels.(map[string]string) + } + if extraAnnotations, ok := d.GetOk("extra_annotations"); ok { + entry.ExtraAnnotations = extraAnnotations.(map[string]string) + } + + // Validate the entry + if len(entry.K8sNamespaces) == 0 && entry.K8sNamespaceSelector == "" { + return logical.ErrorResponse("one (at least) of allowed_kubernetes_namespaces or allowed_kubernetes_namespace_selector must be set"), nil + } + if !onlyOneSet(entry.ServiceAccountName, entry.K8sRoleName, entry.RoleRules) { + return logical.ErrorResponse("one (and only one) of service_account_name, kubernetes_role_name or generated_role_rules must be set"), nil + } + if entry.TokenMaxTTL > 0 && entry.TokenDefaultTTL > entry.TokenMaxTTL { + return logical.ErrorResponse("token_default_ttl %s cannot be greater than token_max_ttl %s", entry.TokenDefaultTTL, entry.TokenMaxTTL), nil + } + + casedRoleType := makeRoleType(entry.K8sRoleType) + if casedRoleType != "Role" && casedRoleType != "ClusterRole" { + return logical.ErrorResponse("kubernetes_role_type must be either 'Role' or 'ClusterRole'"), nil + } + entry.K8sRoleType = casedRoleType + + // Try parsing the label selector as json or yaml + if entry.K8sNamespaceSelector != "" { + if _, err := makeLabelSelector(entry.K8sNamespaceSelector); err != nil { + return logical.ErrorResponse("failed to parse 'allowed_kubernetes_namespace_selector' as k8s.io/api/meta/v1/LabelSelector object"), nil + } + } + + // Try parsing the role rules as json or yaml + if entry.RoleRules != "" { + if _, err := makeRules(entry.RoleRules); err != nil { + return logical.ErrorResponse("failed to parse 'generated_role_rules' as k8s.io/api/rbac/v1/Policy object"), nil + } + } + + // verify the template is valid + nameTemplate := entry.NameTemplate + if nameTemplate == "" { + nameTemplate = defaultNameTemplate + } + _, err = template.NewTemplate(template.Template(nameTemplate)) + if err != nil { + return logical.ErrorResponse("unable to initialize name template: %s", err), nil + } + + if err := setRole(ctx, req.Storage, name, entry); err != nil { + return nil, err + } + + return nil, nil +} + +func (b *backend) pathRolesDelete(ctx context.Context, req *logical.Request, d *framework.FieldData) (resp *logical.Response, err error) { + rName := d.Get("name").(string) + if err := req.Storage.Delete(ctx, rolesPath+rName); err != nil { + return nil, err + } + return nil, nil +} + +func (b *backend) pathRolesList(ctx context.Context, req *logical.Request, d *framework.FieldData) (resp *logical.Response, err error) { + roles, err := req.Storage.List(ctx, rolesPath) + if err != nil { + return nil, fmt.Errorf("failed to list roles: %w", err) + } + return logical.ListResponse(roles), nil +} + +func onlyOneSet(vars ...string) bool { + count := 0 + for _, v := range vars { + if v != "" { + count++ + } + } + return count == 1 +} + +func getRole(ctx context.Context, s logical.Storage, name string) (*roleEntry, error) { + if name == "" { + return nil, fmt.Errorf("missing role name") + } + + entry, err := s.Get(ctx, rolesPath+name) + if err != nil { + return nil, err + } + + if entry == nil { + return nil, nil + } + + var role roleEntry + + if err := entry.DecodeJSON(&role); err != nil { + return nil, err + } + return &role, nil +} + +func setRole(ctx context.Context, s logical.Storage, name string, entry *roleEntry) error { + jsonEntry, err := logical.StorageEntryJSON(rolesPath+name, entry) + if err != nil { + return err + } + + if jsonEntry == nil { + return fmt.Errorf("failed to create storage entry for role %q", name) + } + + return s.Put(ctx, jsonEntry) +} + +const ( + rolesHelpSynopsis = `Manage the roles that can be created with this secrets engine.` + rolesHelpDescription = `This path lets you manage the roles that can be created with this secrets engine.` + pathRolesListHelpSynopsis = `List the existing roles in this secrets engine.` + pathRolesListHelpDescription = `A list of existing role names will be returned.` +) diff --git a/builtin/logical/kubernetes/path_roles_test.go b/builtin/logical/kubernetes/path_roles_test.go new file mode 100644 index 0000000000..ff365b4338 --- /dev/null +++ b/builtin/logical/kubernetes/path_roles_test.go @@ -0,0 +1,423 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package kubesecrets + +import ( + "context" + "testing" + "time" + + "github.com/openbao/openbao/sdk/logical" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRoles(t *testing.T) { + b, s := getTestBackend(t) + + t.Run("create role - fail", func(t *testing.T) { + resp, err := testRoleCreate(t, b, s, "badrole", map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"*"}, + }) + assert.NoError(t, err) + assert.EqualError(t, resp.Error(), "one (and only one) of service_account_name, kubernetes_role_name or generated_role_rules must be set") + + resp, err = testRoleCreate(t, b, s, "badrole", map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"*"}, + "service_account_name": "test_svc_account", + "kubernetes_role_name": "existing_role", + }) + assert.NoError(t, err) + assert.EqualError(t, resp.Error(), "one (and only one) of service_account_name, kubernetes_role_name or generated_role_rules must be set") + + resp, err = testRoleCreate(t, b, s, "badrole", map[string]interface{}{ + "service_account_name": "test_svc_account", + }) + assert.NoError(t, err) + assert.EqualError(t, resp.Error(), "one (at least) of allowed_kubernetes_namespaces or allowed_kubernetes_namespace_selector must be set") + + resp, err = testRoleCreate(t, b, s, "badrole", map[string]interface{}{ + "allowed_kubernetes_namespace_selector": badYAMLSelector, + "kubernetes_role_name": "existing_role", + }) + assert.NoError(t, err) + assert.EqualError(t, resp.Error(), "failed to parse 'allowed_kubernetes_namespace_selector' as k8s.io/api/meta/v1/LabelSelector object") + + resp, err = testRoleCreate(t, b, s, "badrole", map[string]interface{}{ + "allowed_kubernetes_namespace_selector": badJSONSelector, + "kubernetes_role_name": "existing_role", + }) + assert.NoError(t, err) + assert.EqualError(t, resp.Error(), "failed to parse 'allowed_kubernetes_namespace_selector' as k8s.io/api/meta/v1/LabelSelector object") + + resp, err = testRoleCreate(t, b, s, "badrole", map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"app1", "app2"}, + "generated_role_rules": badYAMLRules, + }) + assert.NoError(t, err) + assert.EqualError(t, resp.Error(), "failed to parse 'generated_role_rules' as k8s.io/api/rbac/v1/Policy object") + + resp, err = testRoleCreate(t, b, s, "badrole", map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"app1", "app2"}, + "generated_role_rules": badJSONRules, + }) + assert.NoError(t, err) + assert.EqualError(t, resp.Error(), "failed to parse 'generated_role_rules' as k8s.io/api/rbac/v1/Policy object") + + badmeta := map[string]interface{}{ + "foo": []string{"one", "two"}, + } + resp, err = testRoleCreate(t, b, s, "badmeta", map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"*"}, + "service_account_name": "test_svc_account", + "extra_labels": badmeta, + "extra_annotations": badmeta, + }) + assert.NoError(t, err) + assert.Contains(t, resp.Error().Error(), "Field validation failed") + + resp, err = testRoleCreate(t, b, s, "badrole", map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"app1", "app2"}, + "service_account_name": "test_svc_account", + "kubernetes_role_type": "notARole", + }) + assert.NoError(t, err) + assert.EqualError(t, resp.Error(), "kubernetes_role_type must be either 'Role' or 'ClusterRole'") + + resp, err = testRoleCreate(t, b, s, "badttl_tokenmax", map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"app1", "app2"}, + "service_account_name": "test_svc_account", + "token_default_ttl": "11h", + "token_max_ttl": "5h", + }) + assert.NoError(t, err) + assert.EqualError(t, resp.Error(), "token_default_ttl 11h0m0s cannot be greater than token_max_ttl 5h0m0s") + + resp, err = testRoleCreate(t, b, s, "badtemplate", map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"app1", "app2"}, + "service_account_name": "test_svc_account", + "name_template": "{{.String", + }) + assert.NoError(t, err) + assert.EqualError(t, resp.Error(), "unable to initialize name template: unable to parse template: template: template:1: unclosed action") + }) + + t.Run("delete role - non-existant and blank", func(t *testing.T) { + resp, err := testRolesDelete(t, b, s, "nope") + assert.NoError(t, err) + assert.Nil(t, resp) + + resp, err = testRolesDelete(t, b, s, "") + assert.EqualError(t, err, "unsupported operation") + assert.Nil(t, resp) + }) + + t.Run("full role crud", func(t *testing.T) { + // No roles yet, list is empty + resp, err := testRolesList(t, b, s) + require.NoError(t, err) + assert.Empty(t, resp.Data) + + // Create one with json namespace label selector + resp, err = testRoleCreate(t, b, s, "jsonselector", map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"test"}, + "allowed_kubernetes_namespace_selector": goodJSONSelector, + "kubernetes_role_name": "existing_role", + "token_default_ttl": "5h", + "token_default_audiences": []string{"foobar"}, + }) + assert.NoError(t, err) + assert.NoError(t, resp.Error()) + + resp, err = testRoleRead(t, b, s, "jsonselector") + require.NoError(t, err) + var nilMeta map[string]string + assert.Equal(t, map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"test"}, + "allowed_kubernetes_namespace_selector": goodJSONSelector, + "extra_labels": nilMeta, + "extra_annotations": nilMeta, + "generated_role_rules": "", + "kubernetes_role_name": "existing_role", + "kubernetes_role_type": "Role", + "name": "jsonselector", + "name_template": "", + "service_account_name": "", + "token_max_ttl": time.Duration(0).Seconds(), + "token_default_ttl": time.Duration(time.Hour * 5).Seconds(), + "token_default_audiences": []string{"foobar"}, + }, resp.Data) + + // Create one with yaml namespace selector and metadata + resp, err = testRoleCreate(t, b, s, "yamlselector", map[string]interface{}{ + "allowed_kubernetes_namespace_selector": goodYAMLSelector, + "extra_annotations": testExtraAnnotations, + "extra_labels": testExtraLabels, + "kubernetes_role_name": "existing_role", + "kubernetes_role_type": "role", + "token_default_audiences": []string{"foobar"}, + }) + assert.NoError(t, err) + assert.NoError(t, resp.Error()) + + resp, err = testRoleRead(t, b, s, "yamlselector") + require.NoError(t, err) + assert.Equal(t, map[string]interface{}{ + "allowed_kubernetes_namespaces": []string(nil), + "allowed_kubernetes_namespace_selector": goodYAMLSelector, + "extra_annotations": testExtraAnnotations, + "extra_labels": testExtraLabels, + "generated_role_rules": "", + "kubernetes_role_name": "existing_role", + "kubernetes_role_type": "Role", + "name": "yamlselector", + "name_template": "", + "service_account_name": "", + "token_max_ttl": time.Duration(0).Seconds(), + "token_default_ttl": time.Duration(0).Seconds(), + "token_default_audiences": []string{"foobar"}, + }, resp.Data) + + // Create one with json role rules + resp, err = testRoleCreate(t, b, s, "jsonrules", map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"app1", "app2"}, + "generated_role_rules": goodJSONRules, + "token_default_ttl": "5h", + "token_default_audiences": []string{"foobar"}, + }) + assert.NoError(t, err) + assert.NoError(t, resp.Error()) + + resp, err = testRoleRead(t, b, s, "jsonrules") + require.NoError(t, err) + assert.Equal(t, map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"app1", "app2"}, + "allowed_kubernetes_namespace_selector": "", + "extra_labels": nilMeta, + "extra_annotations": nilMeta, + "generated_role_rules": goodJSONRules, + "kubernetes_role_name": "", + "kubernetes_role_type": "Role", + "name": "jsonrules", + "name_template": "", + "service_account_name": "", + "token_max_ttl": time.Duration(0).Seconds(), + "token_default_ttl": time.Duration(time.Hour * 5).Seconds(), + "token_default_audiences": []string{"foobar"}, + }, resp.Data) + + // Create one with yaml role rules and metadata + resp, err = testRoleCreate(t, b, s, "yamlrules", map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"app1", "app2"}, + "extra_annotations": testExtraAnnotations, + "extra_labels": testExtraLabels, + "generated_role_rules": goodYAMLRules, + "kubernetes_role_type": "role", + "token_default_audiences": []string{"foobar"}, + }) + assert.NoError(t, err) + assert.NoError(t, resp.Error()) + + resp, err = testRoleRead(t, b, s, "yamlrules") + require.NoError(t, err) + assert.Equal(t, map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"app1", "app2"}, + "allowed_kubernetes_namespace_selector": "", + "extra_annotations": testExtraAnnotations, + "extra_labels": testExtraLabels, + "generated_role_rules": goodYAMLRules, + "kubernetes_role_name": "", + "kubernetes_role_type": "Role", + "name": "yamlrules", + "name_template": "", + "service_account_name": "", + "token_max_ttl": time.Duration(0).Seconds(), + "token_default_ttl": time.Duration(0).Seconds(), + "token_default_audiences": []string{"foobar"}, + }, resp.Data) + + // update yamlrules (with a duplicate namespace) + resp, err = testRoleCreate(t, b, s, "yamlrules", map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"app3", "app4", "App4"}, + }) + assert.NoError(t, err) + assert.NoError(t, resp.Error()) + resp, err = testRoleRead(t, b, s, "yamlrules") + require.NoError(t, err) + assert.Equal(t, map[string]interface{}{ + "allowed_kubernetes_namespaces": []string{"app3", "app4"}, + "allowed_kubernetes_namespace_selector": "", + "extra_annotations": testExtraAnnotations, + "extra_labels": testExtraLabels, + "generated_role_rules": goodYAMLRules, + "kubernetes_role_name": "", + "kubernetes_role_type": "Role", + "name": "yamlrules", + "name_template": "", + "service_account_name": "", + "token_max_ttl": time.Duration(0).Seconds(), + "token_default_ttl": time.Duration(0).Seconds(), + "token_default_audiences": []string{"foobar"}, + }, resp.Data) + + // Now there should be four roles returned from list + resp, err = testRolesList(t, b, s) + require.NoError(t, err) + assert.Equal(t, map[string]interface{}{ + "keys": []string{"jsonrules", "jsonselector", "yamlrules", "yamlselector"}, + }, resp.Data) + + // Delete one + resp, err = testRolesDelete(t, b, s, "jsonrules") + require.NoError(t, err) + // Now there should be three + resp, err = testRolesList(t, b, s) + require.NoError(t, err) + assert.Equal(t, map[string]interface{}{ + "keys": []string{"jsonselector", "yamlrules", "yamlselector"}, + }, resp.Data) + // Delete the last three + resp, err = testRolesDelete(t, b, s, "yamlrules") + require.NoError(t, err) + resp, err = testRolesDelete(t, b, s, "jsonselector") + require.NoError(t, err) + resp, err = testRolesDelete(t, b, s, "yamlselector") + require.NoError(t, err) + // Now there should be none + resp, err = testRolesList(t, b, s) + require.NoError(t, err) + assert.Empty(t, resp.Data) + }) +} + +func testRoleCreate(t *testing.T, b *backend, s logical.Storage, name string, d map[string]interface{}) (*logical.Response, error) { + t.Helper() + + resp, err := b.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.CreateOperation, + Path: rolesPath + name, + Data: d, + Storage: s, + }) + if err != nil { + return nil, err + } + + return resp, nil +} + +func testRoleRead(t *testing.T, b *backend, s logical.Storage, name string) (*logical.Response, error) { + t.Helper() + return b.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.ReadOperation, + Path: rolesPath + name, + Storage: s, + }) +} + +func testRolesList(t *testing.T, b *backend, s logical.Storage) (*logical.Response, error) { + t.Helper() + return b.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.ListOperation, + Path: rolesPath, + Storage: s, + }) +} + +func testRolesDelete(t *testing.T, b *backend, s logical.Storage, name string) (*logical.Response, error) { + t.Helper() + return b.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.DeleteOperation, + Path: rolesPath + name, + Storage: s, + }) +} + +var ( + testExtraLabels = map[string]string{ + "one": "two", + } + testExtraAnnotations = map[string]string{ + "test": "annotation", + } +) + +const ( + goodJSONSelector = `{ + "matchLabels": { + "stage": "prod", + "app": "vault" + } +}` + + badJSONSelector = `{ + "matchLabels": + "stage": "prod", + "app": "vault" +}` + + goodYAMLSelector = `matchLabels: + stage: prod + app: vault +` + badYAMLSelector = `matchLabels: +- stage: prod +- app: vault +` + + goodJSONRules = `"rules": [ + { + "apiGroups": [ + "admissionregistration.k8s.io" + ], + "resources": [ + "mutatingwebhookconfigurations" + ], + "verbs": [ + "get", + "list", + "watch", + "patch" + ] + } +]` + badJSONRules = `"rules": [ + { + apiGroups: + "admissionregistration.k8s.io" + "resources": [ + "mutatingwebhookconfigurations" + ], + "verbs": [ + "get", + "list", + "watch", + "patch" + ], + } +]` + + goodYAMLRules = `rules: +- apiGroups: + - admissionregistration.k8s.io + resources: + - mutatingwebhookconfigurations + verbs: + - get + - list + - watch + - patch +` + badYAMLRules = `rules: += apiGroups: + - admissionregistration.k8s.io + resources: + ? mutatingwebhookconfigurations + verbs: + - get + - list + - watch + - patch +` +) diff --git a/builtin/logical/kubernetes/scripts/gofmtcheck.sh b/builtin/logical/kubernetes/scripts/gofmtcheck.sh new file mode 100755 index 0000000000..a256b92bd4 --- /dev/null +++ b/builtin/logical/kubernetes/scripts/gofmtcheck.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + + +echo "==> Checking that code complies with gofumpt requirements..." + +gofmt_files=$(gofumpt -l `find . -name '*.go'`) +if [[ -n ${gofmt_files} ]]; then + echo 'gofumpt needs running on the following files:' + echo "${gofmt_files}" + echo "You can use the command: \`make fmt\` to reformat code." + exit 1 +fi diff --git a/builtin/logical/kubernetes/scripts/local_dev.sh b/builtin/logical/kubernetes/scripts/local_dev.sh new file mode 100755 index 0000000000..1f6c3b9815 --- /dev/null +++ b/builtin/logical/kubernetes/scripts/local_dev.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +set -e + +MNT_PATH="kube-secrets" +PLUGIN_NAME="vault-plugin-secrets-kubernetes" +PLUGIN_CATALOG_NAME="vault-plugin-secrets-kubernetes" + +# +# Helper script for local development. Automatically builds and registers the +# plugin. Requires `vault` is installed and available on $PATH. +# + +# Get the right dir +DIR="$(cd "$(dirname "$(readlink "$0")")" && pwd)" + +echo "==> Starting dev" + +echo "--> Scratch dir" +echo " Creating" +SCRATCH="$DIR/tmp" +mkdir -p "$SCRATCH/plugins" + +echo "--> Vault server" +echo " Writing config" +tee "$SCRATCH/vault.hcl" > /dev/null < Cleaning up" + kill -INT "$VAULT_PID" + rm -rf "$SCRATCH" +} +trap cleanup EXIT + +echo " Authing" +vault login root &>/dev/null + +echo "--> Building" +go build -o "$SCRATCH/plugins/$PLUGIN_NAME" "./cmd/$PLUGIN_NAME" +SHASUM=$(shasum -a 256 "$SCRATCH/plugins/$PLUGIN_NAME" | cut -d " " -f1) + +echo " Registering plugin" +vault write sys/plugins/catalog/$PLUGIN_CATALOG_NAME \ + sha_256="$SHASUM" \ + command="$PLUGIN_NAME" + +echo " Mounting plugin" +vault secrets enable -path=$MNT_PATH -plugin-name=$PLUGIN_CATALOG_NAME -listing-visibility=unauth plugin + +if [ -e scripts/custom.sh ] +then + . scripts/custom.sh +fi + +echo "==> Ready!" +wait $! diff --git a/builtin/logical/kubernetes/wal.go b/builtin/logical/kubernetes/wal.go new file mode 100644 index 0000000000..4c39f40ab8 --- /dev/null +++ b/builtin/logical/kubernetes/wal.go @@ -0,0 +1,130 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package kubesecrets + +import ( + "context" + "fmt" + "time" + + "github.com/mitchellh/mapstructure" + "github.com/openbao/openbao/sdk/logical" +) + +const ( + walRoleKind = "role" + walBindingKind = "roleBinding" +) + +// Eventually expire the WAL if for some reason the rollback operation consistently fails +var maxWALAge = 24 * time.Hour + +func (b *backend) walRollback(ctx context.Context, req *logical.Request, kind string, data interface{}) error { + switch kind { + case walRoleKind: + return b.rollbackRoleWAL(ctx, req, data) + case walBindingKind: + return b.rollbackRoleBindingWAL(ctx, req, data) + default: + return fmt.Errorf("unknown rollback type %q", kind) + } +} + +type walRole struct { + Namespace string + Name string + RoleType string + Expiration time.Time +} + +// rollbackRoleWAL uses the info in a walRole entry to delete a Role/ClusterRole +// from Kubernetes. We're relying on Kubernetes garbage collection to delete the +// other related objects (RoleBinding/ClusterRoleBinding and ServiceAccount) +// since they should have an owner reference to the Role/ClusterRole +func (b *backend) rollbackRoleWAL(ctx context.Context, req *logical.Request, data interface{}) error { + // Decode the WAL data + var entry walRole + d, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ + DecodeHook: mapstructure.StringToTimeHookFunc(time.RFC3339), + Result: &entry, + }) + if err != nil { + return err + } + err = d.Decode(data) + if err != nil { + return err + } + + client, err := b.getClient(ctx, req.Storage) + if err != nil { + return err + } + + b.Logger().Debug("rolling back", "role", entry.RoleType, "namespace", entry.Namespace, "name", entry.Name) + + // Attempt to delete the Role. If we don't succeed within maxWALAge (e.g. + // client creds are somehow incorrect and the delete will never succeed), + // unconditionally remove the WAL. + if err := client.deleteRole(ctx, entry.Namespace, entry.Name, entry.RoleType); err != nil { + b.Logger().Warn("rollback error deleting", "roleType", entry.RoleType, "namespace", entry.Namespace, "name", entry.Name, "err", err) + + if time.Now().After(entry.Expiration) { + b.Logger().Warn("giving up deleting", "roleType", entry.RoleType, "namespace", entry.Namespace, "name", entry.Name) + return nil + } + return err + } + + return nil +} + +type walRoleBinding struct { + Namespace string + Name string + IsCluster bool + Expiration time.Time +} + +// rollbackRoleBindingWAL uses the info in a walRole entry to delete a +// Role/ClusterRole from Kubernetes. We're relying on Kubernetes garbage +// collection to delete the related ServiceAccount since it should have an owner +// reference to the RoleBinding/ClusterRoleBinding +func (b *backend) rollbackRoleBindingWAL(ctx context.Context, req *logical.Request, data interface{}) error { + // Decode the WAL data + var entry walRoleBinding + d, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ + DecodeHook: mapstructure.StringToTimeHookFunc(time.RFC3339), + Result: &entry, + }) + if err != nil { + return err + } + err = d.Decode(data) + if err != nil { + return err + } + + client, err := b.getClient(ctx, req.Storage) + if err != nil { + return err + } + + b.Logger().Debug("rolling back role binding", "isClusterRoleBinding", entry.IsCluster, "namespace", entry.Namespace, "name", entry.Name) + + // Attempt to delete the RoleBinding. If we don't succeed within maxWALAge + // (e.g. client creds are somehow incorrect and the delete will never + // succeed), unconditionally remove the WAL. + if err := client.deleteRoleBinding(ctx, entry.Namespace, entry.Name, entry.IsCluster); err != nil { + b.Logger().Warn("rollback error deleting role binding", "isClusterRoleBinding", entry.IsCluster, "namespace", entry.Namespace, "name", entry.Name, "err", err) + + if time.Now().After(entry.Expiration) { + b.Logger().Warn("giving up deleting role binding", "isClusterRoleBinding", entry.IsCluster, "namespace", entry.Namespace, "name", entry.Name) + return nil + } + return err + } + + return nil +} diff --git a/go.mod b/go.mod index 3471ff9ddd..58bfcd5c76 100644 --- a/go.mod +++ b/go.mod @@ -67,6 +67,7 @@ require ( github.com/hashicorp/go-retryablehttp v0.7.2 github.com/hashicorp/go-rootcerts v1.0.2 github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 + github.com/hashicorp/go-secure-stdlib/fileutil v0.1.0 github.com/hashicorp/go-secure-stdlib/gatedwriter v0.1.1 github.com/hashicorp/go-secure-stdlib/kv-builder v0.1.2 github.com/hashicorp/go-secure-stdlib/mlock v0.1.3 @@ -168,6 +169,7 @@ require ( honnef.co/go/tools v0.4.3 k8s.io/api v0.27.2 k8s.io/apimachinery v0.27.2 + k8s.io/client-go v0.27.2 k8s.io/utils v0.0.0-20230220204549-a5ecb0141aa5 layeh.com/radius v0.0.0-20190322222518-890bc1058917 mvdan.cc/gofumpt v0.3.1 @@ -367,7 +369,6 @@ require ( gopkg.in/resty.v1 v1.12.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/client-go v0.27.2 // indirect k8s.io/klog/v2 v2.90.1 // indirect k8s.io/kube-openapi v0.0.0-20230501164219-8b0f38b5fd1f // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect diff --git a/go.sum b/go.sum index d4d36053d1..2e8ee2f769 100644 --- a/go.sum +++ b/go.sum @@ -574,6 +574,8 @@ github.com/hashicorp/go-secure-stdlib/awsutil v0.2.3/go.mod h1:oKHSQs4ivIfZ3fbXG github.com/hashicorp/go-secure-stdlib/base62 v0.1.1/go.mod h1:EdWO6czbmthiwZ3/PUsDV+UD1D5IRU4ActiaWGwt0Yw= github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 h1:ET4pqyjiGmY09R5y+rSd70J2w45CtbWDNvGqWp/R3Ng= github.com/hashicorp/go-secure-stdlib/base62 v0.1.2/go.mod h1:EdWO6czbmthiwZ3/PUsDV+UD1D5IRU4ActiaWGwt0Yw= +github.com/hashicorp/go-secure-stdlib/fileutil v0.1.0 h1:f2mwVgMJjXuX/+eWD6ZW30+oIRgCofL+XMWknFkB1WM= +github.com/hashicorp/go-secure-stdlib/fileutil v0.1.0/go.mod h1:uwcr2oga9pN5+OkHZyTN5MDk3+1YHOuMukhpnPaQAoI= github.com/hashicorp/go-secure-stdlib/gatedwriter v0.1.1 h1:9um9R8i0+HbRHS9d64kdvWR0/LJvo12sIonvR9zr1+U= github.com/hashicorp/go-secure-stdlib/gatedwriter v0.1.1/go.mod h1:6RoRTSMDK2H/rKh3P/JIsk1tK8aatKTt3JyvIopi3GQ= github.com/hashicorp/go-secure-stdlib/kv-builder v0.1.2 h1:NS6BHieb/pDfx3M9jDdaPpGyyVp+aD4A3DjX3dgRmzs= diff --git a/helper/builtinplugins/registry.go b/helper/builtinplugins/registry.go index 53580a86b3..2526950df9 100644 --- a/helper/builtinplugins/registry.go +++ b/helper/builtinplugins/registry.go @@ -14,6 +14,7 @@ import ( credLdap "github.com/openbao/openbao/builtin/credential/ldap" credRadius "github.com/openbao/openbao/builtin/credential/radius" credUserpass "github.com/openbao/openbao/builtin/credential/userpass" + logicalKube "github.com/openbao/openbao/builtin/logical/kubernetes" logicalPki "github.com/openbao/openbao/builtin/logical/pki" logicalRabbit "github.com/openbao/openbao/builtin/logical/rabbitmq" logicalSsh "github.com/openbao/openbao/builtin/logical/ssh"